@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.
Files changed (48) hide show
  1. package/PRIVACY.md +29 -3
  2. package/README.md +10 -3
  3. package/SECURITY.md +12 -0
  4. package/package.json +7 -1
  5. package/packages/agent-client/src/cli-args.js +70 -6
  6. package/packages/agent-client/src/cli-batch.js +127 -59
  7. package/packages/agent-client/src/cli-helpers.js +8 -0
  8. package/packages/agent-client/src/cli-output.js +8 -1
  9. package/packages/agent-client/src/cli-setup-commands.js +3 -1
  10. package/packages/agent-client/src/cli.js +254 -26
  11. package/packages/agent-client/src/client.js +156 -3
  12. package/packages/agent-client/src/command-registry.js +14 -4
  13. package/packages/agent-client/src/config.js +195 -0
  14. package/packages/agent-client/src/mcp-config.js +8 -8
  15. package/packages/agent-client/src/npm-self-update.js +305 -0
  16. package/packages/agent-client/src/remotes.js +4 -2
  17. package/packages/agent-client/src/runtime.js +99 -7
  18. package/packages/agent-client/src/types.ts +38 -0
  19. package/packages/mcp-server/src/guidance.js +37 -6
  20. package/packages/mcp-server/src/handlers-capture.js +29 -2
  21. package/packages/mcp-server/src/handlers-dom.js +54 -3
  22. package/packages/mcp-server/src/handlers-page.js +333 -60
  23. package/packages/mcp-server/src/handlers-utils.js +141 -151
  24. package/packages/mcp-server/src/handlers.js +17 -4
  25. package/packages/mcp-server/src/server.js +269 -51
  26. package/packages/mcp-server/src/toolset.js +48 -0
  27. package/packages/native-host/src/artifact-store.js +353 -0
  28. package/packages/native-host/src/config.js +9 -1
  29. package/packages/native-host/src/daemon-logger.js +3 -1
  30. package/packages/native-host/src/daemon.js +457 -27
  31. package/packages/native-host/src/dom-baseline-owners.js +210 -0
  32. package/packages/native-host/src/native-host.js +38 -29
  33. package/packages/protocol/src/budget-presets.js +182 -0
  34. package/packages/protocol/src/capability-values.js +2 -0
  35. package/packages/protocol/src/defaults.js +28 -0
  36. package/packages/protocol/src/errors.js +48 -5
  37. package/packages/protocol/src/har.js +34 -0
  38. package/packages/protocol/src/incidental-sanitizer.js +197 -0
  39. package/packages/protocol/src/index.js +4 -0
  40. package/packages/protocol/src/json-lines.js +3 -2
  41. package/packages/protocol/src/protocol.js +316 -6
  42. package/packages/protocol/src/recovery-telemetry.js +302 -0
  43. package/packages/protocol/src/registry.js +226 -6
  44. package/packages/protocol/src/summary.js +116 -25
  45. package/packages/protocol/src/types.ts +490 -4
  46. package/skills/browser-bridge/SKILL.md +26 -8
  47. package/skills/browser-bridge/references/protocol.md +125 -15
  48. package/skills/browser-bridge/references/token-efficiency.md +17 -7
@@ -4,11 +4,19 @@
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';
10
12
  import { restartBridgeDaemon } from '../../native-host/src/daemon-process.js';
11
- import { createRuntimeContext, METHODS } from '../../protocol/src/index.js';
13
+ import {
14
+ createRuntimeContext,
15
+ describeBridgeMethods,
16
+ isBridgeMethod,
17
+ isHarDocument,
18
+ METHODS,
19
+ } from '../../protocol/src/index.js';
12
20
  import {
13
21
  restartRegisteredMcpProcesses,
14
22
  tryStartMcpProcessControl,
@@ -16,6 +24,8 @@ import {
16
24
  import { startBridgeMcpServer } from '../../mcp-server/src/server.js';
17
25
  import {
18
26
  extractRemoteFlag,
27
+ extractHarFlags,
28
+ extractPresetFlag,
19
29
  extractScreenshotFlags,
20
30
  extractTabFlag,
21
31
  parseCallCommand,
@@ -23,7 +33,7 @@ import {
23
33
  readStdin,
24
34
  } from './cli-args.js';
25
35
  import { runBatchCalls } from './cli-batch.js';
26
- import { parseIntArg } from './cli-helpers.js';
36
+ import { isElementRef, parseIntArg } from './cli-helpers.js';
27
37
  import { printCallResponse, printJson, printSummary, printUsage } from './cli-output.js';
28
38
  import { handleProxyCommand, handleRemoteCommand } from './cli-proxy-remote.js';
29
39
  import {
@@ -32,12 +42,16 @@ import {
32
42
  uninstallBrowserBridge,
33
43
  } from './cli-setup-commands.js';
34
44
  import { SHORTCUT_COMMANDS } from './command-registry.js';
45
+ import { getAutoUpdatePolicy, parseAutoUpdatePolicy, setAutoUpdatePolicy } from './config.js';
35
46
  import { formatMcpConfig, isMcpClientName, MCP_CLIENT_NAMES } from './mcp-config.js';
36
47
  import { getDoctorReport, requestBridge, resolveRef } from './runtime.js';
37
48
  import { createBridgeClientForDestination } from './remotes.js';
49
+ import { atomicWriteFile } from './atomic-write.js';
38
50
 
39
51
  /** @typedef {import('./types.js').BridgeMethod} BridgeMethod */
40
52
  /** @typedef {import('./types.js').ScreenshotResult} ScreenshotResult */
53
+ /** @typedef {import('../../protocol/src/types.js').ArtifactDescriptor} ArtifactDescriptor */
54
+ /** @typedef {import('../../protocol/src/types.js').HarExportResult} HarExportResult */
41
55
 
42
56
  const REQUEST_SOURCE = 'cli';
43
57
  const TEST_TIMEOUT_ENV = 'BBX_CLIENT_REQUEST_TIMEOUT_MS';
@@ -57,6 +71,7 @@ const LOCAL_ONLY_COMMANDS = new Set([
57
71
  '--version',
58
72
  '-v',
59
73
  'skill',
74
+ 'protocol',
60
75
  'install',
61
76
  'uninstall',
62
77
  'install-skill',
@@ -64,6 +79,7 @@ const LOCAL_ONLY_COMMANDS = new Set([
64
79
  'mcp',
65
80
  'proxy',
66
81
  'remote',
82
+ 'config',
67
83
  'doctor',
68
84
  'restart',
69
85
  ]);
@@ -103,6 +119,22 @@ if (command === 'skill') {
103
119
  process.exit(0);
104
120
  }
105
121
 
122
+ if (command === 'protocol') {
123
+ const [subcommand, target, ...extra] = rest;
124
+ if (subcommand !== 'describe' || extra.length > 0) {
125
+ process.stderr.write('Usage: bbx protocol describe [method|group]\n');
126
+ process.exit(1);
127
+ }
128
+ await runLocalCommand(async () => {
129
+ printJson(
130
+ target
131
+ ? describeBridgeMethods(isBridgeMethod(target) ? { method: target } : { group: target })
132
+ : describeBridgeMethods()
133
+ );
134
+ });
135
+ process.exit();
136
+ }
137
+
106
138
  if (command === 'install') {
107
139
  const { execFileSync } = await import('node:child_process');
108
140
  const installScript = path.resolve(
@@ -134,7 +166,7 @@ if (command === 'install-mcp') {
134
166
  }
135
167
 
136
168
  if (command === 'mcp') {
137
- const [subcommand, clientName] = rest;
169
+ const [subcommand, clientName, ...extra] = rest;
138
170
  if (subcommand === 'serve') {
139
171
  const control = await tryStartMcpProcessControl();
140
172
  try {
@@ -146,7 +178,7 @@ if (command === 'mcp') {
146
178
  await new Promise(() => {});
147
179
  }
148
180
  if (subcommand === 'config') {
149
- if (!clientName || !isMcpClientName(clientName)) {
181
+ if (!clientName || !isMcpClientName(clientName) || extra.length > 0) {
150
182
  process.stderr.write(`Usage: bbx mcp config <${MCP_CLIENT_NAMES.join('|')}>\n`);
151
183
  process.exit(1);
152
184
  }
@@ -167,6 +199,11 @@ if (command === 'remote') {
167
199
  process.exit();
168
200
  }
169
201
 
202
+ if (command === 'config') {
203
+ await runLocalCommand(() => handleConfigCommand(rest));
204
+ process.exit();
205
+ }
206
+
170
207
  const clientTimeoutMs = getClientTimeoutOverride();
171
208
  applyWindowsTcpTransportDefaults();
172
209
  const client = await createCliClient();
@@ -189,6 +226,26 @@ async function runLocalCommand(run) {
189
226
  }
190
227
  }
191
228
 
229
+ /**
230
+ * @param {string[]} args
231
+ * @returns {Promise<void>}
232
+ */
233
+ async function handleConfigCommand(args) {
234
+ const [action, key, value] = args;
235
+ if (action === 'get' && key === 'auto-update' && value === undefined) {
236
+ process.stdout.write(`${await getAutoUpdatePolicy()}\n`);
237
+ return;
238
+ }
239
+ if (action === 'set' && key === 'auto-update' && value !== undefined && args.length === 3) {
240
+ const policy = parseAutoUpdatePolicy(value);
241
+ const configPath = await setAutoUpdatePolicy(policy);
242
+ process.stdout.write(`Browser Bridge auto-update policy set to "${policy}".\n`);
243
+ process.stdout.write(`Config: ${configPath}\n`);
244
+ return;
245
+ }
246
+ throw new Error('Usage: bbx config <get auto-update|set auto-update off|compatible>');
247
+ }
248
+
192
249
  /**
193
250
  * Create the bridge client for this invocation, honoring the --remote flag
194
251
  * and BBX_REMOTE env var. Exits with a friendly error when the destination
@@ -213,6 +270,7 @@ async function createCliClient() {
213
270
  }
214
271
 
215
272
  async function main() {
273
+ let relaunchAfterUpdate = false;
216
274
  try {
217
275
  if (command === 'status') {
218
276
  const healthResponse = await requestBridge(
@@ -226,8 +284,10 @@ async function main() {
226
284
  }
227
285
 
228
286
  if (command === 'access-request') {
287
+ if (rest.length > 1) throw new Error('Usage: bbx access-request [intent]');
288
+ const params = SHORTCUT_COMMANDS['access-request'].build(rest);
229
289
  await printSummary(
230
- await requestBridge(client, 'access.request', {}, { source: REQUEST_SOURCE })
290
+ await requestBridge(client, 'access.request', params, { source: REQUEST_SOURCE })
231
291
  );
232
292
  return;
233
293
  }
@@ -338,12 +398,13 @@ async function main() {
338
398
  tabId,
339
399
  source: REQUEST_SOURCE,
340
400
  });
341
- printCallResponse(response);
401
+ printCallResponse(response, method);
342
402
  return;
343
403
  }
344
404
 
345
405
  if (command === 'batch') {
346
- const results = await runBatchCalls(client, rest[0], REQUEST_SOURCE);
406
+ const { preset, rest: batchArgs } = extractPresetFlag(rest);
407
+ const results = await runBatchCalls(client, batchArgs[0], REQUEST_SOURCE, { preset });
347
408
  if (results.some((result) => !result.ok)) {
348
409
  process.exitCode = 1;
349
410
  }
@@ -357,7 +418,7 @@ async function main() {
357
418
  tabId,
358
419
  source: REQUEST_SOURCE,
359
420
  });
360
- printCallResponse(response);
421
+ printCallResponse(response, method);
361
422
  return;
362
423
  }
363
424
 
@@ -378,7 +439,7 @@ async function main() {
378
439
  const [key, refOrSelector] = rest;
379
440
  if (!key) throw new Error('Usage: press-key <key> [ref|selector]');
380
441
  const target = refOrSelector
381
- ? refOrSelector.startsWith('el_')
442
+ ? isElementRef(refOrSelector)
382
443
  ? { elementRef: refOrSelector }
383
444
  : { selector: refOrSelector }
384
445
  : undefined;
@@ -431,6 +492,7 @@ async function main() {
431
492
  elementRef,
432
493
  format: screenshotOptions.format,
433
494
  quality: screenshotOptions.quality,
495
+ delivery: 'artifact',
434
496
  },
435
497
  { tabId: parsed.tabId, source: REQUEST_SOURCE }
436
498
  );
@@ -453,8 +515,11 @@ async function main() {
453
515
  ) {
454
516
  throw new Error(`Screenshot path extension must match ${screenshotOptions.format} format.`);
455
517
  }
456
- const data = screenshotResult.image.replace(/^data:image\/(?:png|jpeg|webp);base64,/, '');
457
- await fs.promises.writeFile(filePath, Buffer.from(data, 'base64'));
518
+ const imageBytes =
519
+ screenshotResult.delivery === 'artifact'
520
+ ? await downloadArtifact(client, screenshotResult.artifact, 'screenshot')
521
+ : decodeScreenshotDataUrl(screenshotResult.image);
522
+ await atomicWriteFile(filePath, imageBytes);
458
523
  printJson({
459
524
  ok: true,
460
525
  summary: `Screenshot saved to ${filePath}.`,
@@ -469,6 +534,50 @@ async function main() {
469
534
  return;
470
535
  }
471
536
 
537
+ if (command === 'har') {
538
+ const parsed = extractTabFlag(rest);
539
+ const harOptions = extractHarFlags(parsed.rest);
540
+ const [outputPath = 'browser-bridge.har', ...extra] = harOptions.rest;
541
+ if (extra.length > 0) {
542
+ throw new Error(
543
+ 'Usage: har [--tab <tabId>] [--limit 1-200] [--url-pattern <pattern>] [--delivery inline|artifact|auto] [outPath]'
544
+ );
545
+ }
546
+ if (path.extname(outputPath) !== '.har') {
547
+ throw new Error('HAR output path must use the .har extension.');
548
+ }
549
+ const response = await requestBridge(
550
+ client,
551
+ 'network.export_har',
552
+ {
553
+ limit: harOptions.limit,
554
+ urlPattern: harOptions.urlPattern,
555
+ delivery: harOptions.delivery,
556
+ },
557
+ { tabId: parsed.tabId, source: REQUEST_SOURCE }
558
+ );
559
+ if (!response.ok) {
560
+ await printSummary(response);
561
+ return;
562
+ }
563
+ const result = /** @type {HarExportResult} */ (response.result);
564
+ let harBytes;
565
+ if (result.delivery === 'artifact') {
566
+ harBytes = await downloadArtifact(client, result.artifact, 'har');
567
+ assertValidHarBytes(harBytes);
568
+ } else {
569
+ assertValidHarDocument(result.har);
570
+ harBytes = Buffer.from(`${JSON.stringify(result.har, null, 2)}\n`, 'utf8');
571
+ }
572
+ await atomicWriteFile(outputPath, harBytes);
573
+ printJson({
574
+ ok: true,
575
+ summary: `HAR saved to ${outputPath}.`,
576
+ evidence: { savedTo: outputPath, delivery: result.delivery },
577
+ });
578
+ return;
579
+ }
580
+
472
581
  if (command === 'intercept') {
473
582
  const { tabId, rest: iArgs } = extractTabFlag(rest);
474
583
  const sub = iArgs[0];
@@ -559,25 +668,144 @@ async function main() {
559
668
  } catch (error) {
560
669
  const message = error instanceof Error ? error.message : String(error);
561
670
  const raw = getErrorCode(error);
562
- let code = 'ERROR';
563
- if (raw === 'ENOENT' || raw === 'ECONNREFUSED' || raw === 'EINVAL') {
564
- code = 'DAEMON_OFFLINE';
565
- } else if (raw === 'BRIDGE_TIMEOUT') {
566
- code = 'BRIDGE_TIMEOUT';
567
- } else if (/socket closed/i.test(message)) {
568
- code = 'CONNECTION_LOST';
569
- } else if (raw) {
570
- code = String(raw);
671
+ if (raw === 'BBX_NPM_UPDATED') {
672
+ relaunchAfterUpdate = true;
673
+ } else {
674
+ let code = 'ERROR';
675
+ if (raw === 'ENOENT' || raw === 'ECONNREFUSED' || raw === 'EINVAL') {
676
+ code = 'DAEMON_OFFLINE';
677
+ } else if (raw === 'BRIDGE_TIMEOUT') {
678
+ code = 'BRIDGE_TIMEOUT';
679
+ } else if (/socket closed/i.test(message)) {
680
+ code = 'CONNECTION_LOST';
681
+ } else if (raw) {
682
+ code = String(raw);
683
+ }
684
+ printJson({
685
+ ok: false,
686
+ summary: `${code}: ${message}`,
687
+ evidence: null,
688
+ });
689
+ process.exitCode = 1;
571
690
  }
572
- printJson({
573
- ok: false,
574
- summary: `${code}: ${message}`,
575
- evidence: null,
576
- });
577
- process.exitCode = 1;
578
691
  } finally {
579
692
  await client.close();
580
693
  }
694
+ if (relaunchAfterUpdate) {
695
+ relaunchCli();
696
+ }
697
+ }
698
+
699
+ /** @param {string} value */
700
+ function decodeScreenshotDataUrl(value) {
701
+ const match = /^data:image\/(?:png|jpeg|webp);base64,([A-Za-z0-9+/]*={0,2})$/u.exec(value);
702
+ if (!match) throw new Error('Screenshot response contains invalid base64 image data.');
703
+ const bytes = Buffer.from(match[1], 'base64');
704
+ if (bytes.toString('base64') !== match[1]) {
705
+ throw new Error('Screenshot response contains non-canonical base64 image data.');
706
+ }
707
+ return bytes;
708
+ }
709
+
710
+ /**
711
+ * @param {import('./client.js').BridgeClient} client
712
+ * @param {ArtifactDescriptor} artifact
713
+ * @param {'screenshot' | 'har'} [expectedKind]
714
+ */
715
+ async function downloadArtifact(client, artifact, expectedKind) {
716
+ const chunks = [];
717
+ let offset = 0;
718
+ try {
719
+ if (expectedKind && artifact.kind !== expectedKind) {
720
+ throw new Error(`Artifact kind does not match the expected ${expectedKind} result.`);
721
+ }
722
+ if (expectedKind === 'har' && artifact.mimeType !== 'application/json') {
723
+ throw new Error('HAR artifact MIME type is invalid.');
724
+ }
725
+ while (offset < artifact.byteLength) {
726
+ const response = await requestBridge(
727
+ client,
728
+ 'artifact.read',
729
+ { artifactId: artifact.artifactId, offset },
730
+ { source: REQUEST_SOURCE }
731
+ );
732
+ if (!response.ok) throw new Error(response.error.message);
733
+ const result = /** @type {Record<string, unknown>} */ (response.result);
734
+ if (result.artifactId !== artifact.artifactId || result.offset !== offset) {
735
+ throw new Error('Artifact chunk metadata is inconsistent.');
736
+ }
737
+ const data = String(result.data ?? '');
738
+ const bytes = Buffer.from(data, 'base64');
739
+ if (bytes.toString('base64') !== data || bytes.length !== result.byteLength) {
740
+ throw new Error('Artifact chunk contains invalid base64 data.');
741
+ }
742
+ chunks.push(bytes);
743
+ if (result.nextOffset === null) break;
744
+ if (typeof result.nextOffset !== 'number' || result.nextOffset <= offset) {
745
+ throw new Error('Artifact chunk did not advance the read offset.');
746
+ }
747
+ offset = result.nextOffset;
748
+ }
749
+ const output = Buffer.concat(chunks);
750
+ const sha256 = createHash('sha256').update(output).digest('hex');
751
+ if (output.length !== artifact.byteLength || sha256 !== artifact.sha256) {
752
+ throw new Error('Artifact length or checksum does not match the capture metadata.');
753
+ }
754
+ return output;
755
+ } finally {
756
+ await requestBridge(
757
+ client,
758
+ 'artifact.delete',
759
+ { artifactId: artifact.artifactId },
760
+ { source: REQUEST_SOURCE }
761
+ ).catch(() => {});
762
+ }
763
+ }
764
+
765
+ /** @param {Buffer} bytes */
766
+ function assertValidHarBytes(bytes) {
767
+ const text = bytes.toString('utf8');
768
+ if (!Buffer.from(text, 'utf8').equals(bytes)) {
769
+ throw new Error('HAR artifact is not valid UTF-8 JSON.');
770
+ }
771
+ let document;
772
+ try {
773
+ document = JSON.parse(text);
774
+ } catch {
775
+ throw new Error('HAR artifact is not valid JSON.');
776
+ }
777
+ assertValidHarDocument(document);
778
+ }
779
+
780
+ /** @param {unknown} document */
781
+ function assertValidHarDocument(document) {
782
+ if (!document || typeof document !== 'object' || Array.isArray(document)) {
783
+ throw new Error('HAR response does not contain an inline HAR object.');
784
+ }
785
+ if (!isHarDocument(document)) {
786
+ throw new Error('HAR response does not contain a valid HAR 1.2 document.');
787
+ }
788
+ }
789
+
790
+ /**
791
+ * Replace this invocation with a fresh Node process after npm has replaced the
792
+ * package on disk. Child stdio is inherited so command output remains stable.
793
+ *
794
+ * @returns {void}
795
+ */
796
+ function relaunchCli() {
797
+ const result = spawnSync(
798
+ process.execPath,
799
+ [fileURLToPath(import.meta.url), ...process.argv.slice(2)],
800
+ {
801
+ stdio: 'inherit',
802
+ env: process.env,
803
+ }
804
+ );
805
+ if (result.error) {
806
+ throw result.error;
807
+ }
808
+ process.exitCode = result.status ?? 1;
581
809
  }
582
810
 
583
811
  /**
@@ -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
- setProtocolPackageVersion(loadPackageVersion());
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 multiple bridge requests without waiting for each response serially.
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 Promise.all(calls.map((call) => this.request(call)));
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 }}