@evan_zhong/browser-attach 0.21.0 → 0.22.1

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,57 @@
1
+ export function createUploads(cdp, value) {
2
+ const fileChoosers = new Map();
3
+ async function upload(tabId, node, args) {
4
+ const send = (target, method, params) => args.budget.run(() => cdp(target, method, params));
5
+ if (!Array.isArray(args.files) || args.files.length > 100 || args.files.some(file => typeof file !== 'string' || !file || file.includes('\0'))) {
6
+ throw new Error('files must contain at most 100 valid absolute file paths; [] clears selection');
7
+ }
8
+ const chooser = fileChoosers.get(tabId);
9
+ let target = node;
10
+ if (!target) {
11
+ if (!chooser) throw new Error('No pending file chooser. Supply a fresh file-input/label ref, or click the upload button first.');
12
+ const resolved = await send({ tabId, ...(chooser.sessionId ? { sessionId: chooser.sessionId } : {}) }, 'DOM.resolveNode', { backendNodeId: chooser.backendNodeId, objectGroup: 'browser-attach' });
13
+ if (!resolved.object?.objectId) throw new Error('File chooser input no longer exists. Observe again.');
14
+ target = { objectId: resolved.object.objectId, target: { tabId, ...(chooser.sessionId ? { sessionId: chooser.sessionId } : {}) } };
15
+ }
16
+ const resolved = await send(target.target, 'Runtime.callFunctionOn', {
17
+ objectId: target.objectId, arguments: [{ value: args.files.length }],
18
+ functionDeclaration: `function(count) {
19
+ const input = this.tagName === 'LABEL' ? this.control : this;
20
+ if (!input || input.tagName !== 'INPUT' || input.type !== 'file') throw new Error('Ref is not a file input or associated label');
21
+ if (!input.isConnected) throw new Error('File input is detached');
22
+ if (input.matches(':disabled')) throw new Error('File input is disabled');
23
+ if (input.webkitdirectory) throw new Error('Directory upload is unsupported; use a regular file input');
24
+ if (count > 1 && !input.multiple) throw new Error('Single-file input cannot accept multiple files');
25
+ return input;
26
+ }`, objectGroup: 'browser-attach',
27
+ });
28
+ if (resolved.exceptionDetails) value(resolved);
29
+ if (!resolved.result?.objectId) throw new Error('Could not resolve the file input');
30
+ args.budget.dispatch();
31
+ if (args.files.length === 0) {
32
+ // Chromium's native empty-path assignment does not emit input/change.
33
+ // Like Playwright's empty payload path, explicitly clear and notify the app.
34
+ value(await send(target.target, 'Runtime.callFunctionOn', { objectId: resolved.result.objectId, returnByValue: true,
35
+ functionDeclaration: `function() {
36
+ this.value = '';
37
+ const Event = this.ownerDocument.defaultView.Event;
38
+ this.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
39
+ this.dispatchEvent(new Event('change', { bubbles: true }));
40
+ return true;
41
+ }` }));
42
+ } else {
43
+ // Native CDP assignment emits input/change and preserves actual file metadata.
44
+ await send(target.target, 'DOM.setFileInputFiles', { objectId: resolved.result.objectId, files: args.files });
45
+ }
46
+ fileChoosers.delete(tabId);
47
+ return { files_set: args.files.length, cleared: args.files.length === 0, events_trusted: args.files.length > 0, observe_again: true };
48
+ }
49
+ return { upload,
50
+ opened(tabId, params, sessionId) {
51
+ if (params.backendNodeId) fileChoosers.set(tabId, { sessionId, backendNodeId: params.backendNodeId, mode: params.mode, frame_id: params.frameId });
52
+ },
53
+ pending: tabId => fileChoosers.get(tabId),
54
+ forget: tabId => fileChoosers.delete(tabId),
55
+ forgetFrames(tabId, affected) { if (affected.has(fileChoosers.get(tabId)?.frame_id)) fileChoosers.delete(tabId); },
56
+ };
57
+ }
package/extension/wait.js CHANGED
@@ -1,117 +1,119 @@
1
- import { deadline, inspectTarget } from './actionability.js';
2
- import { queryElement, targetOptions, resolveSelector, resolveFrame } from './locator.js';
3
-
4
- export const WAIT_CONDITIONS = ['attached', 'absent', 'visible', 'actionable', 'text', 'value', 'checked', 'selected', 'expanded', 'url', 'network_idle'];
5
-
6
- export function waitOptions(args) {
7
- if (!WAIT_CONDITIONS.includes(args.condition)) throw new Error('Unknown wait condition');
8
- const element = !['url', 'network_idle'].includes(args.condition);
9
- if (element) {
10
- if (!args.selector || args.ref != null) throw new Error(`${args.condition} wait requires a reusable selector${args.condition === 'text' ? '; use selector="body" for whole-page rendered text' : ''}`);
11
- targetOptions(args);
12
- } else if (args.selector != null || args.ref != null || args.frame_id != null || args.frame != null || args.nth != null || args.selector_type != null || args.name != null) throw new Error('This wait condition does not use an element target');
13
- if (args.condition === 'text' && (Number(typeof args.expected === 'string') + Number(typeof args.previous === 'string') !== 1)) throw new Error('text wait requires exactly one of expected (substring) or previous (full prior text)');
14
- if (args.condition === 'url' && typeof args.expected !== 'string') throw new Error('url wait requires expected text (substring)');
15
- if (args.condition === 'value' && typeof args.expected !== 'string') throw new Error('value wait requires an exact expected string');
16
- if (['checked', 'selected', 'expanded'].includes(args.condition) && typeof args.expected !== 'boolean' && !(args.condition === 'checked' && args.expected === 'mixed')) throw new Error(`${args.condition} wait requires expected=true/false${args.condition === 'checked' ? ' or "mixed"' : ''}`);
17
- if (!['text', 'url', 'value', 'checked', 'selected', 'expanded'].includes(args.condition) && args.expected != null) throw new Error('expected is not used by this wait condition');
18
- if (args.condition !== 'text' && args.previous != null) throw new Error('previous is only valid for text waits');
19
- if (args.condition === 'network_idle' && (!Number.isInteger(args.idle_ms ?? 500) || (args.idle_ms ?? 500) < 100 || (args.idle_ms ?? 500) > 10000)) throw new Error('idle_ms must be 100..10000');
20
- }
21
-
22
- export async function waitFor(cdp, value, sessions, diagnostics, mouse, tabId, args) {
23
- waitOptions(args);
24
- const budget = args.budget || deadline(args.timeout_ms ?? 5000, 'wait');
25
- let current = 'not observed', previous;
26
- const description = `${args.condition}${args.selector ? ` selector=${JSON.stringify(args.selector)}` : ''}${args.name != null ? ` name=${JSON.stringify(args.name)}` : ''}${args.expected != null && args.condition !== 'value' ? ` expected${['text', 'url'].includes(args.condition) ? ' substring' : ''}=${JSON.stringify(args.expected)}` : ''}${args.previous != null ? ` text different from=${JSON.stringify(args.previous).slice(0, 500)}` : ''}`;
27
- try {
28
- while (true) {
29
- if (args.condition === 'network_idle') {
30
- current = diagnostics.networkStatus(tabId);
31
- if (!current.tracking_overflow && current.inflight === 0 && current.idle_ms >= (args.idle_ms ?? 500)) return { satisfied: true, condition: args.condition, current };
32
- } else if (args.condition === 'url') {
33
- current = value(await budget.run(() => cdp(tabId, 'Runtime.evaluate', { expression: 'location.href', returnByValue: true })));
34
- if (current.includes(args.expected)) return { satisfied: true, condition: args.condition, current };
35
- } else {
36
- try {
37
- const frameId = await resolveFrame(cdp, value, sessions, tabId, args, budget);
38
- const target = sessions.target(tabId, frameId);
39
- if (args.selector_type === 'role') {
40
- const node = await resolveSelector(cdp, value, sessions, tabId, args, budget, { wait: false });
41
- try {
42
- current = node ? value(await budget.run(() => cdp(node.target, 'Runtime.callFunctionOn', { objectId: node.objectId, returnByValue: true,
43
- functionDeclaration: `function(args) { return (${inspectWait.toString()})(() => this, (${inspectTarget.toString()}), args); }`, arguments: [{ value: { selector: args.selector, condition: args.condition, expected: args.expected, previous: args.previous } }] }))) : { satisfied: args.condition === 'absent', state: 'absent' };
44
- } finally {
45
- if (node) await budget.run(() => cdp(node.target, 'Runtime.releaseObjectGroup', { objectGroup: 'browser-attach' }));
46
- }
47
- } else {
48
- const { executionContextId } = await budget.run(() => cdp(target, 'Page.createIsolatedWorld', { frameId, worldName: 'browser-attach-locator' }));
49
- current = value(await budget.run(() => cdp(target, 'Runtime.evaluate', { contextId: executionContextId, returnByValue: true,
50
- expression: `(${inspectWait.toString()})((${queryElement.toString()}),(${inspectTarget.toString()}),${JSON.stringify(args)})` })));
51
- }
52
- const stable = current?.ready && previous?.ready && JSON.stringify(current) === JSON.stringify(previous);
53
- if (current?.satisfied && (args.condition !== 'actionable' || stable)) {
54
- if (args.condition === 'actionable') {
55
- try {
56
- const node = await resolveSelector(cdp, value, sessions, tabId, args, budget);
57
- try {
58
- await mouse.prepare(tabId, node, { budget, scroll: false });
59
- current = { ...current, state: 'actionable' };
60
- } finally {
61
- await budget.run(() => cdp(node.target, 'Runtime.releaseObjectGroup', { objectGroup: 'browser-attach' }));
62
- }
63
- } catch (error) {
64
- if (!/covers|no visible click point|detached before resolution/i.test(error.message)) throw error;
65
- current = { satisfied: false, reason: error.message, ...(error.diagnostics ? { diagnostics: error.diagnostics } : {}) };
66
- }
67
- }
68
- if (current.satisfied) return { satisfied: true, condition: args.condition, current };
69
- }
70
- previous = current;
71
- } catch (error) {
72
- if (!/context.*(destroyed|not found)|cannot find context|no frame with given id|inspected target navigated|detached before resolution|no node|could not find node|node.*(detached|not found)/i.test(error.message)) throw error;
73
- current = 'document/frame changing'; previous = null;
74
- }
75
- }
76
- await budget.run(() => new Promise(resolve => setTimeout(resolve, 50)));
77
- }
78
- } catch (error) {
79
- throw Object.assign(new Error(`Wait for ${description} failed: ${error.message}; current=${JSON.stringify(current)}`), {
80
- input_dispatched: false, diagnostics: error.diagnostics ?? { reason: 'wait_unsatisfied', condition: args.condition, current },
81
- });
82
- }
83
- }
84
-
85
- export function inspectWait(query, inspect, args) {
86
- const element = query(args.selector, args.selector_type ?? 'css', args.nth, true);
87
- if (!element) return { satisfied: args.condition === 'absent', state: 'absent' };
88
- if (element.isConnected === false) return { satisfied: false, state: 'detached_during_observation' };
89
- if (args.condition === 'attached') return { satisfied: true, state: 'attached' };
90
- if (args.condition === 'absent') return { satisfied: false, state: 'attached' };
91
- if (args.condition === 'text') {
92
- const text = element.innerText ?? '';
93
- return { satisfied: args.previous != null ? text !== args.previous : text.includes(args.expected), text: text.slice(0, 500) };
94
- }
95
- if (args.condition === 'value') {
96
- const text = ['INPUT', 'TEXTAREA', 'SELECT'].includes(element.tagName) ? element.value : element.isContentEditable ? element.innerText : undefined;
97
- // Field values may contain credentials. Report the comparison, not its contents.
98
- return { satisfied: typeof text === 'string' && text === args.expected, state: typeof text === 'string' ? 'value' : 'unavailable' };
99
- }
100
- if (['checked', 'selected', 'expanded'].includes(args.condition)) {
101
- let state, source = 'property';
102
- if (args.condition === 'checked' && element.tagName === 'INPUT' && ['checkbox', 'radio'].includes(element.type)) state = element.type === 'checkbox' && element.indeterminate ? 'mixed' : element.checked;
103
- else if (args.condition === 'selected' && element.tagName === 'OPTION') state = element.selected;
104
- else if (args.condition === 'expanded' && element.tagName === 'DETAILS') state = element.open;
105
- else {
106
- source = `aria-${args.condition}`;
107
- const attribute = element.getAttribute(source);
108
- state = attribute === 'true' ? true : attribute === 'false' ? false : args.condition === 'checked' && attribute === 'mixed' ? 'mixed' : undefined;
109
- }
110
- return { satisfied: state !== undefined && state === args.expected, state: state === undefined ? 'unavailable' : args.condition, value: state ?? null, source };
111
- }
112
- const bounds = inspect.call(element);
113
- if (args.condition === 'visible') return { ...bounds,
114
- // Visibility is independent of disabled/inert state.
115
- satisfied: element.getBoundingClientRect().width > 0 && element.getBoundingClientRect().height > 0 && element.ownerDocument.defaultView.getComputedStyle(element).visibility === 'visible' };
116
- return { ...bounds, satisfied: !!bounds.ready };
117
- }
1
+ import { deadline, inspectTarget } from './actionability.js';
2
+ import { queryElement, targetOptions, resolveSelector, resolveFrame } from './locator.js';
3
+ import { checkedValue } from './errors.js';
4
+
5
+ export const WAIT_CONDITIONS = ['attached', 'absent', 'visible', 'actionable', 'text', 'value', 'checked', 'selected', 'expanded', 'url', 'network_idle'];
6
+
7
+ export function waitOptions(args) {
8
+ if (!WAIT_CONDITIONS.includes(args.condition)) throw new Error('Unknown wait condition');
9
+ const element = !['url', 'network_idle'].includes(args.condition);
10
+ if (element) {
11
+ if (!args.selector || args.ref != null) throw new Error(`${args.condition} wait requires a reusable selector${args.condition === 'text' ? '; use selector="body" for whole-page rendered text' : ''}`);
12
+ targetOptions(args);
13
+ } else if (args.selector != null || args.ref != null || args.frame_id != null || args.frame != null || args.nth != null || args.selector_type != null || args.name != null) throw new Error('This wait condition does not use an element target');
14
+ if (args.condition === 'text' && (Number(typeof args.expected === 'string') + Number(typeof args.previous === 'string') !== 1)) throw new Error('text wait requires exactly one of expected (substring) or previous (full prior text)');
15
+ if (args.condition === 'url' && typeof args.expected !== 'string') throw new Error('url wait requires expected text (substring)');
16
+ if (args.condition === 'value' && typeof args.expected !== 'string') throw new Error('value wait requires an exact expected string');
17
+ if (['checked', 'selected', 'expanded'].includes(args.condition) && typeof args.expected !== 'boolean' && !(args.condition === 'checked' && args.expected === 'mixed')) throw new Error(`${args.condition} wait requires expected=true/false${args.condition === 'checked' ? ' or "mixed"' : ''}`);
18
+ if (!['text', 'url', 'value', 'checked', 'selected', 'expanded'].includes(args.condition) && args.expected != null) throw new Error('expected is not used by this wait condition');
19
+ if (args.condition !== 'text' && args.previous != null) throw new Error('previous is only valid for text waits');
20
+ if (args.condition === 'network_idle' && (!Number.isInteger(args.idle_ms ?? 500) || (args.idle_ms ?? 500) < 100 || (args.idle_ms ?? 500) > 10000)) throw new Error('idle_ms must be 100..10000');
21
+ }
22
+
23
+ export async function waitFor(cdp, value, sessions, network, pointer, tabId, args) {
24
+ waitOptions(args);
25
+ const budget = args.budget || deadline(args.timeout_ms ?? 5000, 'wait');
26
+ let current = 'not observed', previous;
27
+ const description = `${args.condition}${args.selector ? ` selector=${JSON.stringify(args.selector)}` : ''}${args.name != null ? ` name=${JSON.stringify(args.name)}` : ''}${args.expected != null && args.condition !== 'value' ? ` expected${['text', 'url'].includes(args.condition) ? ' substring' : ''}=${JSON.stringify(args.expected)}` : ''}${args.previous != null ? ` text different from=${JSON.stringify(args.previous).slice(0, 500)}` : ''}`;
28
+ try {
29
+ while (true) {
30
+ if (args.condition === 'network_idle') {
31
+ current = network.networkStatus(tabId);
32
+ if (!current.tracking_overflow && current.inflight === 0 && current.idle_ms >= (args.idle_ms ?? 500)) return { satisfied: true, condition: args.condition, current };
33
+ } else if (args.condition === 'url') {
34
+ current = value(await budget.run(() => cdp(tabId, 'Runtime.evaluate', { expression: 'location.href', returnByValue: true })));
35
+ if (current.includes(args.expected)) return { satisfied: true, condition: args.condition, current };
36
+ } else {
37
+ try {
38
+ const frameId = await resolveFrame(cdp, value, sessions, tabId, args, budget);
39
+ const target = sessions.target(tabId, frameId);
40
+ if (args.selector_type === 'role') {
41
+ const node = await resolveSelector(cdp, value, sessions, tabId, args, budget, { wait: false });
42
+ try {
43
+ current = node ? value(await budget.run(() => cdp(node.target, 'Runtime.callFunctionOn', { objectId: node.objectId, returnByValue: true,
44
+ functionDeclaration: `function(args) { return (${inspectWait.toString()})(() => this, (${inspectTarget.toString()}), args); }`, arguments: [{ value: { selector: args.selector, condition: args.condition, expected: args.expected, previous: args.previous } }] }))) : { satisfied: args.condition === 'absent', state: 'absent' };
45
+ } finally {
46
+ if (node) await budget.run(() => cdp(node.target, 'Runtime.releaseObjectGroup', { objectGroup: 'browser-attach' }));
47
+ }
48
+ } else {
49
+ const { executionContextId } = await budget.run(() => cdp(target, 'Page.createIsolatedWorld', { frameId, worldName: 'browser-attach-locator' }));
50
+ const send = (method, params) => budget.run(() => cdp(target, method, params));
51
+ current = await checkedValue(await send('Runtime.evaluate', { contextId: executionContextId, returnByValue: true,
52
+ expression: `(${inspectWait.toString()})((${queryElement.toString()}),(${inspectTarget.toString()}),${JSON.stringify(args)})` }), send, value);
53
+ }
54
+ const stable = current?.ready && previous?.ready && JSON.stringify(current) === JSON.stringify(previous);
55
+ if (current?.satisfied && (args.condition !== 'actionable' || stable)) {
56
+ if (args.condition === 'actionable') {
57
+ try {
58
+ const node = await resolveSelector(cdp, value, sessions, tabId, args, budget);
59
+ try {
60
+ await pointer.prepare(tabId, node, { budget, scroll: false });
61
+ current = { ...current, state: 'actionable' };
62
+ } finally {
63
+ await budget.run(() => cdp(node.target, 'Runtime.releaseObjectGroup', { objectGroup: 'browser-attach' }));
64
+ }
65
+ } catch (error) {
66
+ if (!/covers|no visible click point|detached before resolution/i.test(error.message)) throw error;
67
+ current = { satisfied: false, reason: error.message, ...(error.diagnostics ? { diagnostics: error.diagnostics } : {}) };
68
+ }
69
+ }
70
+ if (current.satisfied) return { satisfied: true, condition: args.condition, current };
71
+ }
72
+ previous = current;
73
+ } catch (error) {
74
+ if (!/context.*(destroyed|not found)|cannot find context|no frame with given id|inspected target navigated|detached before resolution|no node|could not find node|node.*(detached|not found)/i.test(error.message)) throw error;
75
+ current = 'document/frame changing'; previous = null;
76
+ }
77
+ }
78
+ await budget.run(() => new Promise(resolve => setTimeout(resolve, 50)));
79
+ }
80
+ } catch (error) {
81
+ throw Object.assign(new Error(`Wait for ${description} failed: ${error.message}; current=${JSON.stringify(current)}`), {
82
+ input_dispatched: false, diagnostics: error.diagnostics ?? { reason: 'wait_unsatisfied', condition: args.condition, current },
83
+ });
84
+ }
85
+ }
86
+
87
+ export function inspectWait(query, inspect, args) {
88
+ const element = query(args.selector, args.selector_type ?? 'css', args.nth, true);
89
+ if (!element) return { satisfied: args.condition === 'absent', state: 'absent' };
90
+ if (element.isConnected === false) return { satisfied: false, state: 'detached_during_observation' };
91
+ if (args.condition === 'attached') return { satisfied: true, state: 'attached' };
92
+ if (args.condition === 'absent') return { satisfied: false, state: 'attached' };
93
+ if (args.condition === 'text') {
94
+ const text = element.innerText ?? '';
95
+ return { satisfied: args.previous != null ? text !== args.previous : text.includes(args.expected), text: text.slice(0, 500) };
96
+ }
97
+ if (args.condition === 'value') {
98
+ const text = ['INPUT', 'TEXTAREA', 'SELECT'].includes(element.tagName) ? element.value : element.isContentEditable ? element.innerText : undefined;
99
+ // Field values may contain credentials. Report the comparison, not its contents.
100
+ return { satisfied: typeof text === 'string' && text === args.expected, state: typeof text === 'string' ? 'value' : 'unavailable' };
101
+ }
102
+ if (['checked', 'selected', 'expanded'].includes(args.condition)) {
103
+ let state, source = 'property';
104
+ if (args.condition === 'checked' && element.tagName === 'INPUT' && ['checkbox', 'radio'].includes(element.type)) state = element.type === 'checkbox' && element.indeterminate ? 'mixed' : element.checked;
105
+ else if (args.condition === 'selected' && element.tagName === 'OPTION') state = element.selected;
106
+ else if (args.condition === 'expanded' && element.tagName === 'DETAILS') state = element.open;
107
+ else {
108
+ source = `aria-${args.condition}`;
109
+ const attribute = element.getAttribute(source);
110
+ state = attribute === 'true' ? true : attribute === 'false' ? false : args.condition === 'checked' && attribute === 'mixed' ? 'mixed' : undefined;
111
+ }
112
+ return { satisfied: state !== undefined && state === args.expected, state: state === undefined ? 'unavailable' : args.condition, value: state ?? null, source };
113
+ }
114
+ const bounds = inspect.call(element);
115
+ if (args.condition === 'visible') return { ...bounds,
116
+ // Visibility is independent of disabled/inert state.
117
+ satisfied: element.getBoundingClientRect().width > 0 && element.getBoundingClientRect().height > 0 && element.ownerDocument.defaultView.getComputedStyle(element).visibility === 'visible' };
118
+ return { ...bounds, satisfied: !!bounds.ready };
119
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evan_zhong/browser-attach",
3
- "version": "0.21.0",
3
+ "version": "0.22.1",
4
4
  "description": "MCP for existing signed-in Chrome and Edge tabs through a locally paired extension",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -15,6 +15,7 @@ for (const path of names) {
15
15
  if (/config\.generated\.js|(^|\/)(\.local|\.venv|test-results|node_modules)\//.test(path)) throw new Error(`Private file included in package: ${path}`);
16
16
  }
17
17
  for (const path of ['extension/connection.js', 'extension/version.js', 'extension/errors.js', 'extension/page-url.js']) if (!names.includes(path)) throw new Error(`Package is missing ${path}`);
18
+ for (const path of ['src/hub-runtime.mjs', 'extension/observation.js', 'extension/pointer.js', 'extension/dialogs.js', 'extension/network.js', 'extension/log-data.js', 'extension/fetch-page.js', 'extension/screenshot.js', 'extension/upload.js']) if (!names.includes(path)) throw new Error(`Package is missing ${path}`);
18
19
  for (const path of ['bin/browser-attach.mjs', 'src/mcp.mjs', 'src/hub.mjs', 'extension/manifest.json', 'extension/evaluate.js', 'extension/fetch.js', 'extension/locator.js', 'extension/batch.js', 'extension/wait.js', 'extension/outcomes.js', 'extension/scroll.js', 'extension/drag.js', 'extension/text.js', 'extension/diagnostics.js', 'extension/sessions.js', 'extension/actionability.js', 'extension/limits.js', 'extension/icons/BrowserAttach.png']) {
19
20
  if (!names.includes(path)) throw new Error(`Package is missing ${path}`);
20
21
  }
package/src/client.mjs CHANGED
@@ -1,135 +1,50 @@
1
- import { CLIENT_RESPONSE_TIMEOUT_MS } from '../extension/limits.js';
2
- import { errorFromData } from '../extension/errors.js';
3
- import { spawn } from 'node:child_process';
4
- import { open, mkdir } from 'node:fs/promises';
5
- import { dirname, join, resolve } from 'node:path';
6
- import { fileURLToPath } from 'node:url';
7
- import WebSocket from 'ws';
8
- import { loadConfig, VERSION } from './config.mjs';
9
- import { compareVersions } from '../extension/version.js';
10
-
11
- async function hubHealth(config) {
12
- let response;
13
- try {
14
- response = await fetch(`http://127.0.0.1:${config.port}/health`, {
15
- headers: { Authorization: `Bearer ${config.token}` }, signal: AbortSignal.timeout(1000),
16
- });
17
- } catch (error) {
18
- if (['ECONNREFUSED', 'ECONNRESET', 'ECONNABORTED', 'UND_ERR_SOCKET'].includes(error.cause?.code) || error.name === 'TimeoutError') return null;
19
- throw error;
20
- }
21
- if (!response.ok) throw new Error('BrowserAttach port is occupied by an unpaired service');
22
- const health = await response.json();
23
- if (health.service !== 'browser-attach') throw new Error('BrowserAttach port is occupied by another service');
24
- compareVersions(health.version, VERSION);
25
- return health;
26
- }
27
-
28
- export async function hubIsRunning(config) {
29
- const health = await hubHealth(config);
30
- if (!health) return false;
31
- if (health.version !== VERSION) throw new Error(`Hub ${health.version} differs from this package ${VERSION}; reconnect MCP to coordinate an upgrade`);
32
- return health.state === 'ready';
33
- }
34
-
35
- export async function ensureHub(configPath) {
36
- configPath = resolve(configPath);
37
- const config = await loadConfig(configPath);
38
- const deadline = Date.now() + 18000;
39
- const expected = new Set();
40
- let spawned = false, requested = false, readyAfter = 0, spawnError;
41
- while (Date.now() < deadline) {
42
- if (spawnError) throw spawnError;
43
- const health = await hubHealth(config);
44
- if (!health) {
45
- if (!spawned) {
46
- spawned = true;
47
- await spawnHub(configPath, error => { spawnError = error; });
48
- readyAfter = Date.now() + 2500;
49
- }
50
- } else {
51
- const comparison = compareVersions(health.version, VERSION);
52
- if (comparison > 0) throw new Error(`Hub ${health.version} is newer than this package ${VERSION}; refusing to downgrade`);
53
- if (comparison < 0 && health.state == null) {
54
- throw new Error(`Hub ${health.version} predates coordinated upgrades; run browser-attach stop, restart MCP, and reload the extension manually if it was already loaded`);
55
- }
56
- if (comparison < 0 && health.state === 'ready' && !requested) {
57
- requested = true;
58
- for (const item of health.extensions) expected.add(item.instance_id);
59
- const response = await fetch(`http://127.0.0.1:${config.port}/upgrade`, { method: 'POST',
60
- headers: { Authorization: `Bearer ${config.token}`, 'X-Browser-Attach-Version': VERSION }, signal: AbortSignal.timeout(10000) });
61
- const result = await response.json();
62
- if (!response.ok) {
63
- const current = await hubHealth(config);
64
- if (current && compareVersions(current.version, VERSION) < 0 && !['upgrading', 'stopping'].includes(current.state)) throw new Error(result.error);
65
- } else for (const id of result.extensions) expected.add(id);
66
- } else if (comparison < 0 && health.state === 'ready' && requested) {
67
- throw new Error(health.upgrade_error || 'Hub upgrade did not complete; reconnect MCP to retry');
68
- } else if (comparison === 0 && health.state === 'ready') {
69
- const failure = health.extensions.find(item => item.error);
70
- if (failure) throw new Error(`Extension upgrade failed (${failure.instance_id}): ${failure.error}`);
71
- for (const item of health.extensions) if (item.ready) expected.delete(item.instance_id);
72
- if (!expected.size && health.extensions.every(item => item.ready) && Date.now() >= readyAfter) return config;
73
- }
74
- }
75
- await new Promise(resolve => setTimeout(resolve, 150));
76
- }
77
- throw new Error(`Hub/extension upgrade did not become ready${expected.size ? `; waiting for ${[...expected].join(', ')}` : ''}. See ${join(dirname(configPath), 'hub.log')}`);
78
- }
79
-
80
- async function spawnHub(configPath, onError) {
81
- await mkdir(dirname(configPath), { recursive: true, mode: 0o700 });
82
- const log = await open(join(dirname(configPath), 'hub.log'), 'a', 0o600);
83
- const child = spawn(process.execPath, [fileURLToPath(new URL('../bin/browser-attach.mjs', import.meta.url)), 'hub', '--config', configPath], {
84
- detached: true, windowsHide: true, stdio: ['ignore', log.fd, log.fd],
85
- env: { ...process.env, BROWSER_ATTACH_HOME: dirname(configPath) },
86
- });
87
- child.on('error', onError);
88
- child.unref(); await log.close();
89
- }
90
-
91
- export class AttachClient {
92
- constructor(config) { this.config = config; this.pending = new Map(); this.counter = 0; }
93
- async connect() {
94
- if (this.socket?.readyState === WebSocket.OPEN) return;
95
- if (this.connecting) return this.connecting;
96
- this.connecting = new Promise((resolve, reject) => {
97
- const ws = new WebSocket(`ws://127.0.0.1:${this.config.port}/client`, {
98
- headers: { Authorization: `Bearer ${this.config.token}`, 'X-Browser-Attach-Version': VERSION }, maxPayload: 24 * 1024 * 1024,
99
- });
100
- this.socket = ws;
101
- ws.once('open', resolve); ws.once('error', reject);
102
- ws.on('message', raw => {
103
- let data;
104
- try { data = JSON.parse(raw.toString()); } catch { ws.close(); return; }
105
- const item = this.pending.get(data.id);
106
- if (!item) return;
107
- this.pending.delete(data.id); clearTimeout(item.timer);
108
- if ('error' in data) item.reject(errorFromData(data)); else item.resolve(data.result);
109
- });
110
- ws.on('close', () => {
111
- reject(new Error('Hub connection closed'));
112
- for (const item of this.pending.values()) { clearTimeout(item.timer); item.reject(new Error('Hub disconnected; outcome may be unknown. Observe again.')); }
113
- this.pending.clear(); this.connecting = null;
114
- });
115
- });
116
- return this.connecting;
117
- }
118
- async call(method, args = {}) {
119
- await this.connect();
120
- return new Promise((resolve, reject) => {
121
- const id = ++this.counter;
122
- const timer = setTimeout(() => {
123
- this.pending.delete(id); this.socket.close();
124
- reject(new Error('Hub response timed out; outcome may be unknown. Observe again.'));
125
- }, CLIENT_RESPONSE_TIMEOUT_MS);
126
- this.pending.set(id, { resolve, reject, timer });
127
- this.socket.send(JSON.stringify({ id, method, arguments: args }));
128
- });
129
- }
130
- async close() {
131
- const ws = this.socket;
132
- if (!ws || ws.readyState === WebSocket.CLOSED) return;
133
- await new Promise(resolve => { ws.once('close', resolve); ws.close(); });
134
- }
135
- }
1
+ import { CLIENT_RESPONSE_TIMEOUT_MS } from '../extension/limits.js';
2
+ import { errorFromData } from '../extension/errors.js';
3
+ import WebSocket from 'ws';
4
+ import { VERSION } from './config.mjs';
5
+
6
+ export class AttachClient {
7
+ constructor(config) { this.config = config; this.pending = new Map(); this.counter = 0; }
8
+ async connect() {
9
+ if (this.socket?.readyState === WebSocket.OPEN) return;
10
+ if (this.connecting) return this.connecting;
11
+ this.connecting = new Promise((resolve, reject) => {
12
+ const ws = new WebSocket(`ws://127.0.0.1:${this.config.port}/client`, {
13
+ headers: { Authorization: `Bearer ${this.config.token}`, 'X-Browser-Attach-Version': VERSION }, maxPayload: 24 * 1024 * 1024,
14
+ });
15
+ this.socket = ws;
16
+ ws.once('open', resolve); ws.once('error', reject);
17
+ ws.on('message', raw => {
18
+ let data;
19
+ try { data = JSON.parse(raw.toString()); } catch { ws.close(); return; }
20
+ const item = this.pending.get(data.id);
21
+ if (!item) return;
22
+ this.pending.delete(data.id); clearTimeout(item.timer);
23
+ if ('error' in data) item.reject(errorFromData(data)); else item.resolve(data.result);
24
+ });
25
+ ws.on('close', () => {
26
+ reject(new Error('Hub connection closed'));
27
+ for (const item of this.pending.values()) { clearTimeout(item.timer); item.reject(new Error('Hub disconnected; outcome may be unknown. Observe again.')); }
28
+ this.pending.clear(); this.connecting = null;
29
+ });
30
+ });
31
+ return this.connecting;
32
+ }
33
+ async call(method, args = {}) {
34
+ await this.connect();
35
+ return new Promise((resolve, reject) => {
36
+ const id = ++this.counter;
37
+ const timer = setTimeout(() => {
38
+ this.pending.delete(id); this.socket.close();
39
+ reject(new Error('Hub response timed out; outcome may be unknown. Observe again.'));
40
+ }, CLIENT_RESPONSE_TIMEOUT_MS);
41
+ this.pending.set(id, { resolve, reject, timer });
42
+ this.socket.send(JSON.stringify({ id, method, arguments: args }));
43
+ });
44
+ }
45
+ async close() {
46
+ const ws = this.socket;
47
+ if (!ws || ws.readyState === WebSocket.CLOSED) return;
48
+ await new Promise(resolve => { ws.once('close', resolve); ws.close(); });
49
+ }
50
+ }
package/src/config.mjs CHANGED
@@ -15,7 +15,7 @@ export const DATA_DIR = configuredHome
15
15
  : process.platform === 'win32' ? join(homedir(), '.browser-attach')
16
16
  : join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'browser-attach');
17
17
  export const DEFAULT_CONFIG = join(DATA_DIR, 'config.json');
18
- export const VERSION = '0.21.0';
18
+ export const VERSION = '0.22.1';
19
19
 
20
20
  export async function loadConfig(path = DEFAULT_CONFIG) {
21
21
  let config;