browserforce 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +293 -0
- package/bin.js +269 -0
- package/mcp/package.json +14 -0
- package/mcp/src/exec-engine.js +424 -0
- package/mcp/src/index.js +372 -0
- package/mcp/src/snapshot.js +197 -0
- package/package.json +52 -0
- package/relay/package.json +1 -0
- package/relay/src/index.js +847 -0
- package/skills/browserforce/SKILL.md +123 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
// BrowserForce — Shared Execution Engine
|
|
2
|
+
// Used by both MCP server and CLI.
|
|
3
|
+
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import {
|
|
8
|
+
TEST_ID_ATTRS,
|
|
9
|
+
buildSnapshotText, parseSearchPattern, annotateStableAttrs,
|
|
10
|
+
} from './snapshot.js';
|
|
11
|
+
|
|
12
|
+
// ─── Configuration ───────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export const BF_DIR = join(homedir(), '.browserforce');
|
|
15
|
+
export const CDP_URL_FILE = join(BF_DIR, 'cdp-url');
|
|
16
|
+
|
|
17
|
+
export function getCdpUrl() {
|
|
18
|
+
if (process.env.BF_CDP_URL) return process.env.BF_CDP_URL;
|
|
19
|
+
try {
|
|
20
|
+
const url = readFileSync(CDP_URL_FILE, 'utf8').trim();
|
|
21
|
+
if (url) return url;
|
|
22
|
+
} catch { /* fall through */ }
|
|
23
|
+
throw new Error(
|
|
24
|
+
'Cannot find CDP URL. Either:\n' +
|
|
25
|
+
' 1. Start the relay first: browserforce serve\n' +
|
|
26
|
+
' 2. Set BF_CDP_URL environment variable'
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Derive the relay HTTP base URL from the CDP WebSocket URL. */
|
|
31
|
+
export function getRelayHttpUrl() {
|
|
32
|
+
const cdpUrl = getCdpUrl();
|
|
33
|
+
try {
|
|
34
|
+
const parsed = new URL(cdpUrl);
|
|
35
|
+
return `http://${parsed.hostname}:${parsed.port}`;
|
|
36
|
+
} catch {
|
|
37
|
+
return 'http://127.0.0.1:19222';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── Smart Page Load Detection ───────────────────────────────────────────────
|
|
42
|
+
// Filters analytics/ad requests that never finish, polls document.readyState +
|
|
43
|
+
// pending resource count.
|
|
44
|
+
|
|
45
|
+
const FILTERED_DOMAINS = [
|
|
46
|
+
'doubleclick', 'googlesyndication', 'googleadservices', 'google-analytics',
|
|
47
|
+
'googletagmanager', 'facebook.net', 'fbcdn.net', 'twitter.com', 'linkedin.com',
|
|
48
|
+
'hotjar', 'mixpanel', 'segment.io', 'segment.com', 'newrelic', 'datadoghq',
|
|
49
|
+
'sentry.io', 'fullstory', 'amplitude', 'intercom', 'crisp.chat', 'zdassets.com',
|
|
50
|
+
'zendesk', 'tawk.to', 'hubspot', 'marketo', 'pardot', 'optimizely', 'crazyegg',
|
|
51
|
+
'mouseflow', 'clarity.ms', 'bing.com/bat', 'ads.', 'analytics.', 'tracking.',
|
|
52
|
+
'pixel.',
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const FILTERED_EXTENSIONS = ['.gif', '.ico', '.cur', '.woff', '.woff2', '.ttf', '.otf', '.eot'];
|
|
56
|
+
|
|
57
|
+
const STUCK_THRESHOLD_MS = 10000;
|
|
58
|
+
const SLOW_RESOURCE_THRESHOLD_MS = 3000;
|
|
59
|
+
|
|
60
|
+
export async function smartWaitForPageLoad(page, timeout, pollInterval = 100, minWait = 500) {
|
|
61
|
+
const startTime = Date.now();
|
|
62
|
+
let lastReadyState = '';
|
|
63
|
+
let lastPendingRequests = [];
|
|
64
|
+
|
|
65
|
+
const checkArgs = {
|
|
66
|
+
filteredDomains: FILTERED_DOMAINS,
|
|
67
|
+
filteredExtensions: FILTERED_EXTENSIONS,
|
|
68
|
+
stuckThreshold: STUCK_THRESHOLD_MS,
|
|
69
|
+
slowResourceThreshold: SLOW_RESOURCE_THRESHOLD_MS,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const checkPageReady = () => page.evaluate((args) => {
|
|
73
|
+
const readyState = document.readyState;
|
|
74
|
+
if (readyState !== 'complete') {
|
|
75
|
+
return { ready: false, readyState, pendingRequests: [`document.readyState: ${readyState}`] };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const resources = performance.getEntriesByType('resource');
|
|
79
|
+
const now = performance.now();
|
|
80
|
+
|
|
81
|
+
const pendingRequests = resources
|
|
82
|
+
.filter((r) => {
|
|
83
|
+
if (r.responseEnd > 0) return false;
|
|
84
|
+
const elapsed = now - r.startTime;
|
|
85
|
+
const url = r.name.toLowerCase();
|
|
86
|
+
if (url.startsWith('data:')) return false;
|
|
87
|
+
if (args.filteredDomains.some((d) => url.includes(d))) return false;
|
|
88
|
+
if (elapsed > args.stuckThreshold) return false;
|
|
89
|
+
if (elapsed > args.slowResourceThreshold &&
|
|
90
|
+
args.filteredExtensions.some((ext) => url.includes(ext))) return false;
|
|
91
|
+
return true;
|
|
92
|
+
})
|
|
93
|
+
.map((r) => r.name);
|
|
94
|
+
|
|
95
|
+
return { ready: pendingRequests.length === 0, readyState, pendingRequests };
|
|
96
|
+
}, checkArgs);
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const first = await checkPageReady();
|
|
100
|
+
if (first.ready) {
|
|
101
|
+
return {
|
|
102
|
+
success: true, readyState: first.readyState,
|
|
103
|
+
pendingRequests: 0, waitTimeMs: Date.now() - startTime, timedOut: false,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
lastReadyState = first.readyState;
|
|
107
|
+
lastPendingRequests = first.pendingRequests;
|
|
108
|
+
} catch {
|
|
109
|
+
// page may not be ready for evaluate yet
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await new Promise((r) => globalThis.setTimeout(r, minWait));
|
|
113
|
+
|
|
114
|
+
while (Date.now() - startTime < timeout) {
|
|
115
|
+
try {
|
|
116
|
+
const { ready, readyState, pendingRequests } = await checkPageReady();
|
|
117
|
+
lastReadyState = readyState;
|
|
118
|
+
lastPendingRequests = pendingRequests;
|
|
119
|
+
if (ready) {
|
|
120
|
+
return {
|
|
121
|
+
success: true, readyState,
|
|
122
|
+
pendingRequests: 0, waitTimeMs: Date.now() - startTime, timedOut: false,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
return {
|
|
127
|
+
success: false, readyState: 'error',
|
|
128
|
+
pendingRequests: ['page.evaluate failed — page may have closed or navigated'],
|
|
129
|
+
waitTimeMs: Date.now() - startTime, timedOut: false,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
await new Promise((r) => globalThis.setTimeout(r, pollInterval));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
success: false, readyState: lastReadyState,
|
|
137
|
+
pendingRequests: lastPendingRequests.slice(0, 10),
|
|
138
|
+
waitTimeMs: Date.now() - startTime, timedOut: true,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ─── Accessibility Tree via DOM ──────────────────────────────────────────────
|
|
143
|
+
// Replaces page.accessibility.snapshot() which was removed in Playwright 1.58.
|
|
144
|
+
// Walks the DOM and builds an AX tree using ARIA roles, HTML semantics, and
|
|
145
|
+
// computed accessible names. Supports Shadow DOM (open roots).
|
|
146
|
+
|
|
147
|
+
export async function getAccessibilityTree(page, rootSelector) {
|
|
148
|
+
return page.evaluate((sel) => {
|
|
149
|
+
function getRole(el) {
|
|
150
|
+
if (el.nodeType !== 1) return null;
|
|
151
|
+
const explicit = el.getAttribute('role');
|
|
152
|
+
if (explicit) return explicit;
|
|
153
|
+
switch (el.tagName) {
|
|
154
|
+
case 'A': return el.hasAttribute('href') ? 'link' : null;
|
|
155
|
+
case 'BUTTON': case 'SUMMARY': return 'button';
|
|
156
|
+
case 'INPUT': {
|
|
157
|
+
const t = (el.type || 'text').toLowerCase();
|
|
158
|
+
if (t === 'hidden') return null;
|
|
159
|
+
return { text: 'textbox', search: 'searchbox', email: 'textbox', url: 'textbox',
|
|
160
|
+
tel: 'textbox', password: 'textbox', number: 'spinbutton',
|
|
161
|
+
checkbox: 'checkbox', radio: 'radio', range: 'slider',
|
|
162
|
+
button: 'button', submit: 'button', reset: 'button', image: 'button',
|
|
163
|
+
}[t] || 'textbox';
|
|
164
|
+
}
|
|
165
|
+
case 'SELECT': return 'combobox';
|
|
166
|
+
case 'TEXTAREA': return 'textbox';
|
|
167
|
+
case 'IMG': return 'img';
|
|
168
|
+
case 'H1': case 'H2': case 'H3': case 'H4': case 'H5': case 'H6': return 'heading';
|
|
169
|
+
case 'NAV': return 'navigation';
|
|
170
|
+
case 'MAIN': return 'main';
|
|
171
|
+
case 'HEADER': return el.closest('article, aside, main, nav, section') ? null : 'banner';
|
|
172
|
+
case 'FOOTER': return el.closest('article, aside, main, nav, section') ? null : 'contentinfo';
|
|
173
|
+
case 'ASIDE': return 'complementary';
|
|
174
|
+
case 'FORM': return (el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || el.getAttribute('name')) ? 'form' : null;
|
|
175
|
+
case 'TABLE': return 'table';
|
|
176
|
+
case 'THEAD': case 'TBODY': case 'TFOOT': return 'rowgroup';
|
|
177
|
+
case 'TR': return 'row';
|
|
178
|
+
case 'TH': return 'columnheader';
|
|
179
|
+
case 'TD': return 'cell';
|
|
180
|
+
case 'UL': case 'OL': return 'list';
|
|
181
|
+
case 'LI': return 'listitem';
|
|
182
|
+
case 'DIALOG': return 'dialog';
|
|
183
|
+
case 'DETAILS': case 'FIELDSET': return 'group';
|
|
184
|
+
case 'PROGRESS': return 'progressbar';
|
|
185
|
+
case 'METER': return 'meter';
|
|
186
|
+
case 'OPTION': return 'option';
|
|
187
|
+
case 'SECTION': return (el.getAttribute('aria-label') || el.getAttribute('aria-labelledby')) ? 'region' : null;
|
|
188
|
+
case 'ARTICLE': return 'article';
|
|
189
|
+
case 'SEARCH': return 'search';
|
|
190
|
+
default: return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function getName(el) {
|
|
195
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
196
|
+
if (ariaLabel) return ariaLabel.trim();
|
|
197
|
+
const labelledBy = el.getAttribute('aria-labelledby');
|
|
198
|
+
if (labelledBy) {
|
|
199
|
+
const t = labelledBy.split(/\s+/)
|
|
200
|
+
.map(id => document.getElementById(id)?.textContent?.trim())
|
|
201
|
+
.filter(Boolean).join(' ');
|
|
202
|
+
if (t) return t;
|
|
203
|
+
}
|
|
204
|
+
if (el.tagName === 'IMG') return (el.alt || '').trim();
|
|
205
|
+
if (['INPUT', 'SELECT', 'TEXTAREA'].includes(el.tagName)) {
|
|
206
|
+
if (el.id) {
|
|
207
|
+
const lab = document.querySelector('label[for="' + CSS.escape(el.id) + '"]');
|
|
208
|
+
if (lab) return lab.textContent?.trim() || '';
|
|
209
|
+
}
|
|
210
|
+
const parentLabel = el.closest('label');
|
|
211
|
+
if (parentLabel) {
|
|
212
|
+
const clone = parentLabel.cloneNode(true);
|
|
213
|
+
clone.querySelectorAll('input,select,textarea').forEach(i => i.remove());
|
|
214
|
+
const t = clone.textContent?.trim();
|
|
215
|
+
if (t) return t;
|
|
216
|
+
}
|
|
217
|
+
if (el.placeholder) return el.placeholder.trim();
|
|
218
|
+
}
|
|
219
|
+
if (el.title && !['A', 'BUTTON'].includes(el.tagName)) return el.title.trim();
|
|
220
|
+
const textTags = ['BUTTON', 'A', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'SUMMARY', 'OPTION', 'LEGEND', 'CAPTION'];
|
|
221
|
+
if (textTags.includes(el.tagName)) {
|
|
222
|
+
return (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 200);
|
|
223
|
+
}
|
|
224
|
+
return '';
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function isHidden(el) {
|
|
228
|
+
if (el.getAttribute('aria-hidden') === 'true') return true;
|
|
229
|
+
if (el.hidden) return true;
|
|
230
|
+
if (['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'HEAD'].includes(el.tagName)) return true;
|
|
231
|
+
try {
|
|
232
|
+
const s = window.getComputedStyle(el);
|
|
233
|
+
if (s.display === 'none' || s.visibility === 'hidden') return true;
|
|
234
|
+
} catch { /* ignore */ }
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function getChildren(el) {
|
|
239
|
+
const kids = [];
|
|
240
|
+
// Regular DOM children
|
|
241
|
+
for (const child of el.children) {
|
|
242
|
+
kids.push(child);
|
|
243
|
+
}
|
|
244
|
+
// Open Shadow DOM
|
|
245
|
+
if (el.shadowRoot) {
|
|
246
|
+
for (const child of el.shadowRoot.children) {
|
|
247
|
+
kids.push(child);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return kids;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
let nodeCount = 0;
|
|
254
|
+
const MAX_NODES = 2000;
|
|
255
|
+
|
|
256
|
+
function buildTree(el, depth) {
|
|
257
|
+
if (!el || el.nodeType !== 1) return null;
|
|
258
|
+
if (isHidden(el)) return null;
|
|
259
|
+
if (depth > 30) return null; // prevent runaway recursion
|
|
260
|
+
if (nodeCount >= MAX_NODES) return null; // cap total nodes
|
|
261
|
+
|
|
262
|
+
const role = getRole(el);
|
|
263
|
+
const children = [];
|
|
264
|
+
for (const child of getChildren(el)) {
|
|
265
|
+
if (nodeCount >= MAX_NODES) break;
|
|
266
|
+
const r = buildTree(child, depth + 1);
|
|
267
|
+
if (r) {
|
|
268
|
+
if (Array.isArray(r)) children.push(...r);
|
|
269
|
+
else children.push(r);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (role) {
|
|
274
|
+
nodeCount++;
|
|
275
|
+
const node = { role, name: getName(el) };
|
|
276
|
+
if (/^H[1-6]$/.test(el.tagName)) node.level = parseInt(el.tagName[1]);
|
|
277
|
+
if (['checkbox', 'radio', 'switch'].includes(role)) {
|
|
278
|
+
node.checked = el.checked ?? el.getAttribute('aria-checked') === 'true';
|
|
279
|
+
}
|
|
280
|
+
if (el.disabled || el.getAttribute('aria-disabled') === 'true') node.disabled = true;
|
|
281
|
+
const exp = el.getAttribute('aria-expanded');
|
|
282
|
+
if (exp !== null) node.expanded = exp === 'true';
|
|
283
|
+
if ((el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') && el.value) {
|
|
284
|
+
node.value = el.value.slice(0, 500);
|
|
285
|
+
}
|
|
286
|
+
if (el.tagName === 'SELECT' && el.selectedOptions?.length) {
|
|
287
|
+
node.value = el.selectedOptions[0]?.text || '';
|
|
288
|
+
}
|
|
289
|
+
if (children.length > 0) node.children = children;
|
|
290
|
+
return node;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// No role: pass through children
|
|
294
|
+
if (children.length === 0) return null;
|
|
295
|
+
if (children.length === 1) return children[0];
|
|
296
|
+
return children; // flatten
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const scope = sel ? document.querySelector(sel) : document.body;
|
|
300
|
+
if (!scope) return null;
|
|
301
|
+
|
|
302
|
+
const result = buildTree(scope, 0);
|
|
303
|
+
if (!result) return { role: 'WebArea', name: document.title, children: [] };
|
|
304
|
+
const kids = Array.isArray(result) ? result : (result.children || [result]);
|
|
305
|
+
return { role: 'WebArea', name: document.title, children: kids };
|
|
306
|
+
}, rootSelector || null);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ─── Snapshot Helper ─────────────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
export async function getStableIds(page, rootSelector) {
|
|
312
|
+
return page.evaluate(({ testIdAttrs, root }) => {
|
|
313
|
+
const scope = root ? document.querySelector(root) : document;
|
|
314
|
+
if (!scope) return {};
|
|
315
|
+
const result = {};
|
|
316
|
+
const selectors = testIdAttrs.map(a => `[${a}]`).join(',');
|
|
317
|
+
const elements = scope.querySelectorAll(selectors + ',[id]');
|
|
318
|
+
for (const el of elements) {
|
|
319
|
+
const name = el.getAttribute('aria-label') ||
|
|
320
|
+
el.textContent?.trim().slice(0, 100) || '';
|
|
321
|
+
if (!name) continue;
|
|
322
|
+
for (const attr of testIdAttrs) {
|
|
323
|
+
const value = el.getAttribute(attr);
|
|
324
|
+
if (value) {
|
|
325
|
+
if (!result[name]) {
|
|
326
|
+
result[name] = { attr, value };
|
|
327
|
+
}
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (!result[name]) {
|
|
332
|
+
const id = el.getAttribute('id');
|
|
333
|
+
if (id && !/^[:\d]/.test(id) && !id.includes('__')) {
|
|
334
|
+
result[name] = { attr: 'id', value: id };
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return result;
|
|
339
|
+
}, { testIdAttrs: TEST_ID_ATTRS, root: rootSelector || null });
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ─── Execution Engine ────────────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
export class CodeExecutionTimeoutError extends Error {
|
|
345
|
+
constructor(ms) {
|
|
346
|
+
super(`Code execution timed out after ${ms}ms`);
|
|
347
|
+
this.name = 'CodeExecutionTimeoutError';
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// buildExecContext takes userState and optional console helpers as params
|
|
352
|
+
// instead of referencing module-level singletons.
|
|
353
|
+
export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = {}) {
|
|
354
|
+
const { consoleLogs, setupConsoleCapture } = consoleHelpers;
|
|
355
|
+
|
|
356
|
+
const activePage = () => {
|
|
357
|
+
if (userState.page && !userState.page.isClosed()) return userState.page;
|
|
358
|
+
if (defaultPage && !defaultPage.isClosed()) return defaultPage;
|
|
359
|
+
throw new Error('No active page. Create one first: state.page = await context.newPage()');
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const snapshot = async ({ selector, search } = {}) => {
|
|
363
|
+
const page = activePage();
|
|
364
|
+
const axRoot = await getAccessibilityTree(page, selector);
|
|
365
|
+
if (!axRoot) return 'No accessibility tree available for this page.';
|
|
366
|
+
const stableIds = await getStableIds(page, selector);
|
|
367
|
+
annotateStableAttrs(axRoot, stableIds);
|
|
368
|
+
const searchPattern = parseSearchPattern(search);
|
|
369
|
+
const { text: snapshotText, refs } = buildSnapshotText(axRoot, null, searchPattern);
|
|
370
|
+
const refTable = refs.length > 0
|
|
371
|
+
? '\n\n--- Ref → Locator ---\n' + refs.map(r => `${r.ref}: ${r.locator}`).join('\n')
|
|
372
|
+
: '';
|
|
373
|
+
const title = await page.title().catch(() => '');
|
|
374
|
+
const pageUrl = page.url();
|
|
375
|
+
return `Page: ${title} (${pageUrl})\nRefs: ${refs.length} interactive elements\n\n${snapshotText}${refTable}`;
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const waitForPageLoad = (opts = {}) =>
|
|
379
|
+
smartWaitForPageLoad(activePage(), opts.timeout ?? 30000);
|
|
380
|
+
|
|
381
|
+
const getLogs = ({ count } = {}) => {
|
|
382
|
+
if (!consoleLogs || !setupConsoleCapture) return [];
|
|
383
|
+
const page = activePage();
|
|
384
|
+
setupConsoleCapture(page);
|
|
385
|
+
const logs = consoleLogs.get(page) || [];
|
|
386
|
+
return count ? logs.slice(-count) : [...logs];
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const clearLogs = () => {
|
|
390
|
+
if (consoleLogs) consoleLogs.set(activePage(), []);
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
page: defaultPage, context: ctx, state: userState,
|
|
395
|
+
snapshot, waitForPageLoad, getLogs, clearLogs,
|
|
396
|
+
fetch, URL, URLSearchParams, Buffer, setTimeout, clearTimeout,
|
|
397
|
+
TextEncoder, TextDecoder,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export async function runCode(code, execCtx, timeoutMs) {
|
|
402
|
+
const keys = Object.keys(execCtx);
|
|
403
|
+
const vals = Object.values(execCtx);
|
|
404
|
+
const fn = new Function(...keys, `return (async function() {\n${code}\n})()`);
|
|
405
|
+
let result;
|
|
406
|
+
const nativeSetTimeout = globalThis.setTimeout;
|
|
407
|
+
await Promise.race([
|
|
408
|
+
(async () => { result = await fn(...vals); })(),
|
|
409
|
+
new Promise((_, reject) =>
|
|
410
|
+
nativeSetTimeout(() => reject(new CodeExecutionTimeoutError(timeoutMs)), timeoutMs)),
|
|
411
|
+
]);
|
|
412
|
+
return result;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function formatResult(result) {
|
|
416
|
+
if (result === undefined || result === null) {
|
|
417
|
+
return { type: 'text', text: String(result) };
|
|
418
|
+
}
|
|
419
|
+
if (Buffer.isBuffer(result)) {
|
|
420
|
+
return { type: 'image', data: result.toString('base64'), mimeType: 'image/png' };
|
|
421
|
+
}
|
|
422
|
+
const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
|
|
423
|
+
return { type: 'text', text: text ?? 'undefined' };
|
|
424
|
+
}
|