experiments in a post-browser web
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();