Something went wrong. Try again.
experiments in a post-browser web
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218/** * Script Executor - Core execution engine for userscripts * * Handles: * - Pattern matching (glob patterns) * - Timeout protection * - Console capture * - Dual execution modes (peek:// and http://) */
export class ScriptExecutor { constructor() { this.defaultTimeout = 5000; // 5 seconds }
/** * Execute a script against a page URL or DOM * @param {object} script - Script object with code, matchPatterns, etc. * @param {object} executionContext - Context object with url, pageDOM, pageWindow, timeout * @returns {Promise<object>} Execution result */ async executeScript(script, executionContext) { const { url, pageDOM = document, pageWindow = window, timeout = this.defaultTimeout } = executionContext;
// Validate script matches URL if (!this.matchesUrl(script, url)) { return { status: 'skipped', reason: 'URL does not match script patterns' }; }
try { const result = await this.runScriptInContext( script.code, pageDOM, pageWindow, timeout );
return { status: 'success', result: result.result, executionTime: result.time, output: result.output }; } catch (error) { return { status: 'error', error: error.message, stack: error.stack }; } }
/** * Check if script's match patterns apply to URL * @param {object} script - Script with matchPatterns and excludePatterns * @param {string} url - URL to test * @returns {boolean} */ matchesUrl(script, url) { const matches = script.matchPatterns.some(pattern => this.matchPattern(pattern, url) );
const excluded = script.excludePatterns && script.excludePatterns.length > 0 ? script.excludePatterns.some(pattern => this.matchPattern(pattern, url)) : false;
return matches && !excluded; }
/** * Match a single pattern against URL * Supports glob patterns: https://example.com/*, *://example.com/*, etc. * @param {string} pattern - Glob pattern * @param {string} url - URL to test * @returns {boolean} */ matchPattern(pattern, url) { // Special case: match all if (pattern === '*' || pattern === '<all_urls>') { return true; }
// Convert glob pattern to regex const regex = new RegExp( '^' + pattern .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special chars .replace(/\*/g, '.*') // * -> .* .replace(/\?/g, '.') // ? -> . + '$' );
return regex.test(url); }
/** * Execute script with timeout, capturing console output. * Uses a Web Worker via blob URL to avoid CSP unsafe-eval restrictions * and to provide true timeout protection (worker.terminate() kills infinite loops). * @param {string} code - JavaScript code to execute * @param {Document} doc - Document object (not available inside Worker) * @param {Window} win - Window object (not available inside Worker) * @param {number} timeout - Timeout in milliseconds * @returns {Promise<object>} Result with time, output, and result */ async runScriptInContext(code, doc, win, timeout) { const startTime = performance.now();
// Build Worker code that: // 1. Provides minimal document/window stubs so scripts don't throw on DOM access // 2. Executes the user code in an async wrapper // 3. Posts the result (or error) back to the main thread const workerCode = ` // Minimal DOM stubs - querySelector returns null, etc. const document = { querySelector: () => null, querySelectorAll: () => [], getElementById: () => null, getElementsByClassName: () => [], getElementsByTagName: () => [], createElement: () => ({ style: {}, setAttribute: () => {}, appendChild: () => {} }), head: { appendChild: () => {} }, body: { appendChild: () => {} } }; const window = self;
const __logs = []; const __origLog = console.log; const __origError = console.error; const __origWarn = console.warn; console.log = (...args) => { __logs.push({ level: 'log', message: args.map(a => typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a)).join(' ') }); __origLog(...args); }; console.error = (...args) => { __logs.push({ level: 'error', message: args.map(a => typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a)).join(' ') }); __origError(...args); }; console.warn = (...args) => { __logs.push({ level: 'warn', message: args.map(a => typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a)).join(' ') }); __origWarn(...args); };
(async () => { try { const result = await (async () => { ${code} })(); postMessage({ type: 'result', result, logs: __logs }); } catch (e) { postMessage({ type: 'error', error: e.message, stack: e.stack, logs: __logs }); } })(); `;
const blob = new Blob([workerCode], { type: 'application/javascript' }); const blobUrl = URL.createObjectURL(blob); const worker = new Worker(blobUrl);
return new Promise((resolve, reject) => { const timer = setTimeout(() => { worker.terminate(); URL.revokeObjectURL(blobUrl); reject(new Error('Script timeout')); }, timeout);
worker.onmessage = (e) => { clearTimeout(timer); worker.terminate(); URL.revokeObjectURL(blobUrl);
const { type, result, error, stack, logs } = e.data; if (type === 'error') { // Re-throw as an Error so the caller gets status: 'error' const err = new Error(error); err.stack = stack; reject(err); } else { resolve({ time: Math.round(performance.now() - startTime), output: logs || [], result }); } };
worker.onerror = (e) => { clearTimeout(timer); worker.terminate(); URL.revokeObjectURL(blobUrl); reject(new Error(e.message || 'Worker error')); }; }); }
/** * Format console arguments for storage * @param {Array} args - Console arguments * @returns {string} */ formatArgs(args) { return args.map(a => typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a) ).join(' '); }}
// Export singleton instanceexport const scriptExecutor = new ScriptExecutor();