@browserbridge/bbx 1.7.8 → 1.9.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.
Files changed (44) hide show
  1. package/PRIVACY.md +57 -12
  2. package/README.md +19 -8
  3. package/SECURITY.md +12 -0
  4. package/package.json +15 -6
  5. package/packages/agent-client/src/cli-args.js +59 -0
  6. package/packages/agent-client/src/cli-batch.js +121 -58
  7. package/packages/agent-client/src/cli-output.js +8 -1
  8. package/packages/agent-client/src/cli.js +272 -70
  9. package/packages/agent-client/src/client.js +296 -8
  10. package/packages/agent-client/src/command-registry.js +64 -55
  11. package/packages/agent-client/src/config.js +195 -0
  12. package/packages/agent-client/src/content-extract.js +198 -0
  13. package/packages/agent-client/src/npm-self-update.js +305 -0
  14. package/packages/agent-client/src/remotes.js +4 -2
  15. package/packages/agent-client/src/runtime.js +864 -39
  16. package/packages/agent-client/src/types.ts +169 -6
  17. package/packages/mcp-server/src/guidance.js +1 -1
  18. package/packages/mcp-server/src/handlers-capture.js +69 -6
  19. package/packages/mcp-server/src/handlers-dom.js +61 -4
  20. package/packages/mcp-server/src/handlers-navigation.js +2 -1
  21. package/packages/mcp-server/src/handlers-page.js +211 -54
  22. package/packages/mcp-server/src/handlers-utils.js +101 -10
  23. package/packages/mcp-server/src/handlers.js +14 -7
  24. package/packages/mcp-server/src/server.js +176 -11
  25. package/packages/native-host/src/artifact-store.js +353 -0
  26. package/packages/native-host/src/config.js +9 -1
  27. package/packages/native-host/src/daemon-logger.js +3 -1
  28. package/packages/native-host/src/daemon.js +701 -33
  29. package/packages/native-host/src/native-host.js +28 -3
  30. package/packages/protocol/src/capability-values.js +2 -0
  31. package/packages/protocol/src/defaults.js +31 -0
  32. package/packages/protocol/src/errors.js +102 -6
  33. package/packages/protocol/src/incidental-sanitizer.js +197 -0
  34. package/packages/protocol/src/index.js +2 -0
  35. package/packages/protocol/src/protocol.js +612 -6
  36. package/packages/protocol/src/recovery-telemetry.js +302 -0
  37. package/packages/protocol/src/registry.js +206 -38
  38. package/packages/protocol/src/summary.js +203 -30
  39. package/packages/protocol/src/types.ts +721 -2
  40. package/skills/browser-bridge/SKILL.md +54 -19
  41. package/skills/browser-bridge/references/interaction.md +55 -11
  42. package/skills/browser-bridge/references/patch-workflow.md +2 -0
  43. package/skills/browser-bridge/references/protocol.md +265 -86
  44. package/skills/browser-bridge/references/token-efficiency.md +29 -13
@@ -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
+ }
@@ -0,0 +1,198 @@
1
+ // @ts-check
2
+
3
+ import { Readability } from '@mozilla/readability';
4
+ import { parseHTML } from 'linkedom';
5
+
6
+ /** @typedef {import('../../protocol/src/types.js').ExtractContentResult} ExtractContentResult */
7
+ /** @typedef {import('../../protocol/src/types.js').NormalizedExtractContentParams} ExtractContentParams */
8
+
9
+ const METADATA_LIMITS = Object.freeze({ title: 300, byline: 200, excerpt: 500, siteName: 200 });
10
+ const EXCLUDED_SELECTOR =
11
+ 'script,style,noscript,template,input,textarea,select,option,button,[hidden],[aria-hidden="true"]';
12
+
13
+ /** @param {string | null | undefined} value @param {number} limit @returns {string | undefined} */
14
+ function boundedText(value, limit) {
15
+ const normalized = String(value ?? '')
16
+ .replace(/\s+/g, ' ')
17
+ .trim();
18
+ return normalized ? normalized.slice(0, limit) : undefined;
19
+ }
20
+
21
+ /** @param {ParentNode} root @returns {void} */
22
+ function sanitize(root) {
23
+ for (const element of root.querySelectorAll(EXCLUDED_SELECTOR)) element.remove();
24
+ for (const element of root.querySelectorAll('[style]')) {
25
+ const style = element.getAttribute('style')?.toLowerCase() ?? '';
26
+ if (/display\s*:\s*none|visibility\s*:\s*hidden/.test(style)) element.remove();
27
+ }
28
+ }
29
+
30
+ /** @param {Element} element @returns {number} */
31
+ function meaningfulLength(element) {
32
+ return (element.textContent ?? '').replace(/\s+/g, ' ').trim().length;
33
+ }
34
+
35
+ /** @param {ParentNode} root @returns {Element | null} */
36
+ function largestSemanticRoot(root) {
37
+ let best = null;
38
+ let bestLength = 0;
39
+ const candidates = [];
40
+ if (
41
+ 'matches' in root &&
42
+ typeof (/** @type {{ matches?: unknown }} */ (root).matches) === 'function' &&
43
+ /** @type {Element} */ (root).matches('article,main,[role="main"]')
44
+ ) {
45
+ candidates.push(/** @type {Element} */ (root));
46
+ }
47
+ candidates.push(...root.querySelectorAll('article,main,[role="main"]'));
48
+ for (const candidate of candidates) {
49
+ const length = meaningfulLength(candidate);
50
+ if (length > bestLength) {
51
+ best = candidate;
52
+ bestLength = length;
53
+ }
54
+ }
55
+ return best;
56
+ }
57
+
58
+ /** @param {Node} node @returns {string} */
59
+ function markdownForNode(node) {
60
+ if (node.nodeType === 3) return (node.textContent ?? '').replace(/\s+/g, ' ');
61
+ if (node.nodeType !== 1) return '';
62
+ const element = /** @type {Element} */ (node);
63
+ const tag = element.tagName.toLowerCase();
64
+ const children = () => Array.from(element.childNodes, markdownForNode).join('');
65
+ if (/^h[1-6]$/.test(tag)) return `\n${'#'.repeat(Number(tag[1]))} ${children().trim()}\n\n`;
66
+ if (['p', 'div', 'section', 'article', 'main'].includes(tag)) {
67
+ return `\n${children().trim()}\n\n`;
68
+ }
69
+ if (tag === 'br') return '\n';
70
+ if (tag === 'hr') return '\n---\n';
71
+ if (tag === 'strong' || tag === 'b') return `**${children().trim()}**`;
72
+ if (tag === 'em' || tag === 'i') return `*${children().trim()}*`;
73
+ if (tag === 'code' && element.parentElement?.tagName.toLowerCase() !== 'pre') {
74
+ return `\`${(element.textContent ?? '').replace(/`/g, '\\`')}\``;
75
+ }
76
+ if (tag === 'pre') return `\n\`\`\`\n${element.textContent ?? ''}\n\`\`\`\n\n`;
77
+ if (tag === 'blockquote') {
78
+ return `\n${children()
79
+ .trim()
80
+ .split('\n')
81
+ .map((line) => `> ${line}`)
82
+ .join('\n')}\n\n`;
83
+ }
84
+ if (tag === 'li') {
85
+ const marker =
86
+ element.parentElement?.tagName.toLowerCase() === 'ol'
87
+ ? `${Array.from(element.parentElement.children).indexOf(element) + 1}.`
88
+ : '-';
89
+ return `\n${marker} ${children().trim()}`;
90
+ }
91
+ if (tag === 'ul' || tag === 'ol') return `${children()}\n\n`;
92
+ if (tag === 'a') {
93
+ const label = children().trim();
94
+ const href = element.getAttribute('href')?.trim();
95
+ return href && !/^(?:javascript|data):/i.test(href) ? `[${label}](${href})` : label;
96
+ }
97
+ return children();
98
+ }
99
+
100
+ /** @param {Element} root @param {'text' | 'markdown'} format @returns {string} */
101
+ function formatRoot(root, format) {
102
+ if (format === 'markdown') {
103
+ return markdownForNode(root)
104
+ .replace(/[ \t]+\n/g, '\n')
105
+ .replace(/\n{3,}/g, '\n\n')
106
+ .trim();
107
+ }
108
+ return (root.textContent ?? '')
109
+ .replace(/[ \t]+/g, ' ')
110
+ .replace(/\n{3,}/g, '\n\n')
111
+ .trim();
112
+ }
113
+
114
+ /** @param {string} content @param {number} budget @param {'text' | 'markdown'} format */
115
+ function truncateContent(content, budget, format) {
116
+ if (content.length <= budget) return { content, truncated: false, omitted: 0 };
117
+ const boundary = Math.max(content.lastIndexOf('\n\n', budget), content.lastIndexOf(' ', budget));
118
+ const end = boundary >= Math.floor(budget * 0.7) ? boundary : budget;
119
+ let value = content.slice(0, end).trimEnd();
120
+ if (format === 'markdown' && (value.match(/```/g)?.length ?? 0) % 2 === 1) {
121
+ value = `${value.slice(0, Math.max(0, budget - 4)).trimEnd()}\n\`\`\``;
122
+ }
123
+ return { content: value, truncated: true, omitted: content.length - value.length };
124
+ }
125
+
126
+ /**
127
+ * Convert one bounded browser HTML snapshot into semantic content in Node.
128
+ * Raw HTML is consumed here and never included in the returned result.
129
+ *
130
+ * @param {string} html
131
+ * @param {ExtractContentParams} params
132
+ * @param {{ title?: string, settlement?: ExtractContentResult['settlement'] }} [context]
133
+ * @returns {ExtractContentResult}
134
+ */
135
+ export function extractContentFromHtml(html, params, context = {}) {
136
+ const escapedTitle = String(context.title ?? '').replace(/[&<>]/g, (character) => {
137
+ if (character === '&') return '&amp;';
138
+ if (character === '<') return '&lt;';
139
+ return '&gt;';
140
+ });
141
+ const { document } = parseHTML(
142
+ `<!doctype html><html><head><title>${escapedTitle}</title></head><body>${html}</body></html>`
143
+ );
144
+ sanitize(document);
145
+ const semanticRoot = largestSemanticRoot(document.body);
146
+ const semanticLength = semanticRoot ? meaningfulLength(semanticRoot) : 0;
147
+ let readability = null;
148
+ try {
149
+ readability = new Readability(/** @type {Document} */ (document.cloneNode(true))).parse();
150
+ } catch {
151
+ readability = null;
152
+ }
153
+
154
+ /** @type {Element} */
155
+ let resultRoot;
156
+ /** @type {import('../../protocol/src/types.js').ExtractContentSource} */
157
+ let source;
158
+ if (readability && (readability.length ?? 0) >= Math.max(200, Math.floor(semanticLength * 0.2))) {
159
+ const template = document.createElement('template');
160
+ template.innerHTML = readability.content ?? '';
161
+ sanitize(template.content);
162
+ const wrapper = document.createElement('article');
163
+ wrapper.append(template.content.cloneNode(true));
164
+ resultRoot = wrapper;
165
+ source = 'readability';
166
+ } else if (semanticRoot && semanticLength > 0) {
167
+ resultRoot = semanticRoot;
168
+ source = 'semantic-root';
169
+ } else {
170
+ resultRoot = document.body;
171
+ source = 'body';
172
+ }
173
+
174
+ const formatted = formatRoot(resultRoot, params.format);
175
+ const bounded = truncateContent(formatted, params.textBudget, params.format);
176
+ const metadata = params.includeMetadata
177
+ ? {
178
+ title: boundedText(readability?.title ?? context.title, METADATA_LIMITS.title),
179
+ byline: boundedText(readability?.byline, METADATA_LIMITS.byline),
180
+ excerpt: boundedText(readability?.excerpt, METADATA_LIMITS.excerpt),
181
+ siteName: boundedText(readability?.siteName, METADATA_LIMITS.siteName),
182
+ }
183
+ : {};
184
+ return {
185
+ format: params.format,
186
+ content: bounded.content,
187
+ ...metadata,
188
+ source,
189
+ root: {
190
+ tag: resultRoot.tagName.toLowerCase(),
191
+ ...(params.selector ? { selector: params.selector } : {}),
192
+ },
193
+ length: formatted.length,
194
+ truncated: bounded.truncated,
195
+ omitted: bounded.omitted,
196
+ ...(context.settlement ? { settlement: context.settlement } : {}),
197
+ };
198
+ }
@@ -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
+ }