@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.
@@ -47,17 +47,119 @@ import { annotateBridgeSummary, summarizeBridgeResponse } from '../../agent-clie
47
47
 
48
48
  export const REQUEST_SOURCE = 'mcp';
49
49
  const MCP_CLIENT_ID = `mcp_${randomUUID()}`;
50
- /** @type {AsyncLocalStorage<import('../../protocol/src/types.js').McpProtocolEra>} */
50
+ /** @type {AsyncLocalStorage<{ era: import('../../protocol/src/types.js').McpProtocolEra, signal?: AbortSignal }>} */
51
51
  const MCP_REQUEST_ERA = new AsyncLocalStorage();
52
52
 
53
53
  /**
54
54
  * @template T
55
55
  * @param {import('../../protocol/src/types.js').McpProtocolEra} era
56
56
  * @param {() => T} callback
57
+ * @param {AbortSignal} [signal]
57
58
  * @returns {T}
58
59
  */
59
- export function runWithMcpRequestEra(era, callback) {
60
- return MCP_REQUEST_ERA.run(era, callback);
60
+ export function runWithMcpRequestEra(era, callback, signal) {
61
+ return MCP_REQUEST_ERA.run({ era, signal }, callback);
62
+ }
63
+
64
+ /** @returns {void} */
65
+ export function throwIfMcpRequestCancelled() {
66
+ MCP_REQUEST_ERA.getStore()?.signal?.throwIfAborted();
67
+ }
68
+
69
+ /**
70
+ * Race request-owned work against cancellation and an optional absolute budget.
71
+ * Connection attempts can finish after close(), so dispose late completions too.
72
+ *
73
+ * @template T
74
+ * @param {() => Promise<T>} operation
75
+ * @param {{ timeoutMs?: number, onCancel?: () => void }} [options]
76
+ * @returns {Promise<T>}
77
+ */
78
+ function awaitMcpOperation(operation, options = {}) {
79
+ const signal = MCP_REQUEST_ERA.getStore()?.signal;
80
+ const deadline = options.timeoutMs === undefined ? null : Date.now() + options.timeoutMs;
81
+ return new Promise((resolve, reject) => {
82
+ let settled = false;
83
+ let cancelled = false;
84
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
85
+ let timer;
86
+ const cleanup = () => {
87
+ clearTimeout(timer);
88
+ signal?.removeEventListener('abort', onAbort);
89
+ };
90
+ /** @param {unknown} error */
91
+ const cancel = (error) => {
92
+ if (settled) return;
93
+ settled = true;
94
+ cancelled = true;
95
+ cleanup();
96
+ options.onCancel?.();
97
+ reject(error);
98
+ };
99
+ const onAbort = () => cancel(signal?.reason);
100
+ const onTimeout = () => cancel(new BridgeError('TIMEOUT', 'MCP connection deadline exceeded.'));
101
+ if (signal?.aborted) {
102
+ onAbort();
103
+ return;
104
+ }
105
+ signal?.addEventListener('abort', onAbort, { once: true });
106
+ if (deadline !== null) {
107
+ timer = setTimeout(onTimeout, Math.max(0, deadline - Date.now()));
108
+ }
109
+ Promise.resolve()
110
+ .then(() => {
111
+ if (deadline !== null && Date.now() >= deadline) onTimeout();
112
+ if (settled) return undefined;
113
+ return operation();
114
+ })
115
+ .then(
116
+ (result) => {
117
+ if (cancelled) options.onCancel?.();
118
+ if (!settled && deadline !== null && Date.now() >= deadline) onTimeout();
119
+ if (settled) return;
120
+ settled = true;
121
+ cleanup();
122
+ resolve(/** @type {T} */ (result));
123
+ },
124
+ (error) => {
125
+ if (cancelled) options.onCancel?.();
126
+ if (settled) return;
127
+ settled = true;
128
+ cleanup();
129
+ reject(error);
130
+ }
131
+ );
132
+ });
133
+ }
134
+
135
+ /**
136
+ * @param {number} delayMs
137
+ * @returns {Promise<void>}
138
+ */
139
+ function waitForMcpRequest(delayMs) {
140
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
141
+ let timer;
142
+ return awaitMcpOperation(
143
+ () =>
144
+ new Promise((resolve) => {
145
+ timer = setTimeout(resolve, delayMs);
146
+ }),
147
+ { onCancel: () => clearTimeout(timer) }
148
+ );
149
+ }
150
+
151
+ /**
152
+ * @param {import('../../agent-client/src/client.js').BridgeClient} client
153
+ * @param {number} timeoutMs
154
+ * @returns {Promise<void>}
155
+ */
156
+ function connectMcpClient(client, timeoutMs) {
157
+ return awaitMcpOperation(() => client.connect(), {
158
+ timeoutMs,
159
+ onCancel: () => {
160
+ void client.close().catch(() => {});
161
+ },
162
+ });
61
163
  }
62
164
 
63
165
  /** @type {ReadonlySet<BridgeMethod>} */
@@ -107,7 +209,7 @@ const RETRY_SAFE_METHODS = new Set([
107
209
  export function isRetrySafeBridgeMethod(method, params) {
108
210
  if (method === 'page.get_console' || method === 'page.get_network') {
109
211
  return (
110
- params.clear !== true &&
212
+ (params.clear === undefined || params.clear === false) &&
111
213
  (method !== 'page.get_network' ||
112
214
  params.source !== 'cdp' ||
113
215
  params.capture === undefined ||
@@ -227,27 +329,49 @@ export function summarizeToolError(error) {
227
329
  */
228
330
  export async function withToolClient(callback, options = {}) {
229
331
  try {
230
- if (!options.destinationId) {
231
- return await withBridgeClient(callback, {
232
- checkProtocolOnConnect: false,
233
- clientId: MCP_CLIENT_ID,
234
- });
235
- }
236
- const client = await createBridgeClientForDestination(options.destinationId, {
237
- checkProtocolOnConnect: false,
238
- clientId: MCP_CLIENT_ID,
239
- });
240
- await client.connect();
241
- try {
242
- return await callback(client);
243
- } finally {
244
- await client.close();
245
- }
332
+ return await withMcpRequestClient(callback, options);
246
333
  } catch (error) {
247
334
  return summarizeToolError(error);
248
335
  }
249
336
  }
250
337
 
338
+ /**
339
+ * @template T
340
+ * @param {(client: import('../../agent-client/src/client.js').BridgeClient) => Promise<T>} callback
341
+ * @param {{ destinationId?: string | null }} [options]
342
+ * @returns {Promise<T>}
343
+ */
344
+ export async function withMcpRequestClient(callback, options = {}) {
345
+ throwIfMcpRequestCancelled();
346
+ const client = await createBridgeClientForDestination(options.destinationId, {
347
+ checkProtocolOnConnect: false,
348
+ clientId: MCP_CLIENT_ID,
349
+ });
350
+ let completed = false;
351
+ try {
352
+ const result = await awaitMcpOperation(
353
+ async () => {
354
+ await connectMcpClient(client, client.defaultTimeoutMs);
355
+ throwIfMcpRequestCancelled();
356
+ return callback(client);
357
+ },
358
+ {
359
+ onCancel: () => {
360
+ void client.close().catch(() => {});
361
+ },
362
+ }
363
+ );
364
+ completed = true;
365
+ return result;
366
+ } finally {
367
+ if (completed) {
368
+ await client.close();
369
+ } else {
370
+ await client.close().catch(() => {});
371
+ }
372
+ }
373
+ }
374
+
251
375
  /**
252
376
  * @param {import('../../agent-client/src/client.js').BridgeClient} client
253
377
  * @param {{ elementRef?: string | undefined, selector?: string | undefined }} input
@@ -255,11 +379,33 @@ export async function withToolClient(callback, options = {}) {
255
379
  * @returns {Promise<string>}
256
380
  */
257
381
  export async function resolveToolRef(client, input, tabId = null) {
382
+ throwIfMcpRequestCancelled();
258
383
  if (typeof input.elementRef === 'string' && input.elementRef) {
259
384
  return input.elementRef;
260
385
  }
261
386
  if (typeof input.selector === 'string' && input.selector) {
262
- return resolveRef(client, input.selector, tabId, REQUEST_SOURCE);
387
+ const response = await requestBridgeWithRetry(
388
+ client,
389
+ 'dom.query',
390
+ {
391
+ selector: input.selector,
392
+ },
393
+ { tabId, source: REQUEST_SOURCE }
394
+ );
395
+ if (!response.ok) {
396
+ throw new BridgeError(response.error.code, response.error.message, response.error.details);
397
+ }
398
+ const result = /** @type {{ nodes?: Array<{ elementRef: string }> }} */ (response.result);
399
+ if (!result.nodes?.length) {
400
+ throw new BridgeError(
401
+ 'INVALID_REQUEST',
402
+ `No element found for selector "${input.selector}".`,
403
+ {
404
+ selector: input.selector,
405
+ }
406
+ );
407
+ }
408
+ return result.nodes[0].elementRef;
263
409
  }
264
410
  throw new BridgeError('INVALID_REQUEST', 'Provide either elementRef or selector.');
265
411
  }
@@ -570,7 +716,7 @@ export function isConnectionLossError(error) {
570
716
  * Wait, bounded, for the client to regain its daemon connection.
571
717
  *
572
718
  * When the client's own auto-reconnect loop is active it owns reconnecting and
573
- * emits `reconnected`; otherwise one manual connect is attempted per poll
719
+ * reconnects independently; otherwise one manual connect is attempted per poll
574
720
  * interval. Application state is never touched here - this only restores the
575
721
  * transport.
576
722
  *
@@ -580,26 +726,17 @@ export function isConnectionLossError(error) {
580
726
  */
581
727
  export async function waitForClientReconnect(client, timeoutMs = RECONNECT_WAIT_TIMEOUT_MS) {
582
728
  const deadline = Date.now() + timeoutMs;
729
+ throwIfMcpRequestCancelled();
583
730
  while (!client.connected && Date.now() < deadline) {
584
731
  if (client.autoReconnect) {
585
- await new Promise((resolve) => {
586
- const remaining = Math.max(0, deadline - Date.now());
587
- /** @type {ReturnType<typeof setTimeout>} */
588
- let timer;
589
- const cleanup = () => {
590
- clearTimeout(timer);
591
- client.off('reconnected', onReconnected);
592
- resolve(undefined);
593
- };
594
- const onReconnected = () => cleanup();
595
- timer = setTimeout(cleanup, Math.min(RECONNECT_POLL_INTERVAL_MS, remaining));
596
- client.on('reconnected', onReconnected);
597
- });
732
+ await waitForMcpRequest(Math.min(RECONNECT_POLL_INTERVAL_MS, deadline - Date.now()));
598
733
  continue;
599
734
  }
600
735
  try {
601
- await client.connect();
736
+ await connectMcpClient(client, deadline - Date.now());
602
737
  } catch (error) {
738
+ throwIfMcpRequestCancelled();
739
+ if (error instanceof BridgeError && error.code === 'TIMEOUT') return false;
603
740
  if (error instanceof Error && /already connected/u.test(error.message)) {
604
741
  break;
605
742
  }
@@ -607,9 +744,7 @@ export async function waitForClientReconnect(client, timeoutMs = RECONNECT_WAIT_
607
744
  }
608
745
  if (!client.connected) {
609
746
  const remaining = Math.max(0, deadline - Date.now());
610
- await new Promise((resolve) =>
611
- setTimeout(resolve, Math.min(RECONNECT_POLL_INTERVAL_MS, remaining))
612
- );
747
+ await waitForMcpRequest(Math.min(RECONNECT_POLL_INTERVAL_MS, remaining));
613
748
  }
614
749
  }
615
750
  return client.connected;
@@ -625,13 +760,22 @@ export async function waitForClientReconnect(client, timeoutMs = RECONNECT_WAIT_
625
760
  export async function requestBridgeWithRetry(client, method, params, options) {
626
761
  const requestOptions = {
627
762
  ...options,
628
- mcpEra: options.mcpEra ?? MCP_REQUEST_ERA.getStore(),
763
+ mcpEra: options.mcpEra ?? MCP_REQUEST_ERA.getStore()?.era,
629
764
  };
765
+ /** @param {typeof requestOptions} attemptOptions */
766
+ const attempt = (attemptOptions) =>
767
+ awaitMcpOperation(async () => {
768
+ throwIfMcpRequestCancelled();
769
+ if (!client.connected) await connectMcpClient(client, client.defaultTimeoutMs);
770
+ throwIfMcpRequestCancelled();
771
+ return requestBridge(client, method, params, attemptOptions);
772
+ });
630
773
  /** @type {BridgeResponse} */
631
774
  let response;
632
775
  try {
633
- response = await requestBridge(client, method, params, requestOptions);
776
+ response = await attempt(requestOptions);
634
777
  } catch (error) {
778
+ throwIfMcpRequestCancelled();
635
779
  if (!isConnectionLossError(error) || !isRetrySafeBridgeMethod(method, params)) {
636
780
  throw error;
637
781
  }
@@ -641,7 +785,7 @@ export async function requestBridgeWithRetry(client, method, params, options) {
641
785
  if (!(await waitForClientReconnect(client))) {
642
786
  throw error;
643
787
  }
644
- return requestBridge(client, method, params, {
788
+ return attempt({
645
789
  ...requestOptions,
646
790
  automaticRetry: 'mcp_second_attempt',
647
791
  });
@@ -655,8 +799,8 @@ export async function requestBridgeWithRetry(client, method, params, options) {
655
799
  process.stderr.write(
656
800
  `[bbx-mcp] Retrying ${method} after ${delay}ms (${response.error.code})\n`
657
801
  );
658
- await new Promise((r) => setTimeout(r, delay));
659
- return requestBridge(client, method, params, {
802
+ await waitForMcpRequest(delay);
803
+ return attempt({
660
804
  ...requestOptions,
661
805
  automaticRetry: 'mcp_second_attempt',
662
806
  });
@@ -172,7 +172,8 @@ export function createBridgeMcpServer(options = {}) {
172
172
  */
173
173
  const registerTool = (name, config, handler) => {
174
174
  const wrappedHandler = /** @type {typeof handler} */ (
175
- (args, context) => runWithMcpRequestEra(era, () => handler(args, context))
175
+ (args, context) =>
176
+ runWithMcpRequestEra(era, () => handler(args, context), context.mcpReq.signal)
176
177
  );
177
178
  const registration = server.registerTool(name, config, wrappedHandler);
178
179
  registrations.set(name, registration);
@@ -1,6 +1,6 @@
1
1
  // @ts-check
2
2
 
3
- import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
3
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
 
@@ -11,6 +11,12 @@ export const BRIDGE_AUTH_TOKEN_FILE_ENV = 'BBX_AUTH_TOKEN_FILE';
11
11
  const TOKEN_FILENAME = 'daemon.auth';
12
12
  const EXTENSION_TOKEN_FILENAME = 'daemon.extension.auth';
13
13
  const TOKEN_BYTES = 32;
14
+ /**
15
+ * @typedef {{ tokenPath?: string, readFile?: typeof fs.promises.readFile,
16
+ * writeFile?: typeof fs.promises.writeFile, mkdir?: typeof fs.promises.mkdir,
17
+ * chmod?: typeof fs.promises.chmod, link?: typeof fs.promises.link,
18
+ * unlink?: typeof fs.promises.unlink, randomBytesFn?: typeof randomBytes }} TokenInitOptions
19
+ */
14
20
  const TOKEN_PATTERN = /^[A-Za-z0-9_-]{32,256}$/u;
15
21
  const UUID_TOKEN_PATTERN =
16
22
  /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
@@ -155,14 +161,7 @@ export async function writeBridgeAuthToken(token, options = {}) {
155
161
  }
156
162
 
157
163
  /**
158
- * @param {{
159
- * tokenPath?: string,
160
- * readFile?: typeof fs.promises.readFile,
161
- * writeFile?: typeof fs.promises.writeFile,
162
- * mkdir?: typeof fs.promises.mkdir,
163
- * chmod?: typeof fs.promises.chmod,
164
- * randomBytesFn?: typeof randomBytes
165
- * }} [options={}]
164
+ * @param {TokenInitOptions} [options={}]
166
165
  * @returns {Promise<string>}
167
166
  */
168
167
  export async function ensureBridgeAuthToken(options = {}) {
@@ -171,14 +170,7 @@ export async function ensureBridgeAuthToken(options = {}) {
171
170
  }
172
171
 
173
172
  /**
174
- * @param {{
175
- * tokenPath?: string,
176
- * readFile?: typeof fs.promises.readFile,
177
- * writeFile?: typeof fs.promises.writeFile,
178
- * mkdir?: typeof fs.promises.mkdir,
179
- * chmod?: typeof fs.promises.chmod,
180
- * randomBytesFn?: typeof randomBytes
181
- * }} [options={}]
173
+ * @param {TokenInitOptions} [options={}]
182
174
  * @returns {Promise<string>}
183
175
  */
184
176
  export async function ensureBridgeExtensionAuthToken(options = {}) {
@@ -188,13 +180,7 @@ export async function ensureBridgeExtensionAuthToken(options = {}) {
188
180
 
189
181
  /**
190
182
  * @param {string} tokenPath
191
- * @param {{
192
- * readFile?: typeof fs.promises.readFile,
193
- * writeFile?: typeof fs.promises.writeFile,
194
- * mkdir?: typeof fs.promises.mkdir,
195
- * chmod?: typeof fs.promises.chmod,
196
- * randomBytesFn?: typeof randomBytes
197
- * }} options
183
+ * @param {TokenInitOptions} options
198
184
  * @returns {Promise<string>}
199
185
  */
200
186
  async function ensureStoredBridgeAuthToken(tokenPath, options) {
@@ -202,6 +188,8 @@ async function ensureStoredBridgeAuthToken(tokenPath, options) {
202
188
  const writeFile = options.writeFile ?? fs.promises.writeFile.bind(fs.promises);
203
189
  const mkdir = options.mkdir ?? fs.promises.mkdir.bind(fs.promises);
204
190
  const chmod = options.chmod ?? fs.promises.chmod.bind(fs.promises);
191
+ const link = options.link ?? fs.promises.link.bind(fs.promises);
192
+ const unlink = options.unlink ?? fs.promises.unlink.bind(fs.promises);
205
193
  const randomBytesFn = options.randomBytesFn ?? randomBytes;
206
194
  const existing = await readStoredBridgeAuthToken(tokenPath, readFile);
207
195
  if (existing) {
@@ -210,11 +198,85 @@ async function ensureStoredBridgeAuthToken(tokenPath, options) {
210
198
 
211
199
  const token = randomBytesFn(TOKEN_BYTES).toString('base64url');
212
200
  await mkdir(path.dirname(tokenPath), { recursive: true });
213
- await writeFile(tokenPath, `${token}\n`, { encoding: 'utf8', mode: 0o600 });
214
- if (process.platform !== 'win32') {
215
- await chmod(tokenPath, 0o600).catch(() => {});
201
+ const nonce = randomUUID();
202
+ const temporaryPath = `${tokenPath}.${nonce}.tmp`;
203
+ const ownerPath = `${temporaryPath}.owner`;
204
+ let lockPath = `${tokenPath}.init.lock`;
205
+ let locked = false;
206
+ /** @param {string} filePath */
207
+ const remove = async (filePath) => {
208
+ try {
209
+ await unlink(filePath);
210
+ } catch (error) {
211
+ if (!isMissingFileError(error)) throw error;
212
+ }
213
+ };
214
+ try {
215
+ // Neither the credential nor lock name is visible until its complete contents
216
+ // have been written and closed. Hard links publish without replacing a winner.
217
+ await writeFile(temporaryPath, `${token}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
218
+ await writeFile(ownerPath, `${process.pid}:${nonce}\n`, {
219
+ encoding: 'utf8',
220
+ mode: 0o600,
221
+ flag: 'wx',
222
+ });
223
+ if (process.platform !== 'win32') await chmod(temporaryPath, 0o600).catch(() => {});
224
+ for (let attempt = 0; attempt < 100; attempt += 1) {
225
+ try {
226
+ await link(ownerPath, lockPath);
227
+ locked = true;
228
+ break;
229
+ } catch (error) {
230
+ if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'EEXIST')
231
+ throw error;
232
+ }
233
+ let owner;
234
+ try {
235
+ owner = await readFile(lockPath, 'utf8');
236
+ } catch (error) {
237
+ if (isMissingFileError(error)) continue;
238
+ throw error;
239
+ }
240
+ const match = /^(\d+):([0-9a-f-]{36})\n$/u.exec(owner);
241
+ if (!match || !Number.isSafeInteger(Number(match[1])) || Number(match[1]) <= 0) {
242
+ throw new Error('Bridge auth initialization lock has invalid ownership metadata.');
243
+ }
244
+ try {
245
+ process.kill(Number(match[1]), 0);
246
+ } catch (error) {
247
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ESRCH') {
248
+ // Never unlink a dead owner's lock: two stale-lock cleaners could delete
249
+ // a new winner's lock. All contenders instead follow the same successor.
250
+ lockPath = `${tokenPath}.init-${match[2]}.lock`;
251
+ continue;
252
+ }
253
+ if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'EPERM')
254
+ throw error;
255
+ }
256
+ await new Promise((resolve) => setTimeout(resolve, 10));
257
+ }
258
+ if (!locked) throw new Error('Timed out waiting for bridge auth initialization.');
259
+ const winner = await readStoredBridgeAuthToken(tokenPath, readFile);
260
+ if (winner) return winner;
261
+ await remove(tokenPath);
262
+ try {
263
+ await link(temporaryPath, tokenPath);
264
+ return token;
265
+ } catch (error) {
266
+ if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'EEXIST')
267
+ throw error;
268
+ const published = await readStoredBridgeAuthToken(tokenPath, readFile);
269
+ if (published) return published;
270
+ throw error;
271
+ }
272
+ } finally {
273
+ // Release the lock even if temporary-file cleanup fails.
274
+ try {
275
+ if (locked) await remove(lockPath);
276
+ } finally {
277
+ await Promise.all([remove(temporaryPath), remove(ownerPath)]);
278
+ }
216
279
  }
217
- return token;
218
280
  }
219
281
 
220
282
  /**
@@ -6,7 +6,7 @@ import path from 'node:path';
6
6
  import { promisify } from 'node:util';
7
7
  import { fileURLToPath } from 'node:url';
8
8
 
9
- import { pingExistingDaemon } from './daemon.js';
9
+ import { hasLiveListener, pingExistingDaemon } from './daemon.js';
10
10
  import {
11
11
  applyWindowsTcpTransportDefaults,
12
12
  createSocketBridgeTransport,
@@ -725,7 +725,6 @@ async function removeStaleSocket(transport, rmFn, pingDaemonFn) {
725
725
  if (await safePingDaemon(transport, pingDaemonFn)) {
726
726
  return false;
727
727
  }
728
-
729
728
  try {
730
729
  await fs.promises.access(transport.socketPath);
731
730
  } catch (error) {
@@ -735,6 +734,10 @@ async function removeStaleSocket(transport, rmFn, pingDaemonFn) {
735
734
  throw error;
736
735
  }
737
736
 
737
+ if (await hasLiveListener(transport)) {
738
+ return false;
739
+ }
740
+
738
741
  try {
739
742
  await rmFn(transport.socketPath, { force: true });
740
743
  return true;