@remcp/runtime 0.2.47 → 0.2.48

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.
@@ -0,0 +1,592 @@
1
+ import process from 'node:process';
2
+ import { resolveSafePath } from '../util.mjs';
3
+ import { clamp, jsonResult, optionalString, requireEnum, unavailable } from './common.mjs';
4
+
5
+ const DEFAULT_ENDPOINT = 'http://127.0.0.1:9222';
6
+ const MAX_DISCOVERY_BYTES = 8 * 1024 * 1024;
7
+
8
+ function boundedText(value, max = 1000) {
9
+ const text = String(value ?? '');
10
+ return text.length <= max ? text : `${text.slice(0, max)}…`;
11
+ }
12
+
13
+ function isLoopbackHost(hostname) {
14
+ const host = String(hostname || '').replace(/^\[|\]$/g, '').toLowerCase();
15
+ return ['localhost', '127.0.0.1', '::1'].includes(host);
16
+ }
17
+
18
+ function endpointUrl(value) {
19
+ let url;
20
+ try { url = new URL(optionalString(value) || process.env.REMCP_CDP_URL || DEFAULT_ENDPOINT); }
21
+ catch { throw new Error('endpoint must be a valid local http(s) URL'); }
22
+ if (!['http:', 'https:'].includes(url.protocol)) throw new Error('endpoint must use http or https');
23
+ if (!isLoopbackHost(url.hostname)) throw new Error('Browser CDP endpoint must be loopback-only');
24
+ return url;
25
+ }
26
+
27
+ async function requestJson(pathname, endpoint, options = {}) {
28
+ const base = endpointUrl(endpoint);
29
+ const url = new URL(pathname, base);
30
+ const controller = new AbortController();
31
+ const timer = setTimeout(() => controller.abort(), clamp(options.timeout_ms, 5000, 100, 30_000));
32
+ try {
33
+ const response = await fetch(url, { method: options.method || 'GET', signal: controller.signal });
34
+ if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);
35
+ const declared = Number(response.headers.get('content-length') || 0);
36
+ if (declared > MAX_DISCOVERY_BYTES) throw new Error(`CDP discovery response is too large (${declared} bytes)`);
37
+ const reader = response.body?.getReader();
38
+ if (!reader) return JSON.parse(await response.text());
39
+ const chunks = [];
40
+ let total = 0;
41
+ for (;;) {
42
+ const { value, done } = await reader.read();
43
+ if (done) break;
44
+ total += value.byteLength;
45
+ if (total > MAX_DISCOVERY_BYTES) {
46
+ await reader.cancel().catch(() => {});
47
+ throw new Error(`CDP discovery response exceeded ${MAX_DISCOVERY_BYTES} bytes`);
48
+ }
49
+ chunks.push(Buffer.from(value));
50
+ }
51
+ return JSON.parse(Buffer.concat(chunks, total).toString('utf8'));
52
+ } catch (error) {
53
+ throw new Error(`Could not reach Chrome DevTools Protocol at ${base.origin}: ${error instanceof Error ? error.message : String(error)}. Start Chrome/Edge with --remote-debugging-port=9222.`);
54
+ } finally {
55
+ clearTimeout(timer);
56
+ }
57
+ }
58
+
59
+ export async function browserCapabilityAvailable(endpoint, timeoutMs = 500) {
60
+ try {
61
+ const version = await requestJson('/json/version', endpoint, { timeout_ms: timeoutMs });
62
+ return Boolean(version && typeof version === 'object' && (version.Browser || version.webSocketDebuggerUrl));
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ export async function listBrowserTargets(endpoint) {
69
+ const list = await requestJson('/json/list', endpoint);
70
+ return Array.isArray(list) ? list : [];
71
+ }
72
+
73
+ async function chooseTarget(args = {}) {
74
+ const targets = (await listBrowserTargets(args.endpoint)).filter(item => item.type === 'page' && item.webSocketDebuggerUrl);
75
+ const id = optionalString(args.target_id || args.targetId);
76
+ const title = optionalString(args.title);
77
+ const urlContains = optionalString(args.url_contains || args.urlContains);
78
+ const target = targets.find(item =>
79
+ (!id || item.id === id) &&
80
+ (!title || String(item.title || '').toLowerCase().includes(title.toLowerCase())) &&
81
+ (!urlContains || String(item.url || '').toLowerCase().includes(urlContains.toLowerCase()))
82
+ ) || targets[0];
83
+ if (!target) unavailable('Browser control', 'no debuggable page target is available');
84
+ let socketUrl;
85
+ try { socketUrl = new URL(String(target.webSocketDebuggerUrl)); }
86
+ catch { throw new Error('Browser returned an invalid CDP WebSocket URL'); }
87
+ if (!['ws:', 'wss:'].includes(socketUrl.protocol) || !isLoopbackHost(socketUrl.hostname)) {
88
+ throw new Error('Browser CDP WebSocket target must be loopback-only');
89
+ }
90
+ return { ...target, webSocketDebuggerUrl: socketUrl.href };
91
+ }
92
+
93
+ class CdpSession {
94
+ constructor(url, timeoutMs) {
95
+ this.url = url;
96
+ this.timeoutMs = timeoutMs;
97
+ this.socket = null;
98
+ this.nextId = 1;
99
+ this.pending = new Map();
100
+ this.waiters = new Map();
101
+ this.observers = new Map();
102
+ }
103
+
104
+ async open() {
105
+ if (typeof WebSocket !== 'function') throw new Error('This Node.js runtime does not provide WebSocket support');
106
+ await new Promise((resolve, reject) => {
107
+ const socket = new WebSocket(this.url);
108
+ const timer = setTimeout(() => reject(new Error('CDP websocket connection timed out')), this.timeoutMs);
109
+ socket.addEventListener('open', () => { clearTimeout(timer); this.socket = socket; resolve(); }, { once: true });
110
+ socket.addEventListener('error', event => { clearTimeout(timer); reject(new Error(event?.message || 'CDP websocket error')); }, { once: true });
111
+ socket.addEventListener('message', event => this.onMessage(event));
112
+ socket.addEventListener('close', () => this.onClose());
113
+ });
114
+ return this;
115
+ }
116
+
117
+ onMessage(event) {
118
+ let message;
119
+ try { message = JSON.parse(String(event.data)); } catch { return; }
120
+ if (message.id && this.pending.has(message.id)) {
121
+ const pending = this.pending.get(message.id);
122
+ this.pending.delete(message.id);
123
+ clearTimeout(pending.timer);
124
+ if (message.error) pending.reject(new Error(message.error.message || JSON.stringify(message.error)));
125
+ else pending.resolve(message.result || {});
126
+ return;
127
+ }
128
+ const observers = this.observers.get(message.method);
129
+ if (observers) {
130
+ for (const observer of [...observers]) {
131
+ try { observer(message.params || {}); } catch {}
132
+ }
133
+ }
134
+ const listeners = this.waiters.get(message.method);
135
+ if (!listeners) return;
136
+ this.waiters.delete(message.method);
137
+ for (const listener of listeners) {
138
+ clearTimeout(listener.timer);
139
+ listener.resolve(message.params || {});
140
+ }
141
+ }
142
+
143
+ onClose() {
144
+ for (const pending of this.pending.values()) {
145
+ clearTimeout(pending.timer);
146
+ pending.reject(new Error('CDP websocket closed'));
147
+ }
148
+ this.pending.clear();
149
+ for (const listeners of this.waiters.values()) {
150
+ for (const listener of listeners) {
151
+ clearTimeout(listener.timer);
152
+ listener.reject(new Error('CDP websocket closed'));
153
+ }
154
+ }
155
+ this.waiters.clear();
156
+ this.observers.clear();
157
+ }
158
+
159
+ observe(method, callback) {
160
+ const listeners = this.observers.get(method) || new Set();
161
+ listeners.add(callback);
162
+ this.observers.set(method, listeners);
163
+ return () => {
164
+ const current = this.observers.get(method);
165
+ if (!current) return;
166
+ current.delete(callback);
167
+ if (!current.size) this.observers.delete(method);
168
+ };
169
+ }
170
+
171
+ send(method, params = {}) {
172
+ const id = this.nextId++;
173
+ return new Promise((resolve, reject) => {
174
+ const timer = setTimeout(() => {
175
+ this.pending.delete(id);
176
+ reject(new Error(`${method} timed out`));
177
+ }, this.timeoutMs);
178
+ this.pending.set(id, { resolve, reject, timer });
179
+ this.socket.send(JSON.stringify({ id, method, params }));
180
+ });
181
+ }
182
+
183
+ waitFor(method, timeoutMs = this.timeoutMs) {
184
+ return new Promise((resolve, reject) => {
185
+ const listeners = this.waiters.get(method) || [];
186
+ const listener = { resolve, reject, timer: null };
187
+ listener.timer = setTimeout(() => {
188
+ const current = this.waiters.get(method) || [];
189
+ const next = current.filter(item => item !== listener);
190
+ if (next.length) this.waiters.set(method, next); else this.waiters.delete(method);
191
+ reject(new Error(`${method} timed out`));
192
+ }, timeoutMs);
193
+ listeners.push(listener);
194
+ this.waiters.set(method, listeners);
195
+ });
196
+ }
197
+
198
+ close() { try { this.socket?.close(); } catch {} }
199
+ }
200
+
201
+ async function withTarget(args, callback) {
202
+ const target = await chooseTarget(args);
203
+ const session = await new CdpSession(target.webSocketDebuggerUrl, clamp(args.timeout_ms, 10_000, 100, 120_000)).open();
204
+ try { return await callback(session, target); } finally { session.close(); }
205
+ }
206
+
207
+ async function evaluate(session, expression, awaitPromise = true) {
208
+ const result = await session.send('Runtime.evaluate', {
209
+ expression,
210
+ returnByValue: true,
211
+ awaitPromise,
212
+ userGesture: true,
213
+ });
214
+ if (result.exceptionDetails) {
215
+ throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text || 'JavaScript evaluation failed');
216
+ }
217
+ return result.result?.value;
218
+ }
219
+
220
+ export async function browserTabs(args = {}) {
221
+ const targets = await listBrowserTargets(args.endpoint);
222
+ return jsonResult(targets.filter(item => item.type === 'page').slice(0, 500).map(item => ({
223
+ id: boundedText(item.id, 256),
224
+ title: boundedText(item.title, 1000),
225
+ url: boundedText(item.url, 4096),
226
+ description: boundedText(item.description, 1000),
227
+ })));
228
+ }
229
+
230
+ async function waitForHistoryReady(session, expectedUrl, timeoutMs) {
231
+ const deadline = Date.now() + timeoutMs;
232
+ do {
233
+ const state = await evaluate(session, '({url:location.href,ready:document.readyState})').catch(() => null);
234
+ if (state?.ready === 'complete' && (!expectedUrl || state.url === expectedUrl)) return true;
235
+ await new Promise(resolve => setTimeout(resolve, 75));
236
+ } while (Date.now() < deadline);
237
+ return false;
238
+ }
239
+
240
+ export async function browserNavigate(args = {}) {
241
+ const url = optionalString(args.url);
242
+ const action = requireEnum(args.action || (url ? 'url' : 'reload'), 'action', ['url','new_tab','back','forward','reload']);
243
+ if (['url','new_tab'].includes(action) && !url) throw new Error('url is required when action=url or new_tab');
244
+ if (action === 'new_tab') {
245
+ const created = await requestJson(`/json/new?${encodeURIComponent(url)}`, args.endpoint, { method:'PUT', timeout_ms:args.timeout_ms });
246
+ return jsonResult({
247
+ target_id:created?.id || null,
248
+ action,
249
+ url:created?.url || url,
250
+ title:created?.title || '',
251
+ });
252
+ }
253
+ return withTarget(args, async (session, target) => {
254
+ await session.send('Page.enable');
255
+ const timeoutMs = clamp(args.timeout_ms, 15_000, 500, 120_000);
256
+ const loaded = args.wait === false || ['back','forward'].includes(action)
257
+ ? null
258
+ : session.waitFor('Page.loadEventFired', timeoutMs).catch(() => null);
259
+ let frameId = null;
260
+ let errorText = null;
261
+ let historyUrl = null;
262
+ if (action === 'url') {
263
+ const result = await session.send('Page.navigate', { url });
264
+ frameId = result.frameId || null;
265
+ errorText = result.errorText || null;
266
+ } else if (action === 'reload') {
267
+ await session.send('Page.reload', { ignoreCache: Boolean(args.ignore_cache) });
268
+ } else {
269
+ const history = await session.send('Page.getNavigationHistory');
270
+ const current = Number(history.currentIndex || 0);
271
+ const nextIndex = action === 'back' ? current - 1 : current + 1;
272
+ const entry = Array.isArray(history.entries) ? history.entries[nextIndex] : null;
273
+ if (!entry) throw new Error(`Cannot navigate ${action}: no history entry is available`);
274
+ historyUrl = entry.url || null;
275
+ await session.send('Page.navigateToHistoryEntry', { entryId: entry.id });
276
+ }
277
+ if (loaded) await loaded;
278
+ if (args.wait !== false && ['back','forward'].includes(action)) {
279
+ await waitForHistoryReady(session, historyUrl, timeoutMs);
280
+ }
281
+ const currentUrl = await evaluate(session, 'location.href').catch(() => url || target.url || null);
282
+ return jsonResult({ target_id: target.id, action, url: currentUrl, frame_id: frameId, error_text: errorText });
283
+ });
284
+ }
285
+
286
+ export async function browserSnapshot(args) {
287
+ return withTarget(args, async (session, target) => {
288
+ await session.send('Accessibility.enable');
289
+ const { nodes = [] } = await session.send('Accessibility.getFullAXTree');
290
+ const limit = clamp(args.max_nodes, 1500, 1, 10_000);
291
+ const simplified = nodes.slice(0, limit).map(node => ({
292
+ node_id: boundedText(node.nodeId, 256),
293
+ ignored: Boolean(node.ignored),
294
+ role: boundedText(node.role?.value, 256),
295
+ name: boundedText(node.name?.value, 1000),
296
+ value: boundedText(node.value?.value, 1000),
297
+ description: boundedText(node.description?.value, 1000),
298
+ child_ids: Array.isArray(node.childIds) ? node.childIds.slice(0, 200).map(id => boundedText(id, 256)) : [],
299
+ backend_dom_node_id: node.backendDOMNodeId || null,
300
+ }));
301
+ const payload = {
302
+ target_id: target.id,
303
+ title: target.title,
304
+ url: target.url,
305
+ count: simplified.length,
306
+ truncated: nodes.length > limit,
307
+ nodes: simplified,
308
+ };
309
+ if (!args.include_screenshot) return jsonResult(payload);
310
+
311
+ await session.send('Page.enable');
312
+ const selector = optionalString(args.selector);
313
+ if (selector) {
314
+ const bounds = await evaluate(session, `new Promise((resolve,reject) => {
315
+ const el=document.querySelector(${JSON.stringify(selector)});
316
+ if(!el){reject(new Error('Screenshot element not found'));return;}
317
+ const initial=el.getBoundingClientRect();
318
+ const desiredTop=Math.max(0, scrollY + initial.top - Math.max(0,(innerHeight-initial.height)/2));
319
+ window.scrollTo({top:desiredTop,left:scrollX,behavior:'instant'});
320
+ requestAnimationFrame(() => requestAnimationFrame(() => {
321
+ const r=el.getBoundingClientRect();
322
+ resolve({
323
+ x:r.x,
324
+ y:r.y,
325
+ width:r.width,
326
+ height:r.height,
327
+ viewport_width:innerWidth,
328
+ viewport_height:innerHeight,
329
+ scroll_x:scrollX,
330
+ scroll_y:scrollY,
331
+ fully_visible:r.x>=0 && r.y>=0 && r.right<=innerWidth && r.bottom<=innerHeight
332
+ });
333
+ }));
334
+ })`);
335
+ if (!bounds || !Number.isFinite(Number(bounds.width)) || !Number.isFinite(Number(bounds.height)) || bounds.width <= 0 || bounds.height <= 0) {
336
+ throw new Error('Screenshot element has invalid bounds');
337
+ }
338
+ payload.screenshot_target = { selector, ...bounds };
339
+ }
340
+ payload.screenshot = {
341
+ scope: 'viewport',
342
+ width: await evaluate(session, 'innerWidth').catch(() => null),
343
+ height: await evaluate(session, 'innerHeight').catch(() => null),
344
+ };
345
+ const result = jsonResult(payload);
346
+ const captured = await session.send('Page.captureScreenshot', {
347
+ format: 'png',
348
+ fromSurface: true,
349
+ captureBeyondViewport: false,
350
+ });
351
+ const data = String(captured?.data || '');
352
+ if (!data) throw new Error('CDP returned no screenshot data');
353
+ if (Buffer.byteLength(data, 'base64') > 8 * 1024 * 1024) throw new Error('Browser screenshot exceeds the 8 MiB inline image limit');
354
+ result.content.push({ type:'image', data, mimeType:'image/png' });
355
+ return result;
356
+ });
357
+ }
358
+
359
+ function findExpression(args) {
360
+ const selector = optionalString(args.selector);
361
+ const needle = optionalString(args.text);
362
+ const role = optionalString(args.role);
363
+ const limit = clamp(args.limit, 20, 1, 200);
364
+ return `(() => {
365
+ const selector = ${JSON.stringify(selector)};
366
+ const needle = ${JSON.stringify(needle)};
367
+ const role = ${JSON.stringify(role)};
368
+ const source = selector ? Array.from(document.querySelectorAll(selector)) : Array.from(document.querySelectorAll('*'));
369
+ function cssPath(el) {
370
+ if (el.id) return '#' + CSS.escape(el.id);
371
+ const parts = [];
372
+ while (el && el.nodeType === 1 && el !== document.documentElement) {
373
+ let part = el.tagName.toLowerCase();
374
+ if (el.parentElement) {
375
+ const same = Array.from(el.parentElement.children).filter(x => x.tagName === el.tagName);
376
+ if (same.length > 1) part += ':nth-of-type(' + (same.indexOf(el) + 1) + ')';
377
+ }
378
+ parts.unshift(part); el = el.parentElement;
379
+ }
380
+ return parts.join(' > ');
381
+ }
382
+ return source.filter(el => {
383
+ const style = getComputedStyle(el);
384
+ const visible = style.visibility !== 'hidden' && style.display !== 'none' && el.getClientRects().length > 0;
385
+ const hay = (el.innerText || el.textContent || el.getAttribute('aria-label') || '').trim();
386
+ const r = el.getAttribute('role') || '';
387
+ return visible && (!needle || hay.toLowerCase().includes(needle.toLowerCase())) && (!role || r.toLowerCase() === role.toLowerCase());
388
+ }).slice(0, ${limit}).map(el => {
389
+ const b = el.getBoundingClientRect();
390
+ return { selector: cssPath(el), tag: el.tagName.toLowerCase(), role: el.getAttribute('role') || '', name: el.getAttribute('aria-label') || el.getAttribute('name') || '', text: (el.innerText || el.textContent || '').trim().slice(0, 500), value: 'value' in el ? String(el.value).slice(0, 500) : '', x: b.x, y: b.y, width: b.width, height: b.height, disabled: Boolean(el.disabled) };
391
+ });
392
+ })()`;
393
+ }
394
+
395
+ export async function browserFind(args) {
396
+ return withTarget(args, async (session, target) => {
397
+ const matches = await evaluate(session, findExpression(args));
398
+ return jsonResult({ target_id: target.id, count: Array.isArray(matches) ? matches.length : 0, matches: Array.isArray(matches) ? matches : [] });
399
+ });
400
+ }
401
+
402
+ function elementLookup(args) {
403
+ const selector = optionalString(args.selector);
404
+ const needle = optionalString(args.text);
405
+ if (!selector && !needle) throw new Error('selector or text is required');
406
+ return `(() => {
407
+ const selector = ${JSON.stringify(selector)};
408
+ const needle = ${JSON.stringify(needle)};
409
+ let el = selector ? document.querySelector(selector) : null;
410
+ if (!el && needle) el = Array.from(document.querySelectorAll('*')).find(x => ((x.innerText || x.textContent || '').trim().toLowerCase().includes(needle.toLowerCase())));
411
+ return el;
412
+ })()`;
413
+ }
414
+
415
+ export async function browserAction(args) {
416
+ const action = requireEnum(args.action, 'action', ['click', 'focus', 'type', 'set_value', 'select', 'scroll_into_view', 'upload', 'press', 'set_viewport']);
417
+ return withTarget(args, async (session, target) => {
418
+ if (action === 'set_viewport') {
419
+ const width = clamp(args.width, 1280, 200, 8192);
420
+ const height = clamp(args.height, 720, 200, 8192);
421
+ const deviceScaleFactor = Math.max(0.1, Math.min(8, Number(args.device_scale_factor) || 1));
422
+ await session.send('Emulation.setDeviceMetricsOverride', {
423
+ width,
424
+ height,
425
+ deviceScaleFactor,
426
+ mobile: Boolean(args.mobile),
427
+ screenWidth: width,
428
+ screenHeight: height,
429
+ });
430
+ return jsonResult({ target_id:target.id, action, width, height, device_scale_factor:deviceScaleFactor, mobile:Boolean(args.mobile) });
431
+ }
432
+ if (action === 'upload') {
433
+ const selector = optionalString(args.selector);
434
+ if (!selector) throw new Error('selector is required for upload');
435
+ const requested = (Array.isArray(args.paths) ? args.paths : [args.path]).filter(Boolean);
436
+ if (!requested.length) throw new Error('path or paths is required for upload');
437
+ const files = [];
438
+ for (const value of requested) files.push(await resolveSafePath(value, 'path'));
439
+ await session.send('DOM.enable');
440
+ const doc = await session.send('DOM.getDocument', { depth: 1 });
441
+ const found = await session.send('DOM.querySelector', { nodeId: doc.root.nodeId, selector });
442
+ if (!found.nodeId) throw new Error(`No element matches selector ${selector}`);
443
+ await session.send('DOM.setFileInputFiles', { nodeId: found.nodeId, files });
444
+ return jsonResult({ target_id: target.id, action, selector, files: files.length });
445
+ }
446
+ if (action === 'press') {
447
+ const key = optionalString(args.key);
448
+ if (!key) throw new Error('key is required for press');
449
+ await session.send('Input.dispatchKeyEvent', { type: 'keyDown', key });
450
+ await session.send('Input.dispatchKeyEvent', { type: 'keyUp', key });
451
+ return jsonResult({ target_id: target.id, action, key });
452
+ }
453
+ const lookup = elementLookup(args);
454
+ const value = String(args.value ?? args.text_value ?? '');
455
+ const option = String(args.option ?? value);
456
+
457
+ if (action === 'click') {
458
+ const rect = await evaluate(session, `(() => {
459
+ const el = ${lookup};
460
+ if (!el) throw new Error('Element not found');
461
+ el.scrollIntoView({block:'center',inline:'center'});
462
+ const b = el.getBoundingClientRect();
463
+ if (!b.width || !b.height) throw new Error('Element has no clickable bounds');
464
+ return { tag:el.tagName.toLowerCase(), x:b.x, y:b.y, width:b.width, height:b.height };
465
+ })()`);
466
+ const x = Number(rect.x) + Number(rect.width) / 2;
467
+ const y = Number(rect.y) + Number(rect.height) / 2;
468
+ await session.send('Input.dispatchMouseEvent', { type:'mouseMoved', x, y, button:'none' });
469
+ await session.send('Input.dispatchMouseEvent', { type:'mousePressed', x, y, button:'left', clickCount:1 });
470
+ await session.send('Input.dispatchMouseEvent', { type:'mouseReleased', x, y, button:'left', clickCount:1 });
471
+ return jsonResult({ target_id:target.id, action, result:{ ...rect, x, y } });
472
+ }
473
+
474
+ if (action === 'type') {
475
+ const focused = await evaluate(session, `(() => {
476
+ const el = ${lookup};
477
+ if (!el) throw new Error('Element not found');
478
+ el.scrollIntoView({block:'center',inline:'center'});
479
+ el.focus();
480
+ const b = el.getBoundingClientRect();
481
+ return { tag:el.tagName.toLowerCase(), value:'value' in el?String(el.value):'', x:b.x, y:b.y, width:b.width, height:b.height };
482
+ })()`);
483
+ await session.send('Input.insertText', { text:value });
484
+ const current = await evaluate(session, `(() => { const el=${lookup}; return 'value' in el ? String(el.value) : ''; })()`);
485
+ return jsonResult({ target_id:target.id, action, result:{ ...focused, value:current } });
486
+ }
487
+
488
+ const expression = `(() => {
489
+ const el = ${lookup};
490
+ if (!el) throw new Error('Element not found');
491
+ ${action === 'focus' ? 'el.focus();' :
492
+ action === 'scroll_into_view' ? "el.scrollIntoView({block:'center',inline:'center'});" :
493
+ action === 'select' ? `el.value=${JSON.stringify(option)}; el.dispatchEvent(new Event('input',{bubbles:true})); el.dispatchEvent(new Event('change',{bubbles:true}));` :
494
+ `const proto=Object.getPrototypeOf(el);const own=Object.getOwnPropertyDescriptor(proto,'value');const parent=Object.getPrototypeOf(proto);const inherited=parent?Object.getOwnPropertyDescriptor(parent,'value'):null;const setter=own?.set||inherited?.set;if(setter)setter.call(el,${JSON.stringify(value)});else el.value=${JSON.stringify(value)};el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));`
495
+ }
496
+ const b = el.getBoundingClientRect();
497
+ return { tag: el.tagName.toLowerCase(), value: 'value' in el ? String(el.value) : '', x: b.x, y: b.y, width: b.width, height: b.height };
498
+ })()`;
499
+ return jsonResult({ target_id: target.id, action, result: await evaluate(session, expression) });
500
+ });
501
+ }
502
+
503
+ export async function browserWait(args = {}) {
504
+ const condition = requireEnum(args.condition || 'selector', 'condition', ['selector', 'text', 'url_contains', 'expression', 'load', 'navigation', 'network_idle']);
505
+ const timeoutMs = clamp(args.timeout_ms, 10_000, 100, 120_000);
506
+ const pollMs = clamp(args.poll_ms, 200, 50, 5000);
507
+ const idleMs = clamp(args.idle_ms, 500, 100, 10_000);
508
+ const wanted = optionalString(args.selector || args.text || args.value || args.expression);
509
+ if (['selector','text','url_contains','expression'].includes(condition) && !wanted) throw new Error('selector/text/value/expression is required for this condition');
510
+ return withTarget(args, async (session, target) => {
511
+ const started = Date.now();
512
+ const initialUrl = await evaluate(session, 'location.href').catch(() => target.url || '');
513
+ const inflight = new Set();
514
+ let lastResourceCount = -1;
515
+ let stableSince = Date.now();
516
+ const unsubscribers = [];
517
+ if (condition === 'network_idle') {
518
+ await session.send('Network.enable');
519
+ const markBusy = params => {
520
+ if (params?.requestId) inflight.add(params.requestId);
521
+ stableSince = Date.now();
522
+ };
523
+ const markDone = params => {
524
+ if (params?.requestId) inflight.delete(params.requestId);
525
+ stableSince = Date.now();
526
+ };
527
+ unsubscribers.push(
528
+ session.observe('Network.requestWillBeSent', markBusy),
529
+ session.observe('Network.loadingFinished', markDone),
530
+ session.observe('Network.loadingFailed', markDone),
531
+ );
532
+ }
533
+ try {
534
+ while (Date.now() - started < timeoutMs) {
535
+ let matched = false;
536
+ if (condition === 'network_idle') {
537
+ const state = await evaluate(session, `({ready:document.readyState,resources:performance.getEntriesByType('resource').length})`).catch(() => null);
538
+ if (state) {
539
+ if (state.resources !== lastResourceCount) {
540
+ lastResourceCount = state.resources;
541
+ stableSince = Date.now();
542
+ }
543
+ matched = state.ready === 'complete' && inflight.size === 0 && Date.now() - stableSince >= idleMs;
544
+ }
545
+ } else {
546
+ const expression = condition === 'selector' ? `Boolean(document.querySelector(${JSON.stringify(wanted)}))`
547
+ : condition === 'text' ? `(document.body?.innerText||'').toLowerCase().includes(${JSON.stringify(wanted?.toLowerCase())})`
548
+ : condition === 'url_contains' ? `location.href.includes(${JSON.stringify(wanted)})`
549
+ : condition === 'load' ? `document.readyState === 'complete'`
550
+ : condition === 'navigation' ? `location.href !== ${JSON.stringify(initialUrl)}`
551
+ : `Boolean(${wanted})`;
552
+ matched = Boolean(await evaluate(session, expression));
553
+ }
554
+ if (matched) return jsonResult({
555
+ target_id: target.id,
556
+ condition,
557
+ matched: true,
558
+ elapsed_ms: Date.now() - started,
559
+ ...(condition === 'network_idle' ? { inflight: inflight.size, idle_ms: idleMs } : {}),
560
+ url: await evaluate(session, 'location.href').catch(() => target.url || null),
561
+ });
562
+ await new Promise(resolve => setTimeout(resolve, pollMs));
563
+ }
564
+ return jsonResult({
565
+ target_id: target.id,
566
+ condition,
567
+ matched: false,
568
+ elapsed_ms: Date.now() - started,
569
+ ...(condition === 'network_idle' ? { inflight: inflight.size, idle_ms: idleMs } : {}),
570
+ url: await evaluate(session, 'location.href').catch(() => target.url || null),
571
+ });
572
+ } finally {
573
+ for (const unsubscribe of unsubscribers) unsubscribe();
574
+ }
575
+ });
576
+ }
577
+
578
+ export async function browserEvaluate(args) {
579
+ const expression = optionalString(args.expression);
580
+ if (!expression) throw new Error('expression is required');
581
+ return withTarget(args, async (session, target) => jsonResult({ target_id: target.id, value: await evaluate(session, expression, args.await_promise !== false) }));
582
+ }
583
+
584
+ export const browserHandlers = {
585
+ browser_tabs: browserTabs,
586
+ browser_navigate: browserNavigate,
587
+ browser_snapshot: browserSnapshot,
588
+ browser_find: browserFind,
589
+ browser_action: browserAction,
590
+ browser_wait: browserWait,
591
+ browser_evaluate: browserEvaluate,
592
+ };