@browserbridge/bbx 1.8.0 → 1.10.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/PRIVACY.md +29 -3
- package/README.md +10 -3
- package/SECURITY.md +12 -0
- package/package.json +7 -1
- package/packages/agent-client/src/cli-args.js +70 -6
- package/packages/agent-client/src/cli-batch.js +127 -59
- package/packages/agent-client/src/cli-helpers.js +8 -0
- package/packages/agent-client/src/cli-output.js +8 -1
- package/packages/agent-client/src/cli-setup-commands.js +3 -1
- package/packages/agent-client/src/cli.js +254 -26
- package/packages/agent-client/src/client.js +156 -3
- package/packages/agent-client/src/command-registry.js +14 -4
- package/packages/agent-client/src/config.js +195 -0
- package/packages/agent-client/src/mcp-config.js +8 -8
- package/packages/agent-client/src/npm-self-update.js +305 -0
- package/packages/agent-client/src/remotes.js +4 -2
- package/packages/agent-client/src/runtime.js +99 -7
- package/packages/agent-client/src/types.ts +38 -0
- package/packages/mcp-server/src/guidance.js +37 -6
- package/packages/mcp-server/src/handlers-capture.js +29 -2
- package/packages/mcp-server/src/handlers-dom.js +54 -3
- package/packages/mcp-server/src/handlers-page.js +333 -60
- package/packages/mcp-server/src/handlers-utils.js +141 -151
- package/packages/mcp-server/src/handlers.js +17 -4
- package/packages/mcp-server/src/server.js +269 -51
- package/packages/mcp-server/src/toolset.js +48 -0
- package/packages/native-host/src/artifact-store.js +353 -0
- package/packages/native-host/src/config.js +9 -1
- package/packages/native-host/src/daemon-logger.js +3 -1
- package/packages/native-host/src/daemon.js +457 -27
- package/packages/native-host/src/dom-baseline-owners.js +210 -0
- package/packages/native-host/src/native-host.js +38 -29
- package/packages/protocol/src/budget-presets.js +182 -0
- package/packages/protocol/src/capability-values.js +2 -0
- package/packages/protocol/src/defaults.js +28 -0
- package/packages/protocol/src/errors.js +48 -5
- package/packages/protocol/src/har.js +34 -0
- package/packages/protocol/src/incidental-sanitizer.js +197 -0
- package/packages/protocol/src/index.js +4 -0
- package/packages/protocol/src/json-lines.js +3 -2
- package/packages/protocol/src/protocol.js +316 -6
- package/packages/protocol/src/recovery-telemetry.js +302 -0
- package/packages/protocol/src/registry.js +226 -6
- package/packages/protocol/src/summary.js +116 -25
- package/packages/protocol/src/types.ts +490 -4
- package/skills/browser-bridge/SKILL.md +26 -8
- package/skills/browser-bridge/references/protocol.md +125 -15
- package/skills/browser-bridge/references/token-efficiency.md +17 -7
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { BRIDGE_METHOD_REGISTRY, BRIDGE_METHODS } from '../../protocol/src/index.js';
|
|
4
4
|
import {
|
|
5
|
+
isElementRef,
|
|
5
6
|
parseCommaList,
|
|
6
7
|
parseIntArg,
|
|
7
8
|
parseNumberArg,
|
|
@@ -39,7 +40,7 @@ function createShortcutCommand(method, usage, build, options = {}) {
|
|
|
39
40
|
*/
|
|
40
41
|
function createAtomicTarget(value) {
|
|
41
42
|
if (!value) throw new Error('A ref or selector is required.');
|
|
42
|
-
return value
|
|
43
|
+
return isElementRef(value) ? { elementRef: value } : { selector: value };
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
/** @type {Record<string, ShortcutCommand>} */
|
|
@@ -204,7 +205,10 @@ export const SHORTCUT_COMMANDS = {
|
|
|
204
205
|
maxDepth: r[1] ? parseIntArg(r[1], 'maxDepth') : undefined,
|
|
205
206
|
})
|
|
206
207
|
),
|
|
207
|
-
perf: createShortcutCommand('performance.get_metrics', 'bbx perf', () => ({})
|
|
208
|
+
perf: createShortcutCommand('performance.get_metrics', 'bbx perf', () => ({}), {
|
|
209
|
+
description:
|
|
210
|
+
'Read a raw Chrome/CDP counter point sample (names and units vary; no BBX navigation window or LCP/CLS/INP measurement)',
|
|
211
|
+
}),
|
|
208
212
|
scroll: createShortcutCommand('viewport.scroll', 'bbx scroll <top> [left]', (r) => {
|
|
209
213
|
if (!r[0] && !r[1]) throw new Error('Usage: scroll <top> [left]');
|
|
210
214
|
return {
|
|
@@ -250,6 +254,7 @@ export const CLI_METHOD_BINDINGS = Object.freeze({
|
|
|
250
254
|
'press-key': 'input.press_key',
|
|
251
255
|
'cdp-press-key': 'cdp.dispatch_key_event',
|
|
252
256
|
screenshot: 'screenshot.capture_element',
|
|
257
|
+
har: 'network.export_har',
|
|
253
258
|
eval: 'page.evaluate',
|
|
254
259
|
});
|
|
255
260
|
|
|
@@ -262,9 +267,11 @@ export const CLI_HELP_SECTIONS = Object.freeze([
|
|
|
262
267
|
'bbx uninstall Remove native host manifests, Browser Bridge runtime files, and managed MCP/skill installs',
|
|
263
268
|
'bbx install-skill [targets|all] [--global] [--project <path>] Install/update the managed Browser Bridge CLI skill',
|
|
264
269
|
'bbx install-mcp [client|all] [--local] Write MCP config for codex|claude|cursor|copilot|opencode|antigravity|windsurf|agents',
|
|
270
|
+
' MCP starts with common tools; browser_toolset loads one specialized typed tool by name when needed.',
|
|
265
271
|
'bbx status Check bridge connection',
|
|
266
272
|
'bbx doctor Consolidated local runtime, setup, protocol, and readiness diagnostics',
|
|
267
273
|
'bbx restart Restart the local daemon and running MCP servers',
|
|
274
|
+
'bbx config set auto-update <off|compatible> Configure protocol-compatible npm updates',
|
|
268
275
|
'bbx proxy enable [--port 9223] [--bind-host 127.0.0.1] [--rotate-token] Enable loopback TCP proxy mode for SSH tunneling',
|
|
269
276
|
' Non-loopback binds require --unsafe-plaintext because raw TCP is unencrypted.',
|
|
270
277
|
'bbx proxy disable Disable proxy mode and restart the daemon',
|
|
@@ -280,15 +287,17 @@ export const CLI_HELP_SECTIONS = Object.freeze([
|
|
|
280
287
|
'bbx tab-close <tabId> Close a tab',
|
|
281
288
|
'bbx tab-activate <tabId> Bring a tab to the foreground',
|
|
282
289
|
'bbx skill Runtime budget presets and method groups',
|
|
290
|
+
'bbx protocol describe [method|group] Describe method params or list registry groups locally',
|
|
283
291
|
'bbx mcp serve Start Browser Bridge as an MCP stdio server',
|
|
284
292
|
],
|
|
285
293
|
},
|
|
286
294
|
{
|
|
287
295
|
title: 'Generic RPC',
|
|
288
296
|
lines: [
|
|
289
|
-
'bbx call [--tab <tabId>] <method> [paramsJson|-]
|
|
297
|
+
'bbx call [--tab <tabId>] [--preset quick|normal|deep] <method> [paramsJson|-] Call any bridge method (- reads JSON from stdin)',
|
|
290
298
|
'bbx <method> [--tab <tabId>] [paramsJson|-] Direct alias for exact bridge methods such as page.get_state',
|
|
291
|
-
"bbx batch '[{method,params,tabId?},...]'
|
|
299
|
+
"bbx batch [--preset quick|normal|deep] '[{method,params,tabId?},...]' Up to 20 parallel read-only calls",
|
|
300
|
+
'--preset fills budget gaps (maxNodes, maxDepth, textBudget, limit) from the shared quick|normal|deep presets; explicit params always win.',
|
|
292
301
|
'Every bridge command accepts --remote <name> (or BBX_REMOTE env) to target a remote destination from `bbx remote list`.',
|
|
293
302
|
'Advanced bridge params stay available through `bbx call`, even when shortcuts expose only the common case.',
|
|
294
303
|
'For open-ended investigation, start with `bbx batch` on `page.get_state`, `dom.query`, and `page.get_text` before any screenshot or CDP call.',
|
|
@@ -376,6 +385,7 @@ export const CLI_HELP_SECTIONS = Object.freeze([
|
|
|
376
385
|
title: 'Capture',
|
|
377
386
|
lines: [
|
|
378
387
|
'bbx screenshot [--tab <tabId>] [--format png|jpeg|webp] [--quality 0-100] <ref|selector> [path]',
|
|
388
|
+
'bbx har [--tab <tabId>] [--limit 1-200] [--url-pattern <pattern>] [--delivery inline|artifact|auto] [outPath]',
|
|
379
389
|
],
|
|
380
390
|
},
|
|
381
391
|
]);
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { getBridgeDir, getBridgeTransport } from '../../native-host/src/config.js';
|
|
7
|
+
import { atomicWriteFile } from './atomic-write.js';
|
|
8
|
+
|
|
9
|
+
/** @typedef {import('./types.js').AutoUpdatePolicy} AutoUpdatePolicy */
|
|
10
|
+
/** @typedef {import('./types.js').BridgeClientOptions} BridgeClientOptions */
|
|
11
|
+
/** @typedef {import('./types.js').BrowserBridgeConfig} BrowserBridgeConfig */
|
|
12
|
+
|
|
13
|
+
export const AUTO_UPDATE_ENV = 'BBX_AUTO_UPDATE';
|
|
14
|
+
export const CONFIG_FILENAME = 'config.json';
|
|
15
|
+
const AUTO_UPDATE_POLICIES = new Set(['off', 'compatible']);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {NodeJS.ProcessEnv} [env=process.env]
|
|
19
|
+
* @returns {string}
|
|
20
|
+
*/
|
|
21
|
+
export function getBridgeConfigPath(env = process.env) {
|
|
22
|
+
return path.join(getBridgeDir(env), CONFIG_FILENAME);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {unknown} value
|
|
27
|
+
* @returns {AutoUpdatePolicy}
|
|
28
|
+
*/
|
|
29
|
+
export function parseAutoUpdatePolicy(value) {
|
|
30
|
+
if (typeof value !== 'string' || !AUTO_UPDATE_POLICIES.has(value)) {
|
|
31
|
+
throw new Error('Auto-update policy must be "off" or "compatible".');
|
|
32
|
+
}
|
|
33
|
+
return /** @type {AutoUpdatePolicy} */ (value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {NodeJS.ProcessEnv} [env=process.env]
|
|
38
|
+
* @returns {Promise<BrowserBridgeConfig>}
|
|
39
|
+
*/
|
|
40
|
+
export async function readBridgeConfig(env = process.env) {
|
|
41
|
+
const configPath = getBridgeConfigPath(env);
|
|
42
|
+
let raw;
|
|
43
|
+
try {
|
|
44
|
+
raw = await fs.promises.readFile(configPath, 'utf8');
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (isMissingFileError(error)) {
|
|
47
|
+
return { autoUpdate: 'off' };
|
|
48
|
+
}
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(raw);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`Cannot read ${configPath}: ${error instanceof Error ? error.message : String(error)}`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
61
|
+
throw new Error(`Cannot read ${configPath}: expected a JSON object.`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const config = /** @type {Record<string, unknown>} */ (parsed);
|
|
65
|
+
return {
|
|
66
|
+
...config,
|
|
67
|
+
autoUpdate: config.autoUpdate === undefined ? 'off' : parseAutoUpdatePolicy(config.autoUpdate),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {AutoUpdatePolicy} policy
|
|
73
|
+
* @param {NodeJS.ProcessEnv} [env=process.env]
|
|
74
|
+
* @returns {Promise<string>}
|
|
75
|
+
*/
|
|
76
|
+
export async function setAutoUpdatePolicy(policy, env = process.env) {
|
|
77
|
+
const normalized = parseAutoUpdatePolicy(policy);
|
|
78
|
+
const configPath = getBridgeConfigPath(env);
|
|
79
|
+
const config = await readBridgeConfig(env);
|
|
80
|
+
await atomicWriteFile(
|
|
81
|
+
configPath,
|
|
82
|
+
`${JSON.stringify({ ...config, autoUpdate: normalized }, null, 2)}\n`,
|
|
83
|
+
{ encoding: 'utf8', mode: 0o600 }
|
|
84
|
+
);
|
|
85
|
+
return configPath;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Environment configuration overrides the persisted preference for unattended
|
|
90
|
+
* and containerized clients.
|
|
91
|
+
*
|
|
92
|
+
* @param {NodeJS.ProcessEnv} [env=process.env]
|
|
93
|
+
* @returns {Promise<AutoUpdatePolicy>}
|
|
94
|
+
*/
|
|
95
|
+
export async function getAutoUpdatePolicy(env = process.env) {
|
|
96
|
+
if (env[AUTO_UPDATE_ENV] !== undefined) {
|
|
97
|
+
return parseAutoUpdatePolicy(env[AUTO_UPDATE_ENV]);
|
|
98
|
+
}
|
|
99
|
+
return (await readBridgeConfig(env)).autoUpdate;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {BridgeClientOptions} [options={}]
|
|
104
|
+
* @param {NodeJS.ProcessEnv} [env=process.env]
|
|
105
|
+
* @param {string[]} [argv=process.argv]
|
|
106
|
+
* @returns {Promise<BridgeClientOptions>}
|
|
107
|
+
*/
|
|
108
|
+
export async function applyConfiguredAutoUpdate(
|
|
109
|
+
options = {},
|
|
110
|
+
env = process.env,
|
|
111
|
+
argv = process.argv
|
|
112
|
+
) {
|
|
113
|
+
if (options.updateNpmOnCompatibleVersion !== undefined) {
|
|
114
|
+
return options;
|
|
115
|
+
}
|
|
116
|
+
if (env[AUTO_UPDATE_ENV] === undefined && !isBrowserBridgeProcess(argv)) {
|
|
117
|
+
return options;
|
|
118
|
+
}
|
|
119
|
+
if (env.npm_command === 'exec' || (await getAutoUpdatePolicy(env)) !== 'compatible') {
|
|
120
|
+
return options;
|
|
121
|
+
}
|
|
122
|
+
const transport = options.socketPath ? null : (options.transport ?? getBridgeTransport(env));
|
|
123
|
+
if (transport?.type === 'tcp' && !isLoopbackHost(transport.host)) {
|
|
124
|
+
return options;
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
...options,
|
|
128
|
+
checkProtocolOnConnect: true,
|
|
129
|
+
updateNpmOnCompatibleVersion: true,
|
|
130
|
+
exitProcessOnNpmUpdate: isMcpProcess(argv),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {string} host
|
|
136
|
+
* @returns {boolean}
|
|
137
|
+
*/
|
|
138
|
+
function isLoopbackHost(host) {
|
|
139
|
+
const normalized = host
|
|
140
|
+
.trim()
|
|
141
|
+
.toLowerCase()
|
|
142
|
+
.replace(/^\[|\]$/gu, '');
|
|
143
|
+
if (normalized === 'localhost' || normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') {
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/u.exec(normalized);
|
|
147
|
+
return Boolean(
|
|
148
|
+
match && match.slice(1).every((part) => Number(part) <= 255) && match[1] === '127'
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @param {string[]} [argv=process.argv]
|
|
154
|
+
* @returns {boolean}
|
|
155
|
+
*/
|
|
156
|
+
export function isMcpProcess(argv = process.argv) {
|
|
157
|
+
const entry = (argv[1] || '').replaceAll('\\', '/');
|
|
158
|
+
const entryName = entry.slice(entry.lastIndexOf('/') + 1).toLowerCase();
|
|
159
|
+
return (
|
|
160
|
+
(argv[2] === 'mcp' && argv[3] === 'serve') ||
|
|
161
|
+
(entry.includes('/mcp-server/') && entry.endsWith('/bin.js')) ||
|
|
162
|
+
entryName === 'bbx-mcp' ||
|
|
163
|
+
entryName === 'bbx-mcp.cmd'
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Persisted CLI preferences apply to the shipped executables, not arbitrary
|
|
169
|
+
* programmatic BridgeClient consumers that happen to share the same user home.
|
|
170
|
+
*
|
|
171
|
+
* @param {string[]} [argv=process.argv]
|
|
172
|
+
* @returns {boolean}
|
|
173
|
+
*/
|
|
174
|
+
export function isBrowserBridgeProcess(argv = process.argv) {
|
|
175
|
+
if (isMcpProcess(argv)) return true;
|
|
176
|
+
const entry = (argv[1] || '').replaceAll('\\', '/');
|
|
177
|
+
const entryName = entry.slice(entry.lastIndexOf('/') + 1).toLowerCase();
|
|
178
|
+
return (
|
|
179
|
+
(entry.includes('/agent-client/') && entry.endsWith('/cli.js')) ||
|
|
180
|
+
entryName === 'bbx' ||
|
|
181
|
+
entryName === 'bbx.cmd'
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* @param {unknown} error
|
|
187
|
+
* @returns {boolean}
|
|
188
|
+
*/
|
|
189
|
+
function isMissingFileError(error) {
|
|
190
|
+
return Boolean(
|
|
191
|
+
error &&
|
|
192
|
+
typeof error === 'object' &&
|
|
193
|
+
/** @type {{ code?: unknown }} */ (error).code === 'ENOENT'
|
|
194
|
+
);
|
|
195
|
+
}
|
|
@@ -91,23 +91,23 @@ export function getMcpConfigShape(clientName) {
|
|
|
91
91
|
* env: Record<string, string>
|
|
92
92
|
* } | {
|
|
93
93
|
* type: 'local',
|
|
94
|
-
* command: string[]
|
|
94
|
+
* command: string[],
|
|
95
|
+
* environment?: Record<string, string>
|
|
95
96
|
* }}
|
|
96
97
|
*/
|
|
97
98
|
function createBaseServerConfig(clientName) {
|
|
98
|
-
const serverConfig = {
|
|
99
|
-
command: MCP_COMMAND,
|
|
100
|
-
args: [...MCP_ARGS],
|
|
101
|
-
env: {},
|
|
102
|
-
};
|
|
103
|
-
|
|
104
99
|
if (clientName === 'opencode') {
|
|
105
100
|
return {
|
|
106
101
|
type: 'local',
|
|
107
102
|
command: [MCP_COMMAND, ...MCP_ARGS],
|
|
108
103
|
};
|
|
109
104
|
}
|
|
110
|
-
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
command: MCP_COMMAND,
|
|
108
|
+
args: [...MCP_ARGS],
|
|
109
|
+
env: {},
|
|
110
|
+
};
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
/** @type {Record<McpClientName, { key: string, includeType: boolean, keepEmptyBlock?: boolean }>} */
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import net from 'node:net';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const PACKAGE_NAME = '@browserbridge/bbx';
|
|
11
|
+
const PACKAGE_ROOT = path.dirname(fileURLToPath(new URL('../../../package.json', import.meta.url)));
|
|
12
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
|
|
13
|
+
const DEFAULT_LOCK_TIMEOUT_MS = 120_000;
|
|
14
|
+
const UPDATE_LOCK_PORT_START = 49_152;
|
|
15
|
+
const UPDATE_LOCK_PORT_COUNT = 16_384;
|
|
16
|
+
const UPDATE_LOCK_PORT_ATTEMPTS = 32;
|
|
17
|
+
|
|
18
|
+
/** @typedef {import('./types.js').NpmUpdateResult} NpmUpdateResult */
|
|
19
|
+
|
|
20
|
+
export class NpmPackageUpdatedError extends Error {
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} version
|
|
23
|
+
*/
|
|
24
|
+
constructor(version) {
|
|
25
|
+
super(`Browser Bridge updated to ${version}; restarting to load the new version.`);
|
|
26
|
+
this.name = 'NpmPackageUpdatedError';
|
|
27
|
+
this.code = 'BBX_NPM_UPDATED';
|
|
28
|
+
this.version = version;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} value
|
|
34
|
+
* @returns {[number, number, number] | null}
|
|
35
|
+
*/
|
|
36
|
+
export function parseStableVersion(value) {
|
|
37
|
+
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u.exec(value);
|
|
38
|
+
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {string} left
|
|
43
|
+
* @param {string} right
|
|
44
|
+
* @returns {number}
|
|
45
|
+
*/
|
|
46
|
+
export function comparePackageVersions(left, right) {
|
|
47
|
+
const leftParts = parseStableVersion(left);
|
|
48
|
+
const rightParts = parseStableVersion(right);
|
|
49
|
+
if (!leftParts || !rightParts) {
|
|
50
|
+
throw new Error(`Cannot compare invalid stable versions "${left}" and "${right}".`);
|
|
51
|
+
}
|
|
52
|
+
for (let index = 0; index < leftParts.length; index += 1) {
|
|
53
|
+
const delta = leftParts[index] - rightParts[index];
|
|
54
|
+
if (delta !== 0) return delta > 0 ? 1 : -1;
|
|
55
|
+
}
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {string} version
|
|
61
|
+
* @returns {string | null}
|
|
62
|
+
*/
|
|
63
|
+
export function derivePackageProtocolVersion(version) {
|
|
64
|
+
const parsed = parseStableVersion(version);
|
|
65
|
+
return parsed ? `${parsed[0]}.${parsed[1]}` : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {unknown[]} versions
|
|
70
|
+
* @param {readonly string[]} supportedProtocols
|
|
71
|
+
* @param {string} currentVersion
|
|
72
|
+
* @returns {string | null}
|
|
73
|
+
*/
|
|
74
|
+
export function selectCompatibleNpmVersion(versions, supportedProtocols, currentVersion) {
|
|
75
|
+
if (!parseStableVersion(currentVersion)) return null;
|
|
76
|
+
const supported = new Set(supportedProtocols.filter((version) => /^\d+\.\d+$/u.test(version)));
|
|
77
|
+
let selected = null;
|
|
78
|
+
for (const value of versions) {
|
|
79
|
+
if (typeof value !== 'string' || !parseStableVersion(value)) continue;
|
|
80
|
+
const protocolVersion = derivePackageProtocolVersion(value);
|
|
81
|
+
if (!protocolVersion || !supported.has(protocolVersion)) continue;
|
|
82
|
+
if (comparePackageVersions(value, currentVersion) <= 0) continue;
|
|
83
|
+
if (selected === null || comparePackageVersions(value, selected) > 0) selected = value;
|
|
84
|
+
}
|
|
85
|
+
return selected;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {string} packageRoot
|
|
90
|
+
* @returns {Promise<string | null>}
|
|
91
|
+
*/
|
|
92
|
+
async function readPackageVersion(packageRoot) {
|
|
93
|
+
try {
|
|
94
|
+
const parsed = JSON.parse(
|
|
95
|
+
await fs.promises.readFile(path.join(packageRoot, 'package.json'), 'utf8')
|
|
96
|
+
);
|
|
97
|
+
return parsed && typeof parsed.version === 'string' ? parsed.version : null;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {string[]} args
|
|
105
|
+
* @param {{ env?: NodeJS.ProcessEnv, timeoutMs?: number }} [options={}]
|
|
106
|
+
* @returns {Promise<string>}
|
|
107
|
+
*/
|
|
108
|
+
export async function runNpmCommand(args, options = {}) {
|
|
109
|
+
const env = options.env ?? process.env;
|
|
110
|
+
const npmExecPath = env.npm_execpath;
|
|
111
|
+
const useNode = Boolean(npmExecPath && /\.(?:c?js|mjs)$/u.test(npmExecPath));
|
|
112
|
+
const command = useNode
|
|
113
|
+
? process.execPath
|
|
114
|
+
: npmExecPath || (process.platform === 'win32' ? 'npm.cmd' : 'npm');
|
|
115
|
+
const commandArgs = useNode && npmExecPath ? [npmExecPath, ...args] : args;
|
|
116
|
+
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
execFile(
|
|
119
|
+
command,
|
|
120
|
+
commandArgs,
|
|
121
|
+
{
|
|
122
|
+
env,
|
|
123
|
+
encoding: 'utf8',
|
|
124
|
+
timeout: options.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS,
|
|
125
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
126
|
+
windowsHide: true,
|
|
127
|
+
},
|
|
128
|
+
(error, stdout, stderr) => {
|
|
129
|
+
if (!error) {
|
|
130
|
+
resolve(stdout);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const detail = stderr.trim();
|
|
134
|
+
reject(
|
|
135
|
+
new Error(
|
|
136
|
+
`npm ${args.join(' ')} failed${detail ? `: ${detail.slice(0, 1000)}` : `: ${error.message}`}`
|
|
137
|
+
)
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Bind an exclusive loopback port derived from the global npm package path.
|
|
146
|
+
* The OS releases the lock if the updater crashes, avoiding persistent stale
|
|
147
|
+
* lock files while preserving cross-process serialization.
|
|
148
|
+
*
|
|
149
|
+
* @param {string} lockKey
|
|
150
|
+
* @param {number} [offset=0]
|
|
151
|
+
* @returns {number}
|
|
152
|
+
*/
|
|
153
|
+
export function getNpmUpdateLockPort(lockKey, offset = 0) {
|
|
154
|
+
const digest = createHash('sha256').update(lockKey).digest();
|
|
155
|
+
return (
|
|
156
|
+
UPDATE_LOCK_PORT_START +
|
|
157
|
+
((digest.readUInt32BE(0) + Math.max(0, Math.trunc(offset))) % UPDATE_LOCK_PORT_COUNT)
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* @param {number} port
|
|
163
|
+
* @returns {Promise<string | null>}
|
|
164
|
+
*/
|
|
165
|
+
async function readNpmUpdateLockIdentity(port) {
|
|
166
|
+
return new Promise((resolve) => {
|
|
167
|
+
const socket = net.createConnection({ host: '127.0.0.1', port });
|
|
168
|
+
let settled = false;
|
|
169
|
+
let value = '';
|
|
170
|
+
/** @param {string | null} result */
|
|
171
|
+
const finish = (result) => {
|
|
172
|
+
if (settled) return;
|
|
173
|
+
settled = true;
|
|
174
|
+
socket.destroy();
|
|
175
|
+
resolve(result);
|
|
176
|
+
};
|
|
177
|
+
socket.setEncoding('utf8');
|
|
178
|
+
socket.setTimeout(250, () => finish(null));
|
|
179
|
+
socket.on('data', (chunk) => {
|
|
180
|
+
value += chunk;
|
|
181
|
+
if (value.length > 256 || value.includes('\n')) finish(value.trim());
|
|
182
|
+
});
|
|
183
|
+
socket.on('end', () => finish(value.trim() || null));
|
|
184
|
+
socket.on('error', () => finish(null));
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* @param {string} lockKey
|
|
190
|
+
* @param {number} [timeoutMs=DEFAULT_LOCK_TIMEOUT_MS]
|
|
191
|
+
* @returns {Promise<() => Promise<void>>}
|
|
192
|
+
*/
|
|
193
|
+
export async function acquireNpmUpdateLock(lockKey, timeoutMs = DEFAULT_LOCK_TIMEOUT_MS) {
|
|
194
|
+
const startedAt = Date.now();
|
|
195
|
+
const identity = `bbx-npm-update:${createHash('sha256').update(lockKey).digest('hex')}`;
|
|
196
|
+
while (true) {
|
|
197
|
+
let activeUpdaterFound = false;
|
|
198
|
+
for (let offset = 0; offset < UPDATE_LOCK_PORT_ATTEMPTS; offset += 1) {
|
|
199
|
+
const port = getNpmUpdateLockPort(lockKey, offset);
|
|
200
|
+
const server = net.createServer((socket) => socket.end(`${identity}\n`));
|
|
201
|
+
const acquired = await new Promise((resolve, reject) => {
|
|
202
|
+
server.once('error', (error) => {
|
|
203
|
+
if (/** @type {NodeJS.ErrnoException} */ (error).code === 'EADDRINUSE') {
|
|
204
|
+
resolve(false);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
reject(error);
|
|
208
|
+
});
|
|
209
|
+
server.listen({ host: '127.0.0.1', port, exclusive: true }, () => resolve(true));
|
|
210
|
+
});
|
|
211
|
+
if (acquired) {
|
|
212
|
+
return async () => {
|
|
213
|
+
await new Promise((resolve) => server.close(resolve));
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
if ((await readNpmUpdateLockIdentity(port)) === identity) {
|
|
217
|
+
activeUpdaterFound = true;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (Date.now() - startedAt >= timeoutMs) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
activeUpdaterFound
|
|
224
|
+
? 'Timed out waiting for another Browser Bridge npm update to finish.'
|
|
225
|
+
: 'Could not reserve a local Browser Bridge npm update lock.'
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* @param {{
|
|
234
|
+
* extensionVersion: string,
|
|
235
|
+
* supportedVersions: readonly string[],
|
|
236
|
+
* env?: NodeJS.ProcessEnv,
|
|
237
|
+
* packageRoot?: string,
|
|
238
|
+
* lockPath?: string,
|
|
239
|
+
* runNpmFn?: typeof runNpmCommand,
|
|
240
|
+
* }} options
|
|
241
|
+
* @returns {Promise<NpmUpdateResult>}
|
|
242
|
+
*/
|
|
243
|
+
export async function updateCompatibleNpmPackage(options) {
|
|
244
|
+
const env = options.env ?? process.env;
|
|
245
|
+
const packageRoot = options.packageRoot ?? PACKAGE_ROOT;
|
|
246
|
+
const runNpmFn = options.runNpmFn ?? runNpmCommand;
|
|
247
|
+
const extensionProtocol = derivePackageProtocolVersion(options.extensionVersion);
|
|
248
|
+
if (!extensionProtocol || !options.supportedVersions.includes(extensionProtocol)) {
|
|
249
|
+
return { updated: false, reason: 'invalid_extension_version' };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const globalRoot = (await runNpmFn(['root', '--global'], { env })).trim();
|
|
253
|
+
const expectedRoot = path.join(globalRoot, '@browserbridge', 'bbx');
|
|
254
|
+
const [realPackageRoot, realExpectedRoot, expectedStat] = await Promise.all([
|
|
255
|
+
fs.promises.realpath(packageRoot).catch(() => ''),
|
|
256
|
+
fs.promises.realpath(expectedRoot).catch(() => ''),
|
|
257
|
+
fs.promises.lstat(expectedRoot).catch(() => null),
|
|
258
|
+
]);
|
|
259
|
+
if (!realPackageRoot || realPackageRoot !== realExpectedRoot || expectedStat?.isSymbolicLink()) {
|
|
260
|
+
return { updated: false, reason: 'not_global_install' };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const lockKey = options.lockPath ?? realExpectedRoot;
|
|
264
|
+
const releaseLock = await acquireNpmUpdateLock(lockKey);
|
|
265
|
+
try {
|
|
266
|
+
const currentVersion = await readPackageVersion(packageRoot);
|
|
267
|
+
if (!currentVersion || !parseStableVersion(currentVersion)) {
|
|
268
|
+
return { updated: false, reason: 'invalid_installed_version' };
|
|
269
|
+
}
|
|
270
|
+
if (comparePackageVersions(options.extensionVersion, currentVersion) <= 0) {
|
|
271
|
+
return { updated: false, reason: 'extension_not_newer', previousVersion: currentVersion };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const versionsRaw = await runNpmFn(['view', PACKAGE_NAME, 'versions', '--json'], { env });
|
|
275
|
+
const parsedVersions = JSON.parse(versionsRaw);
|
|
276
|
+
const versions = Array.isArray(parsedVersions) ? parsedVersions : [parsedVersions];
|
|
277
|
+
const targetVersion = selectCompatibleNpmVersion(
|
|
278
|
+
versions,
|
|
279
|
+
options.supportedVersions,
|
|
280
|
+
currentVersion
|
|
281
|
+
);
|
|
282
|
+
if (!targetVersion) {
|
|
283
|
+
return { updated: false, reason: 'no_compatible_update', previousVersion: currentVersion };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
await runNpmFn(
|
|
287
|
+
['install', '--global', '--no-audit', '--no-fund', '--', `${PACKAGE_NAME}@${targetVersion}`],
|
|
288
|
+
{ env }
|
|
289
|
+
);
|
|
290
|
+
const installedVersion = await readPackageVersion(packageRoot);
|
|
291
|
+
if (installedVersion !== targetVersion) {
|
|
292
|
+
throw new Error(
|
|
293
|
+
`npm reported success, but Browser Bridge ${targetVersion} was not installed at ${packageRoot}.`
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
updated: true,
|
|
298
|
+
reason: 'updated',
|
|
299
|
+
previousVersion: currentVersion,
|
|
300
|
+
version: targetVersion,
|
|
301
|
+
};
|
|
302
|
+
} finally {
|
|
303
|
+
await releaseLock();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
@@ -7,6 +7,7 @@ import { createTcpBridgeTransport, getBridgeDir } from '../../native-host/src/co
|
|
|
7
7
|
import { normalizeBridgeAuthToken } from '../../native-host/src/auth-token.js';
|
|
8
8
|
import { atomicWriteFile } from './atomic-write.js';
|
|
9
9
|
import { BridgeClient } from './client.js';
|
|
10
|
+
import { applyConfiguredAutoUpdate } from './config.js';
|
|
10
11
|
|
|
11
12
|
const REMOTES_FILENAME = 'remotes.json';
|
|
12
13
|
const LOCAL_DESTINATION_ID = 'local';
|
|
@@ -287,12 +288,12 @@ function validateAuthToken(token) {
|
|
|
287
288
|
|
|
288
289
|
/**
|
|
289
290
|
* @param {string | null | undefined} destinationId
|
|
290
|
-
* @param {
|
|
291
|
+
* @param {import('./types.js').BridgeClientOptions} [options={}]
|
|
291
292
|
* @returns {Promise<BridgeClient>}
|
|
292
293
|
*/
|
|
293
294
|
export async function createBridgeClientForDestination(destinationId, options = {}) {
|
|
294
295
|
if (!destinationId || destinationId === LOCAL_DESTINATION_ID) {
|
|
295
|
-
return new BridgeClient(options);
|
|
296
|
+
return new BridgeClient(await applyConfiguredAutoUpdate(options));
|
|
296
297
|
}
|
|
297
298
|
const config = await readRemoteConfig();
|
|
298
299
|
const remote = config.remotes.find((entry) => entry.id === destinationId);
|
|
@@ -304,5 +305,6 @@ export async function createBridgeClientForDestination(destinationId, options =
|
|
|
304
305
|
transport: createTcpBridgeTransport(remote.port, remote.host),
|
|
305
306
|
authToken: remote.token,
|
|
306
307
|
restartDaemonOnVersionMismatch: false,
|
|
308
|
+
updateNpmOnCompatibleVersion: false,
|
|
307
309
|
});
|
|
308
310
|
}
|