@remcp/runtime 0.2.154 → 0.2.155

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,288 @@
1
+ import process from 'node:process';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { isIP } from 'node:net';
5
+ import { lookup } from 'node:dns/promises';
6
+ import { closeSync, constants as fsConstants, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readdirSync } from 'node:fs';
7
+ import { resolveSafePath } from '../util.mjs';
8
+ import { preserveInterruptedBrowserSession } from './browser-session-backup.mjs';
9
+ import { clamp, commandExists, jsonResult, optionalString, requireEnum, spawnDetached, unavailable } from './common.mjs';
10
+
11
+
12
+ import { assertResolvedBrowserUrl, assertResolvedPageUrl, boundedText, ensureBrowserEndpoint, listBrowserTargets, requestJson } from './browser-endpoint.mjs';
13
+ import { createGuardedNewTab, evaluate, withTarget } from './browser-session.mjs';
14
+
15
+ export async function browserTabs(args = {}) {
16
+ const targets = await listBrowserTargets(args.endpoint);
17
+ const pages = targets.filter(item => item.type === 'page').slice(0, 500).map(item => ({
18
+ id: boundedText(item.id, 256),
19
+ title: boundedText(item.title, 1000),
20
+ url: boundedText(item.url, 4096),
21
+ description: boundedText(item.description, 1000),
22
+ }));
23
+ const result = jsonResult(pages);
24
+ // Keep the typed empty-array contract while providing a native fallback hint.
25
+ if (!pages.length) {
26
+ result.content = [{ type: 'text', text: 'CDP is connected, but no debuggable Chromium tabs are available. Use ui_snapshot and ui_find to inspect existing browser windows without CDP; only open a new tab when explicitly requested.' }];
27
+ }
28
+ return result;
29
+ }
30
+
31
+ export async function waitForHistoryReady(session, expectedUrl, timeoutMs) {
32
+ const deadline = Date.now() + timeoutMs;
33
+ do {
34
+ const state = await evaluate(session, '({url:location.href,ready:document.readyState})').catch(() => null);
35
+ if (state?.ready === 'complete' && (!expectedUrl || state.url === expectedUrl)) return true;
36
+ await new Promise(resolve => setTimeout(resolve, 75));
37
+ } while (Date.now() < deadline);
38
+ return false;
39
+ }
40
+
41
+ export async function browserNavigate(args = {}) {
42
+ const requestedUrl = optionalString(args.url);
43
+ const action = requireEnum(args.action || (requestedUrl ? 'url' : 'reload'), 'action', ['url','new_tab','back','forward','reload']);
44
+ const url = ['url','new_tab'].includes(action) ? await assertResolvedBrowserUrl(requestedUrl, 'url') : null;
45
+ if (['url','new_tab'].includes(action) && !url) throw new Error('url is required when action=url or new_tab');
46
+ if (action === 'new_tab') {
47
+ if (process.env.NODE_ENV === 'production') {
48
+ const opened = await createGuardedNewTab(url, args);
49
+ return jsonResult({
50
+ target_id: opened.target.id,
51
+ action,
52
+ url: opened.finalUrl,
53
+ title: opened.target.title || '',
54
+ error_text: opened.navigation?.errorText || null,
55
+ });
56
+ }
57
+ await ensureBrowserEndpoint(args.endpoint);
58
+ const created = await requestJson(`/json/new?${encodeURIComponent(url)}`, args.endpoint, { method:'PUT', timeout_ms:args.timeout_ms });
59
+ return jsonResult({
60
+ target_id:created?.id || null,
61
+ action,
62
+ url:created?.url ? await assertResolvedPageUrl(created.url) : url,
63
+ title:created?.title || '',
64
+ });
65
+ }
66
+ return withTarget(args, async (session, target) => {
67
+ await session.send('Page.enable');
68
+ const timeoutMs = clamp(args.timeout_ms, 15_000, 500, 120_000);
69
+ const loaded = args.wait === false || ['back','forward'].includes(action)
70
+ ? null
71
+ : session.waitFor('Page.loadEventFired', timeoutMs).catch(() => null);
72
+ let frameId = null;
73
+ let errorText = null;
74
+ let historyUrl = null;
75
+ if (action === 'url') {
76
+ const result = await session.send('Page.navigate', { url });
77
+ frameId = result.frameId || null;
78
+ errorText = result.errorText || null;
79
+ } else if (action === 'reload') {
80
+ await session.send('Page.reload', { ignoreCache: Boolean(args.ignore_cache) });
81
+ } else {
82
+ const history = await session.send('Page.getNavigationHistory');
83
+ const current = Number(history.currentIndex || 0);
84
+ const nextIndex = action === 'back' ? current - 1 : current + 1;
85
+ const entry = Array.isArray(history.entries) ? history.entries[nextIndex] : null;
86
+ if (!entry) throw new Error(`Cannot navigate ${action}: no history entry is available`);
87
+ historyUrl = entry.url ? await assertResolvedPageUrl(entry.url, 'history URL') : null;
88
+ await session.send('Page.navigateToHistoryEntry', { entryId: entry.id });
89
+ }
90
+ if (loaded) await loaded;
91
+ if (args.wait !== false && ['back','forward'].includes(action)) {
92
+ await waitForHistoryReady(session, historyUrl, timeoutMs);
93
+ }
94
+ const currentUrl = await evaluate(session, 'location.href').catch(() => url || target.url || null);
95
+ if (currentUrl) await assertResolvedPageUrl(currentUrl);
96
+ return jsonResult({ target_id: target.id, action, url: currentUrl, frame_id: frameId, error_text: errorText });
97
+ });
98
+ }
99
+
100
+ export async function browserSnapshot(args) {
101
+ return withTarget(args, async (session, target) => {
102
+ await session.send('Accessibility.enable');
103
+ const { nodes = [] } = await session.send('Accessibility.getFullAXTree');
104
+ const limit = clamp(args.max_nodes, 1500, 1, 10_000);
105
+ const simplified = nodes.slice(0, limit).map(node => ({
106
+ node_id: boundedText(node.nodeId, 256),
107
+ ignored: Boolean(node.ignored),
108
+ role: boundedText(node.role?.value, 256),
109
+ name: boundedText(node.name?.value, 1000),
110
+ value: boundedText(node.value?.value, 1000),
111
+ description: boundedText(node.description?.value, 1000),
112
+ child_ids: Array.isArray(node.childIds) ? node.childIds.slice(0, 200).map(id => boundedText(id, 256)) : [],
113
+ backend_dom_node_id: node.backendDOMNodeId || null,
114
+ }));
115
+ const payload = {
116
+ target_id: target.id,
117
+ title: target.title,
118
+ url: target.url,
119
+ count: simplified.length,
120
+ truncated: nodes.length > limit,
121
+ nodes: simplified,
122
+ };
123
+ if (!args.include_screenshot) return jsonResult(payload);
124
+
125
+ await session.send('Page.enable');
126
+ const selector = optionalString(args.selector);
127
+ let restoreScroll = null;
128
+ if (selector) {
129
+ const bounds = await evaluate(session, `new Promise((resolve,reject) => {
130
+ const el=document.querySelector(${JSON.stringify(selector)});
131
+ if(!el){reject(new Error('Screenshot element not found'));return;}
132
+ const originalScrollX=scrollX, originalScrollY=scrollY;
133
+ const initial=el.getBoundingClientRect();
134
+ const desiredTop=Math.max(0, scrollY + initial.top - Math.max(0,(innerHeight-initial.height)/2));
135
+ window.scrollTo({top:desiredTop,left:scrollX,behavior:'instant'});
136
+ requestAnimationFrame(() => requestAnimationFrame(() => {
137
+ const r=el.getBoundingClientRect();
138
+ resolve({
139
+ x:r.x,
140
+ y:r.y,
141
+ width:r.width,
142
+ height:r.height,
143
+ viewport_width:innerWidth,
144
+ viewport_height:innerHeight,
145
+ scroll_x:scrollX,
146
+ scroll_y:scrollY,
147
+ original_scroll_x:originalScrollX,
148
+ original_scroll_y:originalScrollY,
149
+ fully_visible:r.x>=0 && r.y>=0 && r.right<=innerWidth && r.bottom<=innerHeight
150
+ });
151
+ }));
152
+ })`);
153
+ if (!bounds || !Number.isFinite(Number(bounds.width)) || !Number.isFinite(Number(bounds.height)) || bounds.width <= 0 || bounds.height <= 0) {
154
+ throw new Error('Screenshot element has invalid bounds');
155
+ }
156
+ restoreScroll = { x:Number(bounds.original_scroll_x || 0), y:Number(bounds.original_scroll_y || 0) };
157
+ const { original_scroll_x: _originalX, original_scroll_y: _originalY, ...reportedBounds } = bounds;
158
+ payload.screenshot_target = { selector, ...reportedBounds };
159
+ }
160
+ payload.screenshot = {
161
+ scope: 'viewport',
162
+ width: await evaluate(session, 'innerWidth').catch(() => null),
163
+ height: await evaluate(session, 'innerHeight').catch(() => null),
164
+ };
165
+ const result = jsonResult(payload);
166
+ let captured;
167
+ try {
168
+ captured = await session.send('Page.captureScreenshot', {
169
+ format: 'png',
170
+ fromSurface: true,
171
+ captureBeyondViewport: false,
172
+ });
173
+ } finally {
174
+ if (restoreScroll) {
175
+ await evaluate(session, `window.scrollTo({left:${restoreScroll.x},top:${restoreScroll.y},behavior:'instant'})`).catch(() => {});
176
+ }
177
+ }
178
+ const data = String(captured?.data || '');
179
+ if (!data) throw new Error('CDP returned no screenshot data');
180
+ if (Buffer.byteLength(data, 'base64') > 8 * 1024 * 1024) throw new Error('Browser screenshot exceeds the 8 MiB inline image limit');
181
+ result.content.push({ type:'image', data, mimeType:'image/png' });
182
+ return result;
183
+ });
184
+ }
185
+
186
+ export function findExpression(args) {
187
+ const selector = optionalString(args.selector);
188
+ const needle = optionalString(args.text);
189
+ const role = optionalString(args.role);
190
+ const limit = clamp(args.limit, 20, 1, 200);
191
+ return `(() => {
192
+ const selector = ${JSON.stringify(selector)};
193
+ const needle = ${JSON.stringify(needle)};
194
+ const role = ${JSON.stringify(role)};
195
+ const source = selector ? Array.from(document.querySelectorAll(selector)) : Array.from(document.querySelectorAll('*'));
196
+ function cssPath(el) {
197
+ if (el.id) return '#' + CSS.escape(el.id);
198
+ const parts = [];
199
+ while (el && el.nodeType === 1 && el !== document.documentElement) {
200
+ let part = el.tagName.toLowerCase();
201
+ if (el.parentElement) {
202
+ const same = Array.from(el.parentElement.children).filter(x => x.tagName === el.tagName);
203
+ if (same.length > 1) part += ':nth-of-type(' + (same.indexOf(el) + 1) + ')';
204
+ }
205
+ parts.unshift(part); el = el.parentElement;
206
+ }
207
+ return parts.join(' > ');
208
+ }
209
+ function semanticRole(el) {
210
+ const explicit = (el.getAttribute('role') || '').trim().toLowerCase();
211
+ if (explicit) return explicit;
212
+ const tag = el.tagName.toLowerCase();
213
+ if (/^h[1-6]$/.test(tag)) return 'heading';
214
+ if (tag === 'button') return 'button';
215
+ if (tag === 'a' && el.hasAttribute('href')) return 'link';
216
+ if (tag === 'textarea') return 'textbox';
217
+ if (tag === 'select') return el.multiple || el.size > 1 ? 'listbox' : 'combobox';
218
+ if (tag === 'option') return 'option';
219
+ if (tag === 'img') return 'img';
220
+ if (tag === 'progress') return 'progressbar';
221
+ if (tag === 'meter') return 'meter';
222
+ if (tag === 'summary') return 'button';
223
+ if (tag === 'input') {
224
+ const type = String(el.type || 'text').toLowerCase();
225
+ if (['button','submit','reset','image'].includes(type)) return 'button';
226
+ if (type === 'checkbox') return 'checkbox';
227
+ if (type === 'radio') return 'radio';
228
+ if (type === 'range') return 'slider';
229
+ if (type === 'number') return 'spinbutton';
230
+ if (type === 'search') return 'searchbox';
231
+ if (!['hidden','file','color'].includes(type)) return 'textbox';
232
+ }
233
+ return '';
234
+ }
235
+ function visibleText(el) {
236
+ return (el.innerText || el.textContent || el.getAttribute('aria-label') || '').trim();
237
+ }
238
+ function textQuality(value) {
239
+ if (!needle) return 0;
240
+ const hay = value.toLowerCase();
241
+ const wanted = needle.toLowerCase();
242
+ if (hay === wanted) return 0;
243
+ if (hay.startsWith(wanted)) return 1;
244
+ return 2;
245
+ }
246
+ function depthOf(el) {
247
+ let depth = 0;
248
+ for (let current = el.parentElement; current; current = current.parentElement) depth += 1;
249
+ return depth;
250
+ }
251
+ const candidates = source.filter(el => {
252
+ const style = getComputedStyle(el);
253
+ const visible = style.visibility !== 'hidden' && style.display !== 'none' && el.getClientRects().length > 0;
254
+ const hay = visibleText(el);
255
+ const r = semanticRole(el);
256
+ return visible && (!needle || hay.toLowerCase().includes(needle.toLowerCase())) && (!role || r === role.toLowerCase());
257
+ }).map((el, index) => ({
258
+ el,
259
+ index,
260
+ text: visibleText(el),
261
+ role: semanticRole(el),
262
+ depth: depthOf(el),
263
+ }));
264
+ let selected = candidates;
265
+ if (needle && !selector) {
266
+ selected = [...candidates].sort((a, b) =>
267
+ textQuality(a.text) - textQuality(b.text)
268
+ || Number(!a.role) - Number(!b.role)
269
+ || a.text.length - b.text.length
270
+ || b.depth - a.depth
271
+ || a.index - b.index
272
+ );
273
+ const deduped = [];
274
+ for (const candidate of selected) {
275
+ if (deduped.some(existing => existing.el.contains(candidate.el) || candidate.el.contains(existing.el))) continue;
276
+ deduped.push(candidate);
277
+ if (deduped.length >= ${limit}) break;
278
+ }
279
+ selected = deduped;
280
+ } else {
281
+ selected = selected.slice(0, ${limit});
282
+ }
283
+ return selected.map(({ el }) => {
284
+ const b = el.getBoundingClientRect();
285
+ return { selector: cssPath(el), tag: el.tagName.toLowerCase(), role: semanticRole(el), name: el.getAttribute('aria-label') || el.getAttribute('name') || '', text: visibleText(el).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) };
286
+ });
287
+ })()`;
288
+ }