@browserbridge/bbx 1.8.0 → 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.
- 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 +35 -0
- package/packages/agent-client/src/cli-batch.js +121 -58
- package/packages/agent-client/src/cli-output.js +8 -1
- package/packages/agent-client/src/cli.js +227 -19
- package/packages/agent-client/src/client.js +156 -3
- package/packages/agent-client/src/command-registry.js +8 -2
- package/packages/agent-client/src/config.js +195 -0
- 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 +97 -5
- package/packages/agent-client/src/types.ts +38 -0
- 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 +180 -60
- package/packages/mcp-server/src/handlers-utils.js +35 -4
- package/packages/mcp-server/src/handlers.js +10 -3
- package/packages/mcp-server/src/server.js +75 -6
- 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 +543 -27
- package/packages/native-host/src/native-host.js +25 -2
- 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/incidental-sanitizer.js +197 -0
- package/packages/protocol/src/index.js +2 -0
- package/packages/protocol/src/protocol.js +316 -6
- package/packages/protocol/src/recovery-telemetry.js +302 -0
- package/packages/protocol/src/registry.js +143 -6
- package/packages/protocol/src/summary.js +116 -25
- package/packages/protocol/src/types.ts +489 -4
- package/skills/browser-bridge/SKILL.md +22 -8
- package/skills/browser-bridge/references/protocol.md +117 -13
- package/skills/browser-bridge/references/token-efficiency.md +17 -7
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
7
9
|
import { fileURLToPath } from 'node:url';
|
|
8
10
|
|
|
9
11
|
import { applyWindowsTcpTransportDefaults } from '../../native-host/src/config.js';
|
|
@@ -16,6 +18,7 @@ import {
|
|
|
16
18
|
import { startBridgeMcpServer } from '../../mcp-server/src/server.js';
|
|
17
19
|
import {
|
|
18
20
|
extractRemoteFlag,
|
|
21
|
+
extractHarFlags,
|
|
19
22
|
extractScreenshotFlags,
|
|
20
23
|
extractTabFlag,
|
|
21
24
|
parseCallCommand,
|
|
@@ -32,12 +35,16 @@ import {
|
|
|
32
35
|
uninstallBrowserBridge,
|
|
33
36
|
} from './cli-setup-commands.js';
|
|
34
37
|
import { SHORTCUT_COMMANDS } from './command-registry.js';
|
|
38
|
+
import { getAutoUpdatePolicy, parseAutoUpdatePolicy, setAutoUpdatePolicy } from './config.js';
|
|
35
39
|
import { formatMcpConfig, isMcpClientName, MCP_CLIENT_NAMES } from './mcp-config.js';
|
|
36
40
|
import { getDoctorReport, requestBridge, resolveRef } from './runtime.js';
|
|
37
41
|
import { createBridgeClientForDestination } from './remotes.js';
|
|
42
|
+
import { atomicWriteFile } from './atomic-write.js';
|
|
38
43
|
|
|
39
44
|
/** @typedef {import('./types.js').BridgeMethod} BridgeMethod */
|
|
40
45
|
/** @typedef {import('./types.js').ScreenshotResult} ScreenshotResult */
|
|
46
|
+
/** @typedef {import('../../protocol/src/types.js').ArtifactDescriptor} ArtifactDescriptor */
|
|
47
|
+
/** @typedef {import('../../protocol/src/types.js').HarExportResult} HarExportResult */
|
|
41
48
|
|
|
42
49
|
const REQUEST_SOURCE = 'cli';
|
|
43
50
|
const TEST_TIMEOUT_ENV = 'BBX_CLIENT_REQUEST_TIMEOUT_MS';
|
|
@@ -64,6 +71,7 @@ const LOCAL_ONLY_COMMANDS = new Set([
|
|
|
64
71
|
'mcp',
|
|
65
72
|
'proxy',
|
|
66
73
|
'remote',
|
|
74
|
+
'config',
|
|
67
75
|
'doctor',
|
|
68
76
|
'restart',
|
|
69
77
|
]);
|
|
@@ -167,6 +175,11 @@ if (command === 'remote') {
|
|
|
167
175
|
process.exit();
|
|
168
176
|
}
|
|
169
177
|
|
|
178
|
+
if (command === 'config') {
|
|
179
|
+
await runLocalCommand(() => handleConfigCommand(rest));
|
|
180
|
+
process.exit();
|
|
181
|
+
}
|
|
182
|
+
|
|
170
183
|
const clientTimeoutMs = getClientTimeoutOverride();
|
|
171
184
|
applyWindowsTcpTransportDefaults();
|
|
172
185
|
const client = await createCliClient();
|
|
@@ -189,6 +202,26 @@ async function runLocalCommand(run) {
|
|
|
189
202
|
}
|
|
190
203
|
}
|
|
191
204
|
|
|
205
|
+
/**
|
|
206
|
+
* @param {string[]} args
|
|
207
|
+
* @returns {Promise<void>}
|
|
208
|
+
*/
|
|
209
|
+
async function handleConfigCommand(args) {
|
|
210
|
+
const [action, key, value] = args;
|
|
211
|
+
if (action === 'get' && key === 'auto-update' && value === undefined) {
|
|
212
|
+
process.stdout.write(`${await getAutoUpdatePolicy()}\n`);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (action === 'set' && key === 'auto-update' && value !== undefined && args.length === 3) {
|
|
216
|
+
const policy = parseAutoUpdatePolicy(value);
|
|
217
|
+
const configPath = await setAutoUpdatePolicy(policy);
|
|
218
|
+
process.stdout.write(`Browser Bridge auto-update policy set to "${policy}".\n`);
|
|
219
|
+
process.stdout.write(`Config: ${configPath}\n`);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
throw new Error('Usage: bbx config <get auto-update|set auto-update off|compatible>');
|
|
223
|
+
}
|
|
224
|
+
|
|
192
225
|
/**
|
|
193
226
|
* Create the bridge client for this invocation, honoring the --remote flag
|
|
194
227
|
* and BBX_REMOTE env var. Exits with a friendly error when the destination
|
|
@@ -213,6 +246,7 @@ async function createCliClient() {
|
|
|
213
246
|
}
|
|
214
247
|
|
|
215
248
|
async function main() {
|
|
249
|
+
let relaunchAfterUpdate = false;
|
|
216
250
|
try {
|
|
217
251
|
if (command === 'status') {
|
|
218
252
|
const healthResponse = await requestBridge(
|
|
@@ -338,7 +372,7 @@ async function main() {
|
|
|
338
372
|
tabId,
|
|
339
373
|
source: REQUEST_SOURCE,
|
|
340
374
|
});
|
|
341
|
-
printCallResponse(response);
|
|
375
|
+
printCallResponse(response, method);
|
|
342
376
|
return;
|
|
343
377
|
}
|
|
344
378
|
|
|
@@ -357,7 +391,7 @@ async function main() {
|
|
|
357
391
|
tabId,
|
|
358
392
|
source: REQUEST_SOURCE,
|
|
359
393
|
});
|
|
360
|
-
printCallResponse(response);
|
|
394
|
+
printCallResponse(response, method);
|
|
361
395
|
return;
|
|
362
396
|
}
|
|
363
397
|
|
|
@@ -431,6 +465,7 @@ async function main() {
|
|
|
431
465
|
elementRef,
|
|
432
466
|
format: screenshotOptions.format,
|
|
433
467
|
quality: screenshotOptions.quality,
|
|
468
|
+
delivery: 'artifact',
|
|
434
469
|
},
|
|
435
470
|
{ tabId: parsed.tabId, source: REQUEST_SOURCE }
|
|
436
471
|
);
|
|
@@ -453,8 +488,11 @@ async function main() {
|
|
|
453
488
|
) {
|
|
454
489
|
throw new Error(`Screenshot path extension must match ${screenshotOptions.format} format.`);
|
|
455
490
|
}
|
|
456
|
-
const
|
|
457
|
-
|
|
491
|
+
const imageBytes =
|
|
492
|
+
screenshotResult.delivery === 'artifact'
|
|
493
|
+
? await downloadArtifact(client, screenshotResult.artifact, 'screenshot')
|
|
494
|
+
: decodeScreenshotDataUrl(screenshotResult.image);
|
|
495
|
+
await atomicWriteFile(filePath, imageBytes);
|
|
458
496
|
printJson({
|
|
459
497
|
ok: true,
|
|
460
498
|
summary: `Screenshot saved to ${filePath}.`,
|
|
@@ -469,6 +507,50 @@ async function main() {
|
|
|
469
507
|
return;
|
|
470
508
|
}
|
|
471
509
|
|
|
510
|
+
if (command === 'har') {
|
|
511
|
+
const parsed = extractTabFlag(rest);
|
|
512
|
+
const harOptions = extractHarFlags(parsed.rest);
|
|
513
|
+
const [outputPath = 'browser-bridge.har', ...extra] = harOptions.rest;
|
|
514
|
+
if (extra.length > 0) {
|
|
515
|
+
throw new Error(
|
|
516
|
+
'Usage: har [--tab <tabId>] [--limit 1-200] [--url-pattern <pattern>] [--delivery inline|artifact|auto] [outPath]'
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
if (path.extname(outputPath) !== '.har') {
|
|
520
|
+
throw new Error('HAR output path must use the .har extension.');
|
|
521
|
+
}
|
|
522
|
+
const response = await requestBridge(
|
|
523
|
+
client,
|
|
524
|
+
'network.export_har',
|
|
525
|
+
{
|
|
526
|
+
limit: harOptions.limit,
|
|
527
|
+
urlPattern: harOptions.urlPattern,
|
|
528
|
+
delivery: harOptions.delivery,
|
|
529
|
+
},
|
|
530
|
+
{ tabId: parsed.tabId, source: REQUEST_SOURCE }
|
|
531
|
+
);
|
|
532
|
+
if (!response.ok) {
|
|
533
|
+
await printSummary(response);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
const result = /** @type {HarExportResult} */ (response.result);
|
|
537
|
+
let harBytes;
|
|
538
|
+
if (result.delivery === 'artifact') {
|
|
539
|
+
harBytes = await downloadArtifact(client, result.artifact, 'har');
|
|
540
|
+
assertValidHarBytes(harBytes);
|
|
541
|
+
} else {
|
|
542
|
+
assertValidHarDocument(result.har);
|
|
543
|
+
harBytes = Buffer.from(`${JSON.stringify(result.har, null, 2)}\n`, 'utf8');
|
|
544
|
+
}
|
|
545
|
+
await atomicWriteFile(outputPath, harBytes);
|
|
546
|
+
printJson({
|
|
547
|
+
ok: true,
|
|
548
|
+
summary: `HAR saved to ${outputPath}.`,
|
|
549
|
+
evidence: { savedTo: outputPath, delivery: result.delivery },
|
|
550
|
+
});
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
|
|
472
554
|
if (command === 'intercept') {
|
|
473
555
|
const { tabId, rest: iArgs } = extractTabFlag(rest);
|
|
474
556
|
const sub = iArgs[0];
|
|
@@ -559,25 +641,151 @@ async function main() {
|
|
|
559
641
|
} catch (error) {
|
|
560
642
|
const message = error instanceof Error ? error.message : String(error);
|
|
561
643
|
const raw = getErrorCode(error);
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
644
|
+
if (raw === 'BBX_NPM_UPDATED') {
|
|
645
|
+
relaunchAfterUpdate = true;
|
|
646
|
+
} else {
|
|
647
|
+
let code = 'ERROR';
|
|
648
|
+
if (raw === 'ENOENT' || raw === 'ECONNREFUSED' || raw === 'EINVAL') {
|
|
649
|
+
code = 'DAEMON_OFFLINE';
|
|
650
|
+
} else if (raw === 'BRIDGE_TIMEOUT') {
|
|
651
|
+
code = 'BRIDGE_TIMEOUT';
|
|
652
|
+
} else if (/socket closed/i.test(message)) {
|
|
653
|
+
code = 'CONNECTION_LOST';
|
|
654
|
+
} else if (raw) {
|
|
655
|
+
code = String(raw);
|
|
656
|
+
}
|
|
657
|
+
printJson({
|
|
658
|
+
ok: false,
|
|
659
|
+
summary: `${code}: ${message}`,
|
|
660
|
+
evidence: null,
|
|
661
|
+
});
|
|
662
|
+
process.exitCode = 1;
|
|
571
663
|
}
|
|
572
|
-
printJson({
|
|
573
|
-
ok: false,
|
|
574
|
-
summary: `${code}: ${message}`,
|
|
575
|
-
evidence: null,
|
|
576
|
-
});
|
|
577
|
-
process.exitCode = 1;
|
|
578
664
|
} finally {
|
|
579
665
|
await client.close();
|
|
580
666
|
}
|
|
667
|
+
if (relaunchAfterUpdate) {
|
|
668
|
+
relaunchCli();
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** @param {string} value */
|
|
673
|
+
function decodeScreenshotDataUrl(value) {
|
|
674
|
+
const match = /^data:image\/(?:png|jpeg|webp);base64,([A-Za-z0-9+/]*={0,2})$/u.exec(value);
|
|
675
|
+
if (!match) throw new Error('Screenshot response contains invalid base64 image data.');
|
|
676
|
+
const bytes = Buffer.from(match[1], 'base64');
|
|
677
|
+
if (bytes.toString('base64') !== match[1]) {
|
|
678
|
+
throw new Error('Screenshot response contains non-canonical base64 image data.');
|
|
679
|
+
}
|
|
680
|
+
return bytes;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* @param {import('./client.js').BridgeClient} client
|
|
685
|
+
* @param {ArtifactDescriptor} artifact
|
|
686
|
+
* @param {'screenshot' | 'har'} [expectedKind]
|
|
687
|
+
*/
|
|
688
|
+
async function downloadArtifact(client, artifact, expectedKind) {
|
|
689
|
+
const chunks = [];
|
|
690
|
+
let offset = 0;
|
|
691
|
+
try {
|
|
692
|
+
if (expectedKind && artifact.kind !== expectedKind) {
|
|
693
|
+
throw new Error(`Artifact kind does not match the expected ${expectedKind} result.`);
|
|
694
|
+
}
|
|
695
|
+
if (expectedKind === 'har' && artifact.mimeType !== 'application/json') {
|
|
696
|
+
throw new Error('HAR artifact MIME type is invalid.');
|
|
697
|
+
}
|
|
698
|
+
while (offset < artifact.byteLength) {
|
|
699
|
+
const response = await requestBridge(
|
|
700
|
+
client,
|
|
701
|
+
'artifact.read',
|
|
702
|
+
{ artifactId: artifact.artifactId, offset },
|
|
703
|
+
{ source: REQUEST_SOURCE }
|
|
704
|
+
);
|
|
705
|
+
if (!response.ok) throw new Error(response.error.message);
|
|
706
|
+
const result = /** @type {Record<string, unknown>} */ (response.result);
|
|
707
|
+
if (result.artifactId !== artifact.artifactId || result.offset !== offset) {
|
|
708
|
+
throw new Error('Artifact chunk metadata is inconsistent.');
|
|
709
|
+
}
|
|
710
|
+
const data = String(result.data ?? '');
|
|
711
|
+
const bytes = Buffer.from(data, 'base64');
|
|
712
|
+
if (bytes.toString('base64') !== data || bytes.length !== result.byteLength) {
|
|
713
|
+
throw new Error('Artifact chunk contains invalid base64 data.');
|
|
714
|
+
}
|
|
715
|
+
chunks.push(bytes);
|
|
716
|
+
if (result.nextOffset === null) break;
|
|
717
|
+
if (typeof result.nextOffset !== 'number' || result.nextOffset <= offset) {
|
|
718
|
+
throw new Error('Artifact chunk did not advance the read offset.');
|
|
719
|
+
}
|
|
720
|
+
offset = result.nextOffset;
|
|
721
|
+
}
|
|
722
|
+
const output = Buffer.concat(chunks);
|
|
723
|
+
const sha256 = createHash('sha256').update(output).digest('hex');
|
|
724
|
+
if (output.length !== artifact.byteLength || sha256 !== artifact.sha256) {
|
|
725
|
+
throw new Error('Artifact length or checksum does not match the capture metadata.');
|
|
726
|
+
}
|
|
727
|
+
return output;
|
|
728
|
+
} finally {
|
|
729
|
+
await requestBridge(
|
|
730
|
+
client,
|
|
731
|
+
'artifact.delete',
|
|
732
|
+
{ artifactId: artifact.artifactId },
|
|
733
|
+
{ source: REQUEST_SOURCE }
|
|
734
|
+
).catch(() => {});
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** @param {Buffer} bytes */
|
|
739
|
+
function assertValidHarBytes(bytes) {
|
|
740
|
+
const text = bytes.toString('utf8');
|
|
741
|
+
if (!Buffer.from(text, 'utf8').equals(bytes)) {
|
|
742
|
+
throw new Error('HAR artifact is not valid UTF-8 JSON.');
|
|
743
|
+
}
|
|
744
|
+
let document;
|
|
745
|
+
try {
|
|
746
|
+
document = JSON.parse(text);
|
|
747
|
+
} catch {
|
|
748
|
+
throw new Error('HAR artifact is not valid JSON.');
|
|
749
|
+
}
|
|
750
|
+
assertValidHarDocument(document);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** @param {unknown} document */
|
|
754
|
+
function assertValidHarDocument(document) {
|
|
755
|
+
if (!document || typeof document !== 'object' || Array.isArray(document)) {
|
|
756
|
+
throw new Error('HAR response does not contain an inline HAR object.');
|
|
757
|
+
}
|
|
758
|
+
const log = Reflect.get(document, 'log');
|
|
759
|
+
if (
|
|
760
|
+
!log ||
|
|
761
|
+
typeof log !== 'object' ||
|
|
762
|
+
Array.isArray(log) ||
|
|
763
|
+
Reflect.get(log, 'version') !== '1.2' ||
|
|
764
|
+
!Array.isArray(Reflect.get(log, 'entries'))
|
|
765
|
+
) {
|
|
766
|
+
throw new Error('HAR response does not contain a valid HAR 1.2 document.');
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Replace this invocation with a fresh Node process after npm has replaced the
|
|
772
|
+
* package on disk. Child stdio is inherited so command output remains stable.
|
|
773
|
+
*
|
|
774
|
+
* @returns {void}
|
|
775
|
+
*/
|
|
776
|
+
function relaunchCli() {
|
|
777
|
+
const result = spawnSync(
|
|
778
|
+
process.execPath,
|
|
779
|
+
[fileURLToPath(import.meta.url), ...process.argv.slice(2)],
|
|
780
|
+
{
|
|
781
|
+
stdio: 'inherit',
|
|
782
|
+
env: process.env,
|
|
783
|
+
}
|
|
784
|
+
);
|
|
785
|
+
if (result.error) {
|
|
786
|
+
throw result.error;
|
|
787
|
+
}
|
|
788
|
+
process.exitCode = result.status ?? 1;
|
|
581
789
|
}
|
|
582
790
|
|
|
583
791
|
/**
|
|
@@ -12,6 +12,10 @@ import {
|
|
|
12
12
|
DEFAULT_CLIENT_REQUEST_TIMEOUT_MS,
|
|
13
13
|
ERROR_CODES,
|
|
14
14
|
getProtocolVersion,
|
|
15
|
+
isBatchSafeBridgeCall,
|
|
16
|
+
isBridgeMethod,
|
|
17
|
+
MAX_BATCH_CALLS,
|
|
18
|
+
MAX_BATCH_CONCURRENCY,
|
|
15
19
|
parseJsonLines,
|
|
16
20
|
setProtocolPackageVersion,
|
|
17
21
|
} from '../../protocol/src/index.js';
|
|
@@ -23,6 +27,12 @@ import {
|
|
|
23
27
|
} from '../../native-host/src/config.js';
|
|
24
28
|
import { normalizeBridgeAuthToken, readBridgeAuthToken } from '../../native-host/src/auth-token.js';
|
|
25
29
|
import { restartBridgeDaemon } from '../../native-host/src/daemon-process.js';
|
|
30
|
+
import {
|
|
31
|
+
comparePackageVersions,
|
|
32
|
+
NpmPackageUpdatedError,
|
|
33
|
+
parseStableVersion,
|
|
34
|
+
updateCompatibleNpmPackage,
|
|
35
|
+
} from './npm-self-update.js';
|
|
26
36
|
|
|
27
37
|
/** @typedef {import('./types.js').BridgeMeta} BridgeMeta */
|
|
28
38
|
/** @typedef {import('./types.js').BridgeMethod} BridgeMethod */
|
|
@@ -32,7 +42,8 @@ import { restartBridgeDaemon } from '../../native-host/src/daemon-process.js';
|
|
|
32
42
|
/** @typedef {import('./types.js').PendingRequest} PendingRequest */
|
|
33
43
|
/** @typedef {import('./types.js').ProtocolHealthResult} ProtocolHealthResult */
|
|
34
44
|
|
|
35
|
-
|
|
45
|
+
const CLIENT_PACKAGE_VERSION = loadPackageVersion();
|
|
46
|
+
setProtocolPackageVersion(CLIENT_PACKAGE_VERSION);
|
|
36
47
|
|
|
37
48
|
/**
|
|
38
49
|
* @returns {string | null}
|
|
@@ -119,6 +130,9 @@ export class BridgeClient extends EventEmitter {
|
|
|
119
130
|
checkProtocolOnConnect = true,
|
|
120
131
|
restartDaemonOnVersionMismatch = shouldAutoRestartDaemonOnVersionMismatch(),
|
|
121
132
|
restartDaemonFn = restartBridgeDaemon,
|
|
133
|
+
updateNpmOnCompatibleVersion = false,
|
|
134
|
+
exitProcessOnNpmUpdate = false,
|
|
135
|
+
updateCompatibleNpmPackageFn = updateCompatibleNpmPackage,
|
|
122
136
|
authToken = undefined,
|
|
123
137
|
} = {}) {
|
|
124
138
|
super();
|
|
@@ -131,6 +145,9 @@ export class BridgeClient extends EventEmitter {
|
|
|
131
145
|
this.checkProtocolOnConnect = checkProtocolOnConnect;
|
|
132
146
|
this.restartDaemonOnVersionMismatch = restartDaemonOnVersionMismatch;
|
|
133
147
|
this.restartDaemonFn = restartDaemonFn;
|
|
148
|
+
this.updateNpmOnCompatibleVersion = updateNpmOnCompatibleVersion;
|
|
149
|
+
this.exitProcessOnNpmUpdate = exitProcessOnNpmUpdate;
|
|
150
|
+
this.updateCompatibleNpmPackageFn = updateCompatibleNpmPackageFn;
|
|
134
151
|
this.authToken = authToken;
|
|
135
152
|
this.socket = null;
|
|
136
153
|
this.connected = false;
|
|
@@ -140,6 +157,9 @@ export class BridgeClient extends EventEmitter {
|
|
|
140
157
|
this.waiting = new Map();
|
|
141
158
|
this._reconnecting = false;
|
|
142
159
|
this._attemptedVersionMismatchRestart = false;
|
|
160
|
+
this._attemptedNpmUpdate = false;
|
|
161
|
+
/** @type {import('./types.js').NpmUpdateResult | null} */
|
|
162
|
+
this.npmUpdateResult = null;
|
|
143
163
|
}
|
|
144
164
|
|
|
145
165
|
/**
|
|
@@ -280,6 +300,9 @@ export class BridgeClient extends EventEmitter {
|
|
|
280
300
|
if (this.protocolCompatibility.compatible) {
|
|
281
301
|
this._attemptedVersionMismatchRestart = false;
|
|
282
302
|
}
|
|
303
|
+
if (await this.tryCompatibleNpmUpdate(healthResult)) {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
283
306
|
if (this.shouldRestartDaemonForProtocolMismatch(healthResult)) {
|
|
284
307
|
this._attemptedVersionMismatchRestart = true;
|
|
285
308
|
await this.disconnectForDaemonRestart();
|
|
@@ -288,6 +311,95 @@ export class BridgeClient extends EventEmitter {
|
|
|
288
311
|
}
|
|
289
312
|
}
|
|
290
313
|
|
|
314
|
+
/**
|
|
315
|
+
* @param {ProtocolHealthResult} healthResult
|
|
316
|
+
* @returns {Promise<boolean>}
|
|
317
|
+
*/
|
|
318
|
+
async tryCompatibleNpmUpdate(healthResult) {
|
|
319
|
+
const extensionVersion = healthResult.extensionVersion;
|
|
320
|
+
const processVersion = CLIENT_PACKAGE_VERSION;
|
|
321
|
+
const extensionVersions = Array.isArray(healthResult.extension_supported_versions)
|
|
322
|
+
? healthResult.extension_supported_versions
|
|
323
|
+
: healthResult.extensionConnected === true && Array.isArray(healthResult.supported_versions)
|
|
324
|
+
? healthResult.supported_versions
|
|
325
|
+
: [];
|
|
326
|
+
if (
|
|
327
|
+
!this.updateNpmOnCompatibleVersion ||
|
|
328
|
+
this._attemptedNpmUpdate ||
|
|
329
|
+
healthResult.extensionConnected !== true ||
|
|
330
|
+
typeof extensionVersion !== 'string' ||
|
|
331
|
+
!parseStableVersion(extensionVersion) ||
|
|
332
|
+
typeof processVersion !== 'string' ||
|
|
333
|
+
!parseStableVersion(processVersion) ||
|
|
334
|
+
comparePackageVersions(extensionVersion, processVersion) <= 0 ||
|
|
335
|
+
extensionVersions.length === 0
|
|
336
|
+
) {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
this._attemptedNpmUpdate = true;
|
|
341
|
+
const previousAutoReconnect = this.autoReconnect;
|
|
342
|
+
this.autoReconnect = false;
|
|
343
|
+
try {
|
|
344
|
+
this.npmUpdateResult = await this.updateCompatibleNpmPackageFn({
|
|
345
|
+
extensionVersion,
|
|
346
|
+
supportedVersions: extensionVersions,
|
|
347
|
+
});
|
|
348
|
+
if (!this.npmUpdateResult.updated) {
|
|
349
|
+
if (
|
|
350
|
+
typeof this.npmUpdateResult.previousVersion === 'string' &&
|
|
351
|
+
parseStableVersion(this.npmUpdateResult.previousVersion) &&
|
|
352
|
+
comparePackageVersions(this.npmUpdateResult.previousVersion, processVersion) > 0
|
|
353
|
+
) {
|
|
354
|
+
this.requireFreshProcess(this.npmUpdateResult.previousVersion);
|
|
355
|
+
}
|
|
356
|
+
if (this.npmUpdateResult.reason === 'not_global_install') {
|
|
357
|
+
this.setProtocolWarning(
|
|
358
|
+
'Automatic npm update skipped because this Browser Bridge client is not running from the global npm installation.'
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
this.requireFreshProcess(this.npmUpdateResult.version || extensionVersion);
|
|
365
|
+
} catch (error) {
|
|
366
|
+
if (error instanceof NpmPackageUpdatedError || this.npmUpdateResult?.updated) {
|
|
367
|
+
throw error;
|
|
368
|
+
}
|
|
369
|
+
this.setProtocolWarning(
|
|
370
|
+
`Automatic Browser Bridge npm update failed: ${error instanceof Error ? error.message : String(error)}`
|
|
371
|
+
);
|
|
372
|
+
return false;
|
|
373
|
+
} finally {
|
|
374
|
+
this.autoReconnect = previousAutoReconnect;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* @param {string} version
|
|
380
|
+
* @returns {never}
|
|
381
|
+
*/
|
|
382
|
+
requireFreshProcess(version) {
|
|
383
|
+
if (this.exitProcessOnNpmUpdate) {
|
|
384
|
+
setTimeout(() => process.exit(0), 250).unref();
|
|
385
|
+
}
|
|
386
|
+
throw new NpmPackageUpdatedError(version);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* @param {string} warning
|
|
391
|
+
* @returns {void}
|
|
392
|
+
*/
|
|
393
|
+
setProtocolWarning(warning) {
|
|
394
|
+
this.protocolWarning = this.protocolWarning ? `${this.protocolWarning} ${warning}` : warning;
|
|
395
|
+
if (this.protocolCompatibility) {
|
|
396
|
+
this.protocolCompatibility = {
|
|
397
|
+
...this.protocolCompatibility,
|
|
398
|
+
warning: this.protocolWarning,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
291
403
|
/**
|
|
292
404
|
* @param {{
|
|
293
405
|
* method: BridgeMethod,
|
|
@@ -456,7 +568,7 @@ export class BridgeClient extends EventEmitter {
|
|
|
456
568
|
}
|
|
457
569
|
|
|
458
570
|
/**
|
|
459
|
-
* Send
|
|
571
|
+
* Send up to 20 read-only bridge requests with bounded concurrency.
|
|
460
572
|
*
|
|
461
573
|
* @param {Array<{
|
|
462
574
|
* method: BridgeMethod,
|
|
@@ -471,8 +583,27 @@ export class BridgeClient extends EventEmitter {
|
|
|
471
583
|
if (!Array.isArray(calls)) {
|
|
472
584
|
throw new TypeError('BridgeClient.batch expects an array of request objects.');
|
|
473
585
|
}
|
|
586
|
+
if (calls.length === 0 || calls.length > MAX_BATCH_CALLS) {
|
|
587
|
+
throw new RangeError(
|
|
588
|
+
`BridgeClient.batch expects between 1 and ${MAX_BATCH_CALLS} request objects.`
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
for (const [index, call] of calls.entries()) {
|
|
592
|
+
if (!call || typeof call !== 'object' || !isBridgeMethod(call.method)) {
|
|
593
|
+
throw new TypeError(`BridgeClient.batch calls[${index}] needs a known bridge method.`);
|
|
594
|
+
}
|
|
595
|
+
const params = call.params ?? {};
|
|
596
|
+
if (!params || typeof params !== 'object' || Array.isArray(params)) {
|
|
597
|
+
throw new TypeError(`BridgeClient.batch calls[${index}].params must be an object.`);
|
|
598
|
+
}
|
|
599
|
+
if (!isBatchSafeBridgeCall(call.method, params)) {
|
|
600
|
+
throw new TypeError(
|
|
601
|
+
`${call.method} is not safe for BridgeClient.batch. Call it sequentially.`
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
474
605
|
|
|
475
|
-
return
|
|
606
|
+
return mapWithConcurrency(calls, MAX_BATCH_CONCURRENCY, (call) => this.request(call));
|
|
476
607
|
}
|
|
477
608
|
|
|
478
609
|
/**
|
|
@@ -642,6 +773,28 @@ export class BridgeClient extends EventEmitter {
|
|
|
642
773
|
}
|
|
643
774
|
}
|
|
644
775
|
|
|
776
|
+
/**
|
|
777
|
+
* @template T, R
|
|
778
|
+
* @param {T[]} values
|
|
779
|
+
* @param {number} concurrency
|
|
780
|
+
* @param {(value: T) => Promise<R>} callback
|
|
781
|
+
* @returns {Promise<R[]>}
|
|
782
|
+
*/
|
|
783
|
+
async function mapWithConcurrency(values, concurrency, callback) {
|
|
784
|
+
/** @type {R[]} */
|
|
785
|
+
const results = new Array(values.length);
|
|
786
|
+
let nextIndex = 0;
|
|
787
|
+
const workers = Array.from({ length: Math.min(concurrency, values.length) }, async () => {
|
|
788
|
+
while (nextIndex < values.length) {
|
|
789
|
+
const index = nextIndex;
|
|
790
|
+
nextIndex += 1;
|
|
791
|
+
results[index] = await callback(values[index]);
|
|
792
|
+
}
|
|
793
|
+
});
|
|
794
|
+
await Promise.all(workers);
|
|
795
|
+
return results;
|
|
796
|
+
}
|
|
797
|
+
|
|
645
798
|
/**
|
|
646
799
|
* @param {unknown} result
|
|
647
800
|
* @returns {{ html: string, truncated: boolean, omitted: number }}
|
|
@@ -204,7 +204,10 @@ export const SHORTCUT_COMMANDS = {
|
|
|
204
204
|
maxDepth: r[1] ? parseIntArg(r[1], 'maxDepth') : undefined,
|
|
205
205
|
})
|
|
206
206
|
),
|
|
207
|
-
perf: createShortcutCommand('performance.get_metrics', 'bbx perf', () => ({})
|
|
207
|
+
perf: createShortcutCommand('performance.get_metrics', 'bbx perf', () => ({}), {
|
|
208
|
+
description:
|
|
209
|
+
'Read a raw Chrome/CDP counter point sample (names and units vary; no BBX navigation window or LCP/CLS/INP measurement)',
|
|
210
|
+
}),
|
|
208
211
|
scroll: createShortcutCommand('viewport.scroll', 'bbx scroll <top> [left]', (r) => {
|
|
209
212
|
if (!r[0] && !r[1]) throw new Error('Usage: scroll <top> [left]');
|
|
210
213
|
return {
|
|
@@ -250,6 +253,7 @@ export const CLI_METHOD_BINDINGS = Object.freeze({
|
|
|
250
253
|
'press-key': 'input.press_key',
|
|
251
254
|
'cdp-press-key': 'cdp.dispatch_key_event',
|
|
252
255
|
screenshot: 'screenshot.capture_element',
|
|
256
|
+
har: 'network.export_har',
|
|
253
257
|
eval: 'page.evaluate',
|
|
254
258
|
});
|
|
255
259
|
|
|
@@ -265,6 +269,7 @@ export const CLI_HELP_SECTIONS = Object.freeze([
|
|
|
265
269
|
'bbx status Check bridge connection',
|
|
266
270
|
'bbx doctor Consolidated local runtime, setup, protocol, and readiness diagnostics',
|
|
267
271
|
'bbx restart Restart the local daemon and running MCP servers',
|
|
272
|
+
'bbx config set auto-update <off|compatible> Configure protocol-compatible npm updates',
|
|
268
273
|
'bbx proxy enable [--port 9223] [--bind-host 127.0.0.1] [--rotate-token] Enable loopback TCP proxy mode for SSH tunneling',
|
|
269
274
|
' Non-loopback binds require --unsafe-plaintext because raw TCP is unencrypted.',
|
|
270
275
|
'bbx proxy disable Disable proxy mode and restart the daemon',
|
|
@@ -288,7 +293,7 @@ export const CLI_HELP_SECTIONS = Object.freeze([
|
|
|
288
293
|
lines: [
|
|
289
294
|
'bbx call [--tab <tabId>] <method> [paramsJson|-] Call any bridge method (- reads JSON from stdin)',
|
|
290
295
|
'bbx <method> [--tab <tabId>] [paramsJson|-] Direct alias for exact bridge methods such as page.get_state',
|
|
291
|
-
"bbx batch '[{method,params,tabId?},...]'
|
|
296
|
+
"bbx batch '[{method,params,tabId?},...]' Up to 20 parallel read-only calls",
|
|
292
297
|
'Every bridge command accepts --remote <name> (or BBX_REMOTE env) to target a remote destination from `bbx remote list`.',
|
|
293
298
|
'Advanced bridge params stay available through `bbx call`, even when shortcuts expose only the common case.',
|
|
294
299
|
'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 +381,7 @@ export const CLI_HELP_SECTIONS = Object.freeze([
|
|
|
376
381
|
title: 'Capture',
|
|
377
382
|
lines: [
|
|
378
383
|
'bbx screenshot [--tab <tabId>] [--format png|jpeg|webp] [--quality 0-100] <ref|selector> [path]',
|
|
384
|
+
'bbx har [--tab <tabId>] [--limit 1-200] [--url-pattern <pattern>] [--delivery inline|artifact|auto] [outPath]',
|
|
379
385
|
],
|
|
380
386
|
},
|
|
381
387
|
]);
|