@browserbridge/bbx 1.11.1 → 1.11.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserbridge/bbx",
3
- "version": "1.11.1",
3
+ "version": "1.11.3",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "agent-tools",
@@ -76,19 +76,19 @@
76
76
  "@modelcontextprotocol/server": "^2.0.0",
77
77
  "@mozilla/readability": "^0.6.0",
78
78
  "linkedom": "^0.18.13",
79
- "zod": "^4.4.3"
79
+ "zod": "^4.6.5"
80
80
  },
81
81
  "devDependencies": {
82
82
  "@modelcontextprotocol/client": "^2.0.0",
83
- "@types/chrome": "^0.2.6",
84
- "@types/node": "^26.2.0",
83
+ "@types/chrome": "^0.3.0",
84
+ "@types/node": "^26.6.1",
85
85
  "ajv": "^8.20.0",
86
86
  "ajv-formats": "^3.0.1",
87
87
  "c8": "^12.0.0",
88
88
  "har-schema": "^2.0.0",
89
- "oxfmt": "^0.63.0",
90
- "oxlint": "^1.78.0",
91
- "tsx": "^4.23.12",
89
+ "oxfmt": "^0.68.0",
90
+ "oxlint": "^1.83.0",
91
+ "tsx": "^4.23.13",
92
92
  "typescript": "^7.0.2"
93
93
  },
94
94
  "engines": {
@@ -1,4 +1,5 @@
1
1
  // @ts-check
2
+ import { isProtocolVersionSupported } from '../../protocol/src/index.js';
2
3
 
3
4
  import { EventEmitter, once } from 'node:events';
4
5
  import fs from 'node:fs';
@@ -104,16 +105,37 @@ function shouldAutoRestartDaemonOnVersionMismatch(env = process.env) {
104
105
  /**
105
106
  * @param {net.Socket} socket
106
107
  * @param {string} line
108
+ * @param {AbortSignal} signal
107
109
  * @returns {Promise<void>}
108
110
  */
109
- async function writeSocketLine(socket, line) {
111
+ async function writeSocketLine(socket, line, signal) {
112
+ signal.throwIfAborted();
110
113
  if (!socket.write(line)) {
111
- await Promise.race([
112
- once(socket, 'drain'),
113
- once(socket, 'close').then(() => {
114
- throw new Error('Bridge socket closed while writing.');
115
- }),
116
- ]);
114
+ await new Promise((resolve, reject) => {
115
+ const cleanup = () => {
116
+ socket.off('drain', onDrain);
117
+ socket.off('error', onError);
118
+ socket.off('close', onClose);
119
+ signal.removeEventListener('abort', onAbort);
120
+ };
121
+ const onDrain = () => {
122
+ cleanup();
123
+ resolve(undefined);
124
+ };
125
+ /** @param {unknown} error */
126
+ const onError = (error) => {
127
+ cleanup();
128
+ reject(error);
129
+ };
130
+ const onClose = () => onError(new Error('Bridge socket closed while writing.'));
131
+ const onAbort = () => onError(signal.reason);
132
+ socket.once('drain', onDrain);
133
+ socket.once('error', onError);
134
+ socket.once('close', onClose);
135
+ signal.addEventListener('abort', onAbort, { once: true });
136
+ if (signal.aborted) onAbort();
137
+ else if (socket.destroyed) onClose();
138
+ });
117
139
  }
118
140
  }
119
141
 
@@ -176,12 +198,31 @@ export class BridgeClient extends EventEmitter {
176
198
  this.socket = socket;
177
199
  try {
178
200
  await new Promise((resolve, reject) => {
179
- socket.once('connect', resolve);
180
- socket.once('error', reject);
201
+ const cleanup = () => {
202
+ socket.off('connect', onConnect);
203
+ socket.off('error', onError);
204
+ socket.off('close', onClose);
205
+ };
206
+ const onConnect = () => {
207
+ cleanup();
208
+ resolve(undefined);
209
+ };
210
+ /** @param {Error} error */
211
+ const onError = (error) => {
212
+ cleanup();
213
+ reject(error);
214
+ };
215
+ const onClose = () => onError(new Error('Bridge socket closed while connecting.'));
216
+ socket.once('connect', onConnect);
217
+ socket.once('error', onError);
218
+ socket.once('close', onClose);
181
219
  });
220
+ if (this.socket !== socket || socket.destroyed) {
221
+ throw new Error('Bridge socket closed while connecting.');
222
+ }
182
223
  } catch (error) {
183
224
  socket.destroy();
184
- this.socket = null;
225
+ if (this.socket === socket) this.socket = null;
185
226
  throw error;
186
227
  }
187
228
 
@@ -198,6 +239,7 @@ export class BridgeClient extends EventEmitter {
198
239
  });
199
240
 
200
241
  parseJsonLines(socket, (raw) => {
242
+ if (this.socket !== socket) return;
201
243
  const message = /** @type {ClientMessage} */ (raw);
202
244
  if (message.type === 'registered') {
203
245
  const pending = this.waiting.get('registered');
@@ -231,6 +273,7 @@ export class BridgeClient extends EventEmitter {
231
273
  });
232
274
 
233
275
  socket.on('close', () => {
276
+ if (this.socket !== socket) return;
234
277
  this.connected = false;
235
278
  this.socket = null;
236
279
  this.rejectAllPending(new Error('Bridge socket closed.'));
@@ -240,35 +283,50 @@ export class BridgeClient extends EventEmitter {
240
283
  });
241
284
 
242
285
  socket.on('error', (error) => {
286
+ if (this.socket !== socket) return;
243
287
  this.rejectAllPending(error);
244
288
  // 'close' fires after 'error'; reconnect is triggered there.
245
289
  });
246
290
 
247
- const authToken =
248
- this.authToken === undefined
249
- ? this.transport.type === 'tcp'
250
- ? await readBridgeAuthToken()
251
- : null
252
- : normalizeBridgeAuthToken(this.authToken);
291
+ const registrationWrite = new AbortController();
253
292
  try {
254
- await writeSocketLine(
255
- socket,
256
- `${JSON.stringify({
257
- type: 'register',
258
- role: 'agent',
259
- clientId: this.clientId,
260
- ...(authToken ? { authToken } : {}),
261
- })}\n`
262
- );
293
+ const writePromise = (async () => {
294
+ const authToken =
295
+ this.authToken === undefined
296
+ ? this.transport.type === 'tcp'
297
+ ? await readBridgeAuthToken()
298
+ : null
299
+ : normalizeBridgeAuthToken(this.authToken);
300
+ await writeSocketLine(
301
+ socket,
302
+ `${JSON.stringify({
303
+ type: 'register',
304
+ role: 'agent',
305
+ clientId: this.clientId,
306
+ ...(authToken ? { authToken } : {}),
307
+ })}\n`,
308
+ registrationWrite.signal
309
+ );
310
+ })();
311
+ await Promise.race([registrationPromise, writePromise.then(() => registrationPromise)]);
312
+ if (this.socket !== socket || socket.destroyed) {
313
+ throw new Error('Bridge socket closed while registering.');
314
+ }
263
315
  } catch (error) {
264
- const pending = this.waiting.get('registered');
265
- if (pending) {
266
- clearTimeout(pending.timeoutId);
267
- this.waiting.delete('registered');
316
+ if (this.socket === socket) {
317
+ const pending = this.waiting.get('registered');
318
+ if (pending) {
319
+ clearTimeout(pending.timeoutId);
320
+ this.waiting.delete('registered');
321
+ }
322
+ this.connected = false;
323
+ this.socket = null;
268
324
  }
325
+ socket.destroy();
269
326
  throw error;
327
+ } finally {
328
+ registrationWrite.abort();
270
329
  }
271
- await registrationPromise;
272
330
 
273
331
  this.protocolCompatibility = null;
274
332
  this.protocolWarning = null;
@@ -553,18 +611,25 @@ export class BridgeClient extends EventEmitter {
553
611
  });
554
612
  });
555
613
 
614
+ const requestWrite = new AbortController();
556
615
  try {
557
- await writeSocketLine(this.socket, `${JSON.stringify({ type: 'agent.request', request })}\n`);
558
- } catch (error) {
616
+ const writePromise = writeSocketLine(
617
+ this.socket,
618
+ `${JSON.stringify({ type: 'agent.request', request })}\n`,
619
+ requestWrite.signal
620
+ );
621
+ const response = /** @type {BridgeResponse} */ (
622
+ await Promise.race([responsePromise, writePromise.then(() => responsePromise)])
623
+ );
624
+ return this.attachProtocolWarning(response);
625
+ } finally {
626
+ requestWrite.abort();
559
627
  const pending = this.waiting.get(request.id);
560
628
  if (pending) {
561
629
  clearTimeout(pending.timeoutId);
562
630
  this.waiting.delete(request.id);
563
631
  }
564
- throw error;
565
632
  }
566
- const response = /** @type {BridgeResponse} */ (await responsePromise);
567
- return this.attachProtocolWarning(response);
568
633
  }
569
634
 
570
635
  /**
@@ -611,11 +676,19 @@ export class BridgeClient extends EventEmitter {
611
676
  */
612
677
  async close() {
613
678
  this.autoReconnect = false; // prevent reconnect on intentional close
614
- if (!this.socket) {
679
+ const socket = this.socket;
680
+ if (!socket) {
681
+ return;
682
+ }
683
+ if (socket.connecting || !this.connected || socket.destroyed || socket.writableNeedDrain) {
684
+ this.socket = null;
685
+ this.connected = false;
686
+ this.rejectAllPending(new Error('Bridge socket closed.'));
687
+ socket.destroy();
615
688
  return;
616
689
  }
617
690
  await new Promise((resolve) => {
618
- this.socket?.end(() => resolve(undefined));
691
+ socket.end(() => resolve(undefined));
619
692
  });
620
693
  }
621
694
 
@@ -672,7 +745,7 @@ export class BridgeClient extends EventEmitter {
672
745
  if (
673
746
  healthResult?.extensionConnected === true &&
674
747
  Array.isArray(extensionVersions) &&
675
- !extensionVersions.includes(getProtocolVersion())
748
+ !isProtocolVersionSupported(getProtocolVersion(), extensionVersions)
676
749
  ) {
677
750
  return false;
678
751
  }
@@ -697,6 +770,7 @@ export class BridgeClient extends EventEmitter {
697
770
  const previousAutoReconnect = this.autoReconnect;
698
771
  this.autoReconnect = false;
699
772
  this.connected = false;
773
+ this.rejectAllPending(new Error('Bridge socket closed.'));
700
774
  this.socket = null;
701
775
 
702
776
  if (!socket.destroyed) {
@@ -729,7 +803,7 @@ export class BridgeClient extends EventEmitter {
729
803
  };
730
804
  }
731
805
  const localVersion = getProtocolVersion();
732
- const compatible = remoteVersions.includes(localVersion);
806
+ const compatible = isProtocolVersionSupported(localVersion, remoteVersions);
733
807
  return {
734
808
  compatible,
735
809
  localVersion,
@@ -1,4 +1,5 @@
1
1
  // @ts-check
2
+ import { isProtocolVersionSupported } from '../../protocol/src/index.js';
2
3
 
3
4
  import { execFile } from 'node:child_process';
4
5
  import { createHash } from 'node:crypto';
@@ -75,12 +76,12 @@ export function derivePackageProtocolVersion(version) {
75
76
  */
76
77
  export function selectCompatibleNpmVersion(versions, supportedProtocols, currentVersion) {
77
78
  if (!parseStableVersion(currentVersion)) return null;
78
- const supported = new Set(supportedProtocols.filter((version) => /^\d+\.\d+$/u.test(version)));
79
79
  let selected = null;
80
80
  for (const value of versions) {
81
81
  if (typeof value !== 'string' || !parseStableVersion(value)) continue;
82
82
  const protocolVersion = derivePackageProtocolVersion(value);
83
- if (!protocolVersion || !supported.has(protocolVersion)) continue;
83
+ if (!protocolVersion || !isProtocolVersionSupported(protocolVersion, supportedProtocols))
84
+ continue;
84
85
  if (comparePackageVersions(value, currentVersion) <= 0) continue;
85
86
  if (selected === null || comparePackageVersions(value, selected) > 0) selected = value;
86
87
  }
@@ -252,7 +253,10 @@ export async function updateCompatibleNpmPackage(options) {
252
253
  const packageRoot = options.packageRoot ?? PACKAGE_ROOT;
253
254
  const runNpmFn = options.runNpmFn ?? runNpmCommand;
254
255
  const extensionProtocol = derivePackageProtocolVersion(options.extensionVersion);
255
- if (!extensionProtocol || !options.supportedVersions.includes(extensionProtocol)) {
256
+ if (
257
+ !extensionProtocol ||
258
+ !isProtocolVersionSupported(extensionProtocol, options.supportedVersions)
259
+ ) {
256
260
  return { updated: false, reason: 'invalid_extension_version' };
257
261
  }
258
262
 
@@ -1,4 +1,5 @@
1
1
  // @ts-check
2
+ import { isProtocolVersionSupported } from '../../protocol/src/index.js';
2
3
 
3
4
  import fs from 'node:fs';
4
5
  import os from 'node:os';
@@ -204,8 +205,8 @@ function withRequestMeta(source, mcpEra, tokenBudget, automaticRetry) {
204
205
  */
205
206
  export async function withBridgeClient(callback, options) {
206
207
  const client = new BridgeClient(await applyConfiguredAutoUpdate(options));
207
- await ensureClientConnected(client);
208
208
  try {
209
+ await ensureClientConnected(client);
209
210
  return await callback(client);
210
211
  } finally {
211
212
  await client.close();
@@ -839,11 +840,11 @@ function summarizeProtocol(health, extensionConnected) {
839
840
  : safeVersions(health?.supported_versions)
840
841
  : [];
841
842
  const daemonCompatible = daemonSupportedVersions.length
842
- ? daemonSupportedVersions.includes(clientVersion)
843
+ ? isProtocolVersionSupported(clientVersion, daemonSupportedVersions)
843
844
  : null;
844
845
  const extensionCompatible = extensionConnected
845
846
  ? extensionSupportedVersions.length
846
- ? extensionSupportedVersions.includes(clientVersion)
847
+ ? isProtocolVersionSupported(clientVersion, extensionSupportedVersions)
847
848
  : null
848
849
  : null;
849
850
  const knownCompatibility = [daemonCompatible, extensionCompatible].filter(
@@ -153,7 +153,7 @@ export function createScreenshotResult(response, method) {
153
153
  return summarizeToolError(new Error(`${method} returned invalid artifact metadata.`));
154
154
  }
155
155
  return createToolResult(
156
- `Captured screenshot artifact (${artifact.byteLength} bytes, expires ${artifact.expiresAt ?? 'soon'}). Read it in chunks with the browser_artifact tool (action "read", paging offset until nextOffset is null), then delete it with browser_artifact action "delete" when done.`,
156
+ `Captured screenshot artifact (${artifact.byteLength} bytes, expires ${artifact.expiresAt ?? 'soon'}). Read it in chunks with browser_call method "artifact.read", paging offset until nextOffset is null, then delete it with browser_call method "artifact.delete" when done.`,
157
157
  {
158
158
  ok: true,
159
159
  method,
@@ -1,6 +1,5 @@
1
1
  // @ts-check
2
2
 
3
- import { createBridgeClientForDestination } from '../../agent-client/src/remotes.js';
4
3
  import {
5
4
  callBridgeTool,
6
5
  createToolResult,
@@ -8,6 +7,7 @@ import {
8
7
  getToolTokenBudget,
9
8
  requestBridgeWithRetry,
10
9
  summarizeToolError,
10
+ withMcpRequestClient,
11
11
  } from './handlers-utils.js';
12
12
 
13
13
  /** @typedef {import('../../protocol/src/types.js').BridgeMethod} BridgeMethod */
@@ -28,23 +28,20 @@ export async function handleTabsTool(args) {
28
28
  }
29
29
  const results = await Promise.all(
30
30
  destinations.map(async (destination) => {
31
- /** @type {import('../../agent-client/src/client.js').BridgeClient | null} */
32
- let client = null;
33
31
  try {
34
- client = await createBridgeClientForDestination(
35
- destination.local ? null : destination.id,
36
- { checkProtocolOnConnect: false }
37
- );
38
- await client.connect();
39
- const response = await requestBridgeWithRetry(
40
- client,
41
- 'tabs.list',
42
- {},
43
- {
44
- tabId: null,
45
- source: 'mcp',
46
- tokenBudget: null,
47
- }
32
+ const response = await withMcpRequestClient(
33
+ (client) =>
34
+ requestBridgeWithRetry(
35
+ client,
36
+ 'tabs.list',
37
+ {},
38
+ {
39
+ tabId: null,
40
+ source: 'mcp',
41
+ tokenBudget: null,
42
+ }
43
+ ),
44
+ { destinationId: destination.local ? null : destination.id }
48
45
  );
49
46
  if (!response.ok) {
50
47
  return {
@@ -67,10 +64,6 @@ export async function handleTabsTool(args) {
67
64
  error: { message: error instanceof Error ? error.message : String(error) },
68
65
  tabs: [],
69
66
  };
70
- } finally {
71
- if (client) {
72
- await client.close().catch(() => {});
73
- }
74
67
  }
75
68
  })
76
69
  );
@@ -13,7 +13,6 @@ import {
13
13
  MAX_BATCH_CONCURRENCY,
14
14
  METHOD_SET,
15
15
  } from '../../protocol/src/index.js';
16
- import { createBridgeClientForDestination } from '../../agent-client/src/remotes.js';
17
16
  import {
18
17
  annotateBridgeSummary,
19
18
  applyLimitBudgetPreset,
@@ -31,6 +30,8 @@ import {
31
30
  summarizeToolError,
32
31
  summarizeToolResponse,
33
32
  withToolClient,
33
+ withMcpRequestClient,
34
+ throwIfMcpRequestCancelled,
34
35
  REQUEST_SOURCE,
35
36
  } from './handlers-utils.js';
36
37
  import { createScreenshotResult } from './handlers-capture.js';
@@ -383,33 +384,23 @@ export async function handleBatchTool(args) {
383
384
  const tokenBudget = getToolTokenBudget(call);
384
385
  const destinationId = typeof call.destinationId === 'string' ? call.destinationId : null;
385
386
  const startTime = Date.now();
386
- /** @type {BridgeClient | null} */
387
- let callClient = null;
388
387
  /** @type {import('../../protocol/src/types.js').BridgeResponse | null} */
389
388
  let response = null;
390
389
  /** @type {unknown} */
391
390
  let callError = null;
392
391
  try {
393
- callClient = await createBridgeClientForDestination(destinationId, {
394
- checkProtocolOnConnect: false,
395
- });
396
- await callClient.connect();
397
392
  const params = applyMethodBudgetPreset(method, call.params || {}, call.budgetPreset);
398
- response = await requestBridgeWithRetry(callClient, method, params, {
399
- tabId,
400
- source: REQUEST_SOURCE,
401
- tokenBudget,
402
- });
393
+ response = await withMcpRequestClient(
394
+ (client) =>
395
+ requestBridgeWithRetry(client, method, params, {
396
+ tabId,
397
+ source: REQUEST_SOURCE,
398
+ tokenBudget,
399
+ }),
400
+ { destinationId }
401
+ );
403
402
  } catch (error) {
404
403
  callError = error;
405
- } finally {
406
- if (callClient) {
407
- try {
408
- await callClient.close();
409
- } catch (error) {
410
- callError ??= error;
411
- }
412
- }
413
404
  }
414
405
  if (callError || !response) {
415
406
  return {
@@ -489,6 +480,7 @@ async function mapWithConcurrency(values, concurrency, callback) {
489
480
  let nextIndex = 0;
490
481
  const worker = async () => {
491
482
  while (nextIndex < values.length) {
483
+ throwIfMcpRequestCancelled();
492
484
  const index = nextIndex;
493
485
  nextIndex += 1;
494
486
  results[index] = await callback(values[index], index);
@@ -537,6 +529,9 @@ export async function handleRawCallTool(args) {
537
529
  if (method === 'sensitive.read') {
538
530
  return createSensitiveReadResult(response);
539
531
  }
532
+ if (method === 'artifact.read') {
533
+ return createArtifactReadResult(response);
534
+ }
540
535
  return summarizeToolResponse(response, method, params);
541
536
  },
542
537
  { destinationId: args.destinationId ?? null }
@@ -729,6 +724,7 @@ export async function handleInvestigateTool(args) {
729
724
  const stepResults = [];
730
725
 
731
726
  for (const step of scope.steps) {
727
+ throwIfMcpRequestCancelled();
732
728
  const startTime = Date.now();
733
729
  try {
734
730
  const response = await requestBridgeWithRetry(client, step.method, step.params(args), {