experiments in a post-browser web
at main 217 lines 6.7 kB view raw
1/** 2 * Script Executor - Core execution engine for userscripts 3 * 4 * Handles: 5 * - Pattern matching (glob patterns) 6 * - Timeout protection 7 * - Console capture 8 * - Dual execution modes (peek:// and http://) 9 */ 10 11export class ScriptExecutor { 12 constructor() { 13 this.defaultTimeout = 5000; // 5 seconds 14 } 15 16 /** 17 * Execute a script against a page URL or DOM 18 * @param {object} script - Script object with code, matchPatterns, etc. 19 * @param {object} executionContext - Context object with url, pageDOM, pageWindow, timeout 20 * @returns {Promise<object>} Execution result 21 */ 22 async executeScript(script, executionContext) { 23 const { 24 url, 25 pageDOM = document, 26 pageWindow = window, 27 timeout = this.defaultTimeout 28 } = executionContext; 29 30 // Validate script matches URL 31 if (!this.matchesUrl(script, url)) { 32 return { 33 status: 'skipped', 34 reason: 'URL does not match script patterns' 35 }; 36 } 37 38 try { 39 const result = await this.runScriptInContext( 40 script.code, 41 pageDOM, 42 pageWindow, 43 timeout 44 ); 45 46 return { 47 status: 'success', 48 result: result.result, 49 executionTime: result.time, 50 output: result.output 51 }; 52 } catch (error) { 53 return { 54 status: 'error', 55 error: error.message, 56 stack: error.stack 57 }; 58 } 59 } 60 61 /** 62 * Check if script's match patterns apply to URL 63 * @param {object} script - Script with matchPatterns and excludePatterns 64 * @param {string} url - URL to test 65 * @returns {boolean} 66 */ 67 matchesUrl(script, url) { 68 const matches = script.matchPatterns.some(pattern => 69 this.matchPattern(pattern, url) 70 ); 71 72 const excluded = script.excludePatterns && script.excludePatterns.length > 0 73 ? script.excludePatterns.some(pattern => this.matchPattern(pattern, url)) 74 : false; 75 76 return matches && !excluded; 77 } 78 79 /** 80 * Match a single pattern against URL 81 * Supports glob patterns: https://example.com/*, *://example.com/*, etc. 82 * @param {string} pattern - Glob pattern 83 * @param {string} url - URL to test 84 * @returns {boolean} 85 */ 86 matchPattern(pattern, url) { 87 // Special case: match all 88 if (pattern === '*' || pattern === '<all_urls>') { 89 return true; 90 } 91 92 // Convert glob pattern to regex 93 const regex = new RegExp( 94 '^' + pattern 95 .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special chars 96 .replace(/\*/g, '.*') // * -> .* 97 .replace(/\?/g, '.') // ? -> . 98 + '$' 99 ); 100 101 return regex.test(url); 102 } 103 104 /** 105 * Execute script with timeout, capturing console output. 106 * Uses a Web Worker via blob URL to avoid CSP unsafe-eval restrictions 107 * and to provide true timeout protection (worker.terminate() kills infinite loops). 108 * @param {string} code - JavaScript code to execute 109 * @param {Document} doc - Document object (not available inside Worker) 110 * @param {Window} win - Window object (not available inside Worker) 111 * @param {number} timeout - Timeout in milliseconds 112 * @returns {Promise<object>} Result with time, output, and result 113 */ 114 async runScriptInContext(code, doc, win, timeout) { 115 const startTime = performance.now(); 116 117 // Build Worker code that: 118 // 1. Provides minimal document/window stubs so scripts don't throw on DOM access 119 // 2. Executes the user code in an async wrapper 120 // 3. Posts the result (or error) back to the main thread 121 const workerCode = ` 122 // Minimal DOM stubs - querySelector returns null, etc. 123 const document = { 124 querySelector: () => null, 125 querySelectorAll: () => [], 126 getElementById: () => null, 127 getElementsByClassName: () => [], 128 getElementsByTagName: () => [], 129 createElement: () => ({ style: {}, setAttribute: () => {}, appendChild: () => {} }), 130 head: { appendChild: () => {} }, 131 body: { appendChild: () => {} } 132 }; 133 const window = self; 134 135 const __logs = []; 136 const __origLog = console.log; 137 const __origError = console.error; 138 const __origWarn = console.warn; 139 console.log = (...args) => { 140 __logs.push({ level: 'log', message: args.map(a => typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a)).join(' ') }); 141 __origLog(...args); 142 }; 143 console.error = (...args) => { 144 __logs.push({ level: 'error', message: args.map(a => typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a)).join(' ') }); 145 __origError(...args); 146 }; 147 console.warn = (...args) => { 148 __logs.push({ level: 'warn', message: args.map(a => typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a)).join(' ') }); 149 __origWarn(...args); 150 }; 151 152 (async () => { 153 try { 154 const result = await (async () => { 155 ${code} 156 })(); 157 postMessage({ type: 'result', result, logs: __logs }); 158 } catch (e) { 159 postMessage({ type: 'error', error: e.message, stack: e.stack, logs: __logs }); 160 } 161 })(); 162 `; 163 164 const blob = new Blob([workerCode], { type: 'application/javascript' }); 165 const blobUrl = URL.createObjectURL(blob); 166 const worker = new Worker(blobUrl); 167 168 return new Promise((resolve, reject) => { 169 const timer = setTimeout(() => { 170 worker.terminate(); 171 URL.revokeObjectURL(blobUrl); 172 reject(new Error('Script timeout')); 173 }, timeout); 174 175 worker.onmessage = (e) => { 176 clearTimeout(timer); 177 worker.terminate(); 178 URL.revokeObjectURL(blobUrl); 179 180 const { type, result, error, stack, logs } = e.data; 181 if (type === 'error') { 182 // Re-throw as an Error so the caller gets status: 'error' 183 const err = new Error(error); 184 err.stack = stack; 185 reject(err); 186 } else { 187 resolve({ 188 time: Math.round(performance.now() - startTime), 189 output: logs || [], 190 result 191 }); 192 } 193 }; 194 195 worker.onerror = (e) => { 196 clearTimeout(timer); 197 worker.terminate(); 198 URL.revokeObjectURL(blobUrl); 199 reject(new Error(e.message || 'Worker error')); 200 }; 201 }); 202 } 203 204 /** 205 * Format console arguments for storage 206 * @param {Array} args - Console arguments 207 * @returns {string} 208 */ 209 formatArgs(args) { 210 return args.map(a => 211 typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a) 212 ).join(' '); 213 } 214} 215 216// Export singleton instance 217export const scriptExecutor = new ScriptExecutor();