@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,87 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { open, mkdir } from 'node:fs/promises';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { loadConfig, VERSION } from './config.mjs';
6
+ import { compareVersions } from '../extension/version.js';
7
+
8
+ async function hubHealth(config) {
9
+ let response;
10
+ try {
11
+ response = await fetch(`http://127.0.0.1:${config.port}/health`, {
12
+ headers: { Authorization: `Bearer ${config.token}` }, signal: AbortSignal.timeout(1000),
13
+ });
14
+ } catch (error) {
15
+ if (['ECONNREFUSED', 'ECONNRESET', 'ECONNABORTED', 'UND_ERR_SOCKET'].includes(error.cause?.code) || error.name === 'TimeoutError') return null;
16
+ throw error;
17
+ }
18
+ if (!response.ok) throw new Error('BrowserAttach port is occupied by an unpaired service');
19
+ const health = await response.json();
20
+ if (health.service !== 'browser-attach') throw new Error('BrowserAttach port is occupied by another service');
21
+ compareVersions(health.version, VERSION);
22
+ return health;
23
+ }
24
+
25
+ export async function hubIsRunning(config) {
26
+ const health = await hubHealth(config);
27
+ if (!health) return false;
28
+ if (health.version !== VERSION) throw new Error(`Hub ${health.version} differs from this package ${VERSION}; reconnect MCP to coordinate an upgrade`);
29
+ return health.state === 'ready';
30
+ }
31
+
32
+ export async function ensureHub(configPath) {
33
+ configPath = resolve(configPath);
34
+ const config = await loadConfig(configPath);
35
+ const deadline = Date.now() + 18000;
36
+ const expected = new Set();
37
+ let spawned = false, requested = false, readyAfter = 0, spawnError;
38
+ while (Date.now() < deadline) {
39
+ if (spawnError) throw spawnError;
40
+ const health = await hubHealth(config);
41
+ if (!health) {
42
+ if (!spawned) {
43
+ spawned = true;
44
+ await spawnHub(configPath, error => { spawnError = error; });
45
+ readyAfter = Date.now() + 2500;
46
+ }
47
+ } else {
48
+ const comparison = compareVersions(health.version, VERSION);
49
+ if (comparison > 0) throw new Error(`Hub ${health.version} is newer than this package ${VERSION}; refusing to downgrade`);
50
+ if (comparison < 0 && health.state == null) {
51
+ 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`);
52
+ }
53
+ if (comparison < 0 && health.state === 'ready' && !requested) {
54
+ requested = true;
55
+ for (const item of health.extensions) expected.add(item.instance_id);
56
+ const response = await fetch(`http://127.0.0.1:${config.port}/upgrade`, { method: 'POST',
57
+ headers: { Authorization: `Bearer ${config.token}`, 'X-Browser-Attach-Version': VERSION }, signal: AbortSignal.timeout(10000) });
58
+ const result = await response.json();
59
+ if (!response.ok) {
60
+ const current = await hubHealth(config);
61
+ if (current && compareVersions(current.version, VERSION) < 0 && !['upgrading', 'stopping'].includes(current.state)) throw new Error(result.error);
62
+ } else for (const id of result.extensions) expected.add(id);
63
+ } else if (comparison < 0 && health.state === 'ready' && requested) {
64
+ throw new Error(health.upgrade_error || 'Hub upgrade did not complete; reconnect MCP to retry');
65
+ } else if (comparison === 0 && health.state === 'ready') {
66
+ const failure = health.extensions.find(item => item.error);
67
+ if (failure) throw new Error(`Extension upgrade failed (${failure.instance_id}): ${failure.error}`);
68
+ for (const item of health.extensions) if (item.ready) expected.delete(item.instance_id);
69
+ if (!expected.size && health.extensions.every(item => item.ready) && Date.now() >= readyAfter) return config;
70
+ }
71
+ }
72
+ await new Promise(resolve => setTimeout(resolve, 150));
73
+ }
74
+ throw new Error(`Hub/extension upgrade did not become ready${expected.size ? `; waiting for ${[...expected].join(', ')}` : ''}. See ${join(dirname(configPath), 'hub.log')}`);
75
+ }
76
+
77
+ async function spawnHub(configPath, onError) {
78
+ await mkdir(dirname(configPath), { recursive: true, mode: 0o700 });
79
+ const log = await open(join(dirname(configPath), 'hub.log'), 'a', 0o600);
80
+ const child = spawn(process.execPath, [fileURLToPath(new URL('../bin/browser-attach.mjs', import.meta.url)), 'hub', '--config', configPath], {
81
+ detached: true, windowsHide: true, stdio: ['ignore', log.fd, log.fd],
82
+ env: { ...process.env, BROWSER_ATTACH_HOME: dirname(configPath) },
83
+ });
84
+ child.on('error', onError);
85
+ child.unref(); await log.close();
86
+ }
87
+