@browserbridge/bbx 1.0.1 → 1.2.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 (70) hide show
  1. package/README.md +4 -4
  2. package/package.json +11 -13
  3. package/packages/agent-client/src/cli-helpers.js +33 -0
  4. package/packages/agent-client/src/cli.js +122 -45
  5. package/packages/agent-client/src/client.js +134 -8
  6. package/packages/agent-client/src/command-registry.js +4 -1
  7. package/packages/agent-client/src/detect.js +159 -48
  8. package/packages/agent-client/src/install.js +24 -1
  9. package/packages/agent-client/src/mcp-config.js +29 -10
  10. package/packages/agent-client/src/setup-status.js +12 -4
  11. package/packages/mcp-server/src/bin.js +57 -5
  12. package/packages/mcp-server/src/handlers-capture.js +279 -0
  13. package/packages/mcp-server/src/handlers-dom.js +196 -0
  14. package/packages/mcp-server/src/handlers-navigation.js +79 -0
  15. package/packages/mcp-server/src/handlers-page.js +365 -0
  16. package/packages/mcp-server/src/handlers-utils.js +296 -0
  17. package/packages/mcp-server/src/handlers.js +63 -1159
  18. package/packages/mcp-server/src/server.js +13 -3
  19. package/packages/native-host/bin/bridge-daemon.js +34 -4
  20. package/packages/native-host/bin/install-manifest.js +32 -2
  21. package/packages/native-host/bin/postinstall.js +16 -0
  22. package/packages/native-host/src/config.js +131 -6
  23. package/packages/native-host/src/daemon-logger.js +157 -0
  24. package/packages/native-host/src/daemon-process.js +422 -0
  25. package/packages/native-host/src/daemon.js +322 -77
  26. package/packages/native-host/src/framing.js +131 -11
  27. package/packages/native-host/src/install-manifest.js +121 -7
  28. package/packages/native-host/src/native-host.js +110 -73
  29. package/packages/protocol/src/capabilities.js +4 -0
  30. package/packages/protocol/src/defaults.js +1 -0
  31. package/packages/protocol/src/errors.js +4 -0
  32. package/packages/protocol/src/payload-cost.js +19 -6
  33. package/packages/protocol/src/protocol.js +143 -7
  34. package/packages/protocol/src/registry.js +13 -0
  35. package/packages/protocol/src/summary.js +18 -10
  36. package/packages/protocol/src/types.js +28 -3
  37. package/skills/browser-bridge/SKILL.md +2 -1
  38. package/skills/browser-bridge/references/interaction.md +1 -0
  39. package/skills/browser-bridge/references/protocol.md +2 -1
  40. package/CHANGELOG.md +0 -55
  41. package/assets/banner.jpg +0 -0
  42. package/assets/logo.png +0 -0
  43. package/assets/logo.svg +0 -65
  44. package/docs/api-reference.md +0 -157
  45. package/docs/cli-guide.md +0 -128
  46. package/docs/index.md +0 -25
  47. package/docs/manual-setup.md +0 -140
  48. package/docs/mcp-vs-cli.md +0 -258
  49. package/docs/publishing.md +0 -112
  50. package/docs/quickstart.md +0 -104
  51. package/docs/troubleshooting.md +0 -59
  52. package/docs/unpacked-extension.md +0 -72
  53. package/docs/usage-scenarios.md +0 -136
  54. package/manifest.json +0 -38
  55. package/packages/extension/assets/icon-128.png +0 -0
  56. package/packages/extension/assets/icon-16.png +0 -0
  57. package/packages/extension/assets/icon-32.png +0 -0
  58. package/packages/extension/assets/icon-48.png +0 -0
  59. package/packages/extension/src/background-helpers.js +0 -474
  60. package/packages/extension/src/background-routing.js +0 -89
  61. package/packages/extension/src/background.js +0 -3490
  62. package/packages/extension/src/content-script-helpers.js +0 -282
  63. package/packages/extension/src/content-script.js +0 -2043
  64. package/packages/extension/src/debugger-coordinator.js +0 -188
  65. package/packages/extension/src/sidepanel-helpers.js +0 -104
  66. package/packages/extension/ui/popup.html +0 -35
  67. package/packages/extension/ui/popup.js +0 -298
  68. package/packages/extension/ui/sidepanel.html +0 -102
  69. package/packages/extension/ui/sidepanel.js +0 -1771
  70. package/packages/extension/ui/ui.css +0 -1160
@@ -29,13 +29,24 @@ import {
29
29
  SUPPORTED_VERSIONS,
30
30
  validateBridgeRequest,
31
31
  } from '../../protocol/src/index.js';
32
- import { getSocketPath } from './config.js';
32
+ import {
33
+ createSocketBridgeTransport,
34
+ formatBridgeTransport,
35
+ getBridgeListenTarget,
36
+ getBridgeTransport,
37
+ getSocketPath,
38
+ } from './config.js';
39
+ import { normalizeDaemonLogger } from './daemon-logger.js';
33
40
  import { writeJsonLine } from './framing.js';
34
41
 
42
+ const DAEMON_VERSION = loadDaemonVersion();
43
+
35
44
  /** @typedef {import('../../protocol/src/types.js').BridgeRequest} BridgeRequest */
36
45
  /** @typedef {import('../../protocol/src/types.js').SetupInstallParams} SetupInstallParams */
37
46
  /** @typedef {import('../../protocol/src/types.js').SetupInstallResult} SetupInstallResult */
38
47
  /** @typedef {import('../../protocol/src/types.js').SetupStatus} SetupStatus */
48
+ /** @typedef {import('./config.js').BridgeTransport} BridgeTransport */
49
+ /** @typedef {import('./daemon-logger.js').DaemonLoggerLike} DaemonLoggerLike */
39
50
  /** @typedef {import('node:net').Socket & { __clientId?: string, __extensionId?: string, __browserName?: string, __profileLabel?: string, __accessEnabled?: boolean, __lastActiveAt?: number }} ClientSocket */
40
51
  /** @typedef {{ socket: ClientSocket, timeoutId: NodeJS.Timeout, source?: string, method?: string, targets: Set<ClientSocket>, lastErrorResponse?: import('../../protocol/src/types.js').BridgeResponse }} PendingEntry */
41
52
  /**
@@ -88,6 +99,27 @@ function getVersionNegotiationPayload(requestedVersion) {
88
99
  };
89
100
  }
90
101
 
102
+ /**
103
+ * @returns {string | null}
104
+ */
105
+ function loadDaemonVersion() {
106
+ try {
107
+ const raw = fs.readFileSync(new URL('../../../package.json', import.meta.url), 'utf8');
108
+ const parsed = JSON.parse(raw);
109
+ return parsed && typeof parsed.version === 'string' ? parsed.version : null;
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * @param {string} socketPath
117
+ * @returns {boolean}
118
+ */
119
+ export function isWindowsNamedPipePath(socketPath) {
120
+ return socketPath.startsWith('\\\\.\\pipe\\');
121
+ }
122
+
91
123
  /**
92
124
  * @typedef {{
93
125
  * type?: string,
@@ -109,25 +141,30 @@ function getVersionNegotiationPayload(requestedVersion) {
109
141
  export class BridgeDaemon {
110
142
  /**
111
143
  * @param {{
144
+ * transport?: BridgeTransport,
112
145
  * socketPath?: string,
113
146
  * listenOptions?: import('node:net').ListenOptions | null,
114
147
  * setupStatusLoader?: () => Promise<SetupStatus>,
115
148
  * setupInstaller?: (params: Record<string, unknown>) => Promise<SetupInstallResult>,
116
- * logger?: Pick<Console, 'log' | 'error'>
149
+ * logger?: DaemonLoggerLike | Pick<Console, 'log' | 'error'>
117
150
  * }} [options={}]
118
151
  */
119
152
  constructor({
120
- socketPath = getSocketPath(),
153
+ transport = getBridgeTransport(),
154
+ socketPath = undefined,
121
155
  listenOptions = null,
122
156
  setupStatusLoader = collectSetupStatus,
123
157
  setupInstaller = installSetupTarget,
124
- logger = console,
158
+ logger = undefined,
125
159
  } = {}) {
126
- this.socketPath = socketPath;
127
- this.listenOptions = listenOptions;
160
+ this.transport = socketPath ? createSocketBridgeTransport(socketPath) : transport;
161
+ this.socketPath =
162
+ this.transport.type === 'socket' ? this.transport.socketPath : getSocketPath();
163
+ this.listenOptions = listenOptions ?? getBridgeListenTarget(this.transport);
128
164
  this.setupStatusLoader = setupStatusLoader;
129
165
  this.setupInstaller = setupInstaller;
130
- this.logger = logger;
166
+ /** @type {DaemonLoggerLike} */
167
+ this.logger = normalizeDaemonLogger(logger);
131
168
  /** @type {net.Server | null} */
132
169
  this.server = null;
133
170
  /** @type {net.AddressInfo | string | null} */
@@ -138,11 +175,130 @@ export class BridgeDaemon {
138
175
  this.agentSockets = new Map();
139
176
  /** @type {Map<string, PendingEntry>} */
140
177
  this.pendingRequests = new Map();
178
+ /** @type {Map<ClientSocket, Set<string>>} */
179
+ this.pendingRequestsByOwnerSocket = new Map();
180
+ /** @type {Map<ClientSocket, Set<string>>} */
181
+ this.pendingRequestsByTargetSocket = new Map();
141
182
  this.pendingTimeoutMs = DEFAULT_DAEMON_PENDING_TIMEOUT_MS;
142
183
  /** @type {Record<string, unknown>[]} */
143
184
  this.recentLog = [];
185
+ /** @type {Array<{ extensionId: string, browserName: string | null, profileLabel: string | null, accessEnabled: boolean }> | null} */
186
+ this.connectedExtensionsCache = null;
144
187
  /** @type {Promise<void> | null} */
145
188
  this.stopPromise = null;
189
+ /** @type {number} */
190
+ this.startedAt = 0;
191
+ /** @type {number} */
192
+ this.requestsProcessed = 0;
193
+ /** @type {number} */
194
+ this.requestsFailed = 0;
195
+ /** @type {number} */
196
+ this.totalResponseTimeMs = 0;
197
+ /** @type {Map<string, number>} */
198
+ this.requestStartTimes = new Map();
199
+ }
200
+
201
+ /**
202
+ * @returns {void}
203
+ */
204
+ invalidateConnectedExtensionsCache() {
205
+ this.connectedExtensionsCache = null;
206
+ }
207
+
208
+ /**
209
+ * @param {Map<ClientSocket, Set<string>>} index
210
+ * @param {ClientSocket} socket
211
+ * @param {string} requestId
212
+ * @returns {void}
213
+ */
214
+ addPendingRequestIndex(index, socket, requestId) {
215
+ const requestIds = index.get(socket);
216
+ if (requestIds) {
217
+ requestIds.add(requestId);
218
+ return;
219
+ }
220
+ index.set(socket, new Set([requestId]));
221
+ }
222
+
223
+ /**
224
+ * @param {Map<ClientSocket, Set<string>>} index
225
+ * @param {ClientSocket} socket
226
+ * @param {string} requestId
227
+ * @returns {void}
228
+ */
229
+ removePendingRequestIndex(index, socket, requestId) {
230
+ const requestIds = index.get(socket);
231
+ if (!requestIds) {
232
+ return;
233
+ }
234
+ requestIds.delete(requestId);
235
+ if (requestIds.size === 0) {
236
+ index.delete(socket);
237
+ }
238
+ }
239
+
240
+ /**
241
+ * @param {string} requestId
242
+ * @param {PendingEntry} pending
243
+ * @returns {void}
244
+ */
245
+ trackPendingRequest(requestId, pending) {
246
+ this.pendingRequests.set(requestId, pending);
247
+ this.requestStartTimes.set(requestId, Date.now());
248
+ this.addPendingRequestIndex(this.pendingRequestsByOwnerSocket, pending.socket, requestId);
249
+ for (const targetSocket of pending.targets) {
250
+ this.addPendingRequestIndex(this.pendingRequestsByTargetSocket, targetSocket, requestId);
251
+ }
252
+ }
253
+
254
+ /**
255
+ * @param {string} requestId
256
+ * @param {PendingEntry | undefined} [pending]
257
+ * @returns {PendingEntry | undefined}
258
+ */
259
+ clearPendingRequest(requestId, pending = this.pendingRequests.get(requestId)) {
260
+ if (!pending) {
261
+ return undefined;
262
+ }
263
+ this.pendingRequests.delete(requestId);
264
+ this.requestStartTimes.delete(requestId);
265
+ this.removePendingRequestIndex(this.pendingRequestsByOwnerSocket, pending.socket, requestId);
266
+ for (const targetSocket of pending.targets) {
267
+ this.removePendingRequestIndex(this.pendingRequestsByTargetSocket, targetSocket, requestId);
268
+ }
269
+ return pending;
270
+ }
271
+
272
+ /**
273
+ * @param {string} requestId
274
+ * @param {PendingEntry} pending
275
+ * @param {ClientSocket} targetSocket
276
+ * @returns {void}
277
+ */
278
+ removePendingTarget(requestId, pending, targetSocket) {
279
+ if (!pending.targets.delete(targetSocket)) {
280
+ return;
281
+ }
282
+ this.removePendingRequestIndex(this.pendingRequestsByTargetSocket, targetSocket, requestId);
283
+ }
284
+
285
+ /**
286
+ * @returns {Array<{ extensionId: string, browserName: string | null, profileLabel: string | null, accessEnabled: boolean }>}
287
+ */
288
+ getConnectedExtensionsSnapshot() {
289
+ if (this.connectedExtensionsCache) {
290
+ return this.connectedExtensionsCache;
291
+ }
292
+
293
+ this.connectedExtensionsCache = Array.from(this.extensionSockets.entries()).map(
294
+ ([extensionId, extSocket]) => ({
295
+ extensionId,
296
+ browserName: extSocket.__browserName ?? null,
297
+ profileLabel: extSocket.__profileLabel ?? null,
298
+ accessEnabled: extSocket.__accessEnabled ?? false,
299
+ })
300
+ );
301
+ return this.connectedExtensionsCache;
146
302
  }
147
303
 
148
304
  /**
@@ -160,6 +316,12 @@ export class BridgeDaemon {
160
316
  typeof message.profileLabel === 'string' ? message.profileLabel : undefined;
161
317
  socket.__lastActiveAt = Date.now();
162
318
  this.extensionSockets.set(extensionId, socket);
319
+ this.invalidateConnectedExtensionsCache();
320
+ this.logger.info('extension registered', {
321
+ extensionId,
322
+ browserName: socket.__browserName ?? null,
323
+ profileLabel: socket.__profileLabel ?? null,
324
+ });
163
325
  void writeJsonLine(socket, { type: 'registered', role: 'extension' });
164
326
  return;
165
327
  }
@@ -168,6 +330,7 @@ export class BridgeDaemon {
168
330
  const clientId = message.clientId || randomUUID();
169
331
  this.agentSockets.set(clientId, socket);
170
332
  socket.__clientId = clientId;
333
+ this.logger.info('agent registered', { clientId });
171
334
  void writeJsonLine(socket, {
172
335
  type: 'registered',
173
336
  role: 'agent',
@@ -180,7 +343,7 @@ export class BridgeDaemon {
180
343
  * @returns {Promise<BridgeDaemon>}
181
344
  */
182
345
  async start() {
183
- if (!this.listenOptions) {
346
+ if (this.transport.type === 'socket' && !isWindowsNamedPipePath(this.socketPath)) {
184
347
  const socketDir = path.dirname(this.socketPath);
185
348
  await fs.promises.mkdir(socketDir, { recursive: true });
186
349
  if (process.platform !== 'win32') {
@@ -188,12 +351,14 @@ export class BridgeDaemon {
188
351
  }
189
352
  try {
190
353
  await fs.promises.access(this.socketPath);
191
- if (await pingExistingDaemon(this.socketPath)) {
354
+ if (await pingExistingDaemon(this.transport)) {
192
355
  throw new Error(
193
356
  `Another daemon is already running on ${this.socketPath}. Stop it before starting a new one.`
194
357
  );
195
358
  }
196
- this.logger.log('[daemon] Removing stale socket from previous run:', this.socketPath);
359
+ this.logger.info('Removing stale socket from previous run', {
360
+ socketPath: this.socketPath,
361
+ });
197
362
  } catch (error) {
198
363
  if (error instanceof Error && error.message.startsWith('Another daemon')) {
199
364
  throw error;
@@ -206,15 +371,14 @@ export class BridgeDaemon {
206
371
  this.server = net.createServer((socket) => {
207
372
  const typedSocket = /** @type {ClientSocket} */ (socket);
208
373
  typedSocket.on('error', (err) => {
209
- this.logger.error?.('[daemon] socket error:', err.message);
374
+ this.logger.error('socket error', { message: err.message });
210
375
  });
211
376
  parseJsonLines(typedSocket, (raw) => {
212
377
  const message = /** @type {DaemonMessage} */ (raw);
213
378
  void this.handleClientMessage(typedSocket, message).catch((err) => {
214
- this.logger.error?.(
215
- '[daemon] handler error:',
216
- err instanceof Error ? err.message : String(err)
217
- );
379
+ this.logger.error('handler error', {
380
+ message: err instanceof Error ? err.message : String(err),
381
+ });
218
382
  });
219
383
  });
220
384
  typedSocket.on('close', () => this.handleSocketClose(typedSocket));
@@ -228,17 +392,20 @@ export class BridgeDaemon {
228
392
  this.serverAddress = server.address();
229
393
  resolve(undefined);
230
394
  };
231
- if (this.listenOptions) {
232
- server.listen(this.listenOptions, onListen);
233
- } else {
234
- server.listen(this.socketPath, onListen);
235
- }
395
+ server.listen(this.listenOptions, onListen);
236
396
  });
237
397
 
238
- if (!this.listenOptions && process.platform !== 'win32') {
398
+ if (this.transport.type === 'socket' && process.platform !== 'win32') {
239
399
  await fs.promises.chmod(this.socketPath, 0o600);
240
400
  }
241
401
 
402
+ this.logger.info('Daemon listening', {
403
+ transport: formatBridgeTransport(this.transport),
404
+ socketPath: this.socketPath ?? null,
405
+ });
406
+
407
+ this.startedAt = Date.now();
408
+
242
409
  return this;
243
410
  }
244
411
 
@@ -266,6 +433,8 @@ export class BridgeDaemon {
266
433
  clearTimeout(pending.timeoutId);
267
434
  }
268
435
  this.pendingRequests.clear();
436
+ this.pendingRequestsByOwnerSocket.clear();
437
+ this.pendingRequestsByTargetSocket.clear();
269
438
 
270
439
  for (const socket of this.agentSockets.values()) {
271
440
  socket.destroy();
@@ -276,6 +445,7 @@ export class BridgeDaemon {
276
445
  socket.destroy();
277
446
  }
278
447
  this.extensionSockets.clear();
448
+ this.invalidateConnectedExtensionsCache();
279
449
 
280
450
  if (this.server) {
281
451
  const server = this.server;
@@ -291,7 +461,7 @@ export class BridgeDaemon {
291
461
  });
292
462
  });
293
463
  } finally {
294
- if (!this.listenOptions) {
464
+ if (this.transport.type === 'socket' && !isWindowsNamedPipePath(this.socketPath)) {
295
465
  await fs.promises.rm(this.socketPath, { force: true });
296
466
  }
297
467
  }
@@ -357,9 +527,12 @@ export class BridgeDaemon {
357
527
  if (this.extensionSockets.size === 0) {
358
528
  const response = createSuccess(request.id, {
359
529
  daemon: 'ok',
530
+ daemonVersion: DAEMON_VERSION,
360
531
  extensionConnected: false,
361
532
  socketPath: this.socketPath,
533
+ transport: formatBridgeTransport(this.transport),
362
534
  connectedExtensions: [],
535
+ daemon_supported_versions: SUPPORTED_VERSIONS,
363
536
  ...getVersionNegotiationPayload(request.meta?.protocol_version),
364
537
  });
365
538
  await writeJsonLine(socket, { type: 'agent.response', response });
@@ -375,6 +548,26 @@ export class BridgeDaemon {
375
548
  return;
376
549
  }
377
550
 
551
+ if (request.method === 'daemon.metrics') {
552
+ const now = Date.now();
553
+ const uptimeMs = this.startedAt > 0 ? now - this.startedAt : 0;
554
+ const avgResponseTimeMs =
555
+ this.requestsProcessed > 0
556
+ ? Math.round(this.totalResponseTimeMs / this.requestsProcessed)
557
+ : 0;
558
+ const response = createSuccess(request.id, {
559
+ uptimeMs,
560
+ activeAgents: this.agentSockets.size,
561
+ activeExtensions: this.extensionSockets.size,
562
+ pendingRequests: this.pendingRequests.size,
563
+ requestsProcessed: this.requestsProcessed,
564
+ requestsFailed: this.requestsFailed,
565
+ avgResponseTimeMs,
566
+ });
567
+ await writeJsonLine(socket, { type: 'agent.response', response });
568
+ return;
569
+ }
570
+
378
571
  if (request.method === 'setup.get_status') {
379
572
  const response = createSuccess(request.id, await this.setupStatusLoader(), {
380
573
  method: request.method,
@@ -412,24 +605,38 @@ export class BridgeDaemon {
412
605
  typeof request.meta?.target_profile === 'string' ? request.meta.target_profile : null;
413
606
  const hasExplicitTarget = Boolean(targetBrowser || targetProfile);
414
607
 
415
- let targets = Array.from(this.extensionSockets.values());
416
- if (targetBrowser || targetProfile) {
417
- targets = targets.filter((extSocket) => {
418
- if (targetBrowser && extSocket.__browserName !== targetBrowser) return false;
419
- if (targetProfile && extSocket.__profileLabel !== targetProfile) return false;
420
- return true;
421
- });
422
- } else {
423
- const enabled = targets.filter((extSocket) => extSocket.__accessEnabled);
424
- if (enabled.length > 0) {
425
- targets = enabled;
426
- } else {
427
- const mostRecent = selectMostRecentlyActiveExtension(targets);
428
- if (mostRecent) {
429
- targets = [mostRecent];
608
+ /** @type {ClientSocket[]} */
609
+ const targets = [];
610
+ /** @type {ClientSocket | null} */
611
+ let mostRecent = null;
612
+ for (const extSocket of this.extensionSockets.values()) {
613
+ if (targetBrowser || targetProfile) {
614
+ if (targetBrowser && extSocket.__browserName !== targetBrowser) {
615
+ continue;
616
+ }
617
+ if (targetProfile && extSocket.__profileLabel !== targetProfile) {
618
+ continue;
430
619
  }
620
+ targets.push(extSocket);
621
+ continue;
622
+ }
623
+
624
+ if (extSocket.__accessEnabled) {
625
+ targets.push(extSocket);
626
+ continue;
627
+ }
628
+
629
+ if (
630
+ !mostRecent ||
631
+ (typeof extSocket.__lastActiveAt === 'number' ? extSocket.__lastActiveAt : 0) >
632
+ (typeof mostRecent.__lastActiveAt === 'number' ? mostRecent.__lastActiveAt : 0)
633
+ ) {
634
+ mostRecent = extSocket;
431
635
  }
432
636
  }
637
+ if (!hasExplicitTarget && targets.length === 0 && mostRecent) {
638
+ targets.push(mostRecent);
639
+ }
433
640
 
434
641
  if (targets.length === 0) {
435
642
  const response = createFailure(
@@ -443,7 +650,7 @@ export class BridgeDaemon {
443
650
  return;
444
651
  }
445
652
 
446
- this.pendingRequests.set(request.id, {
653
+ this.trackPendingRequest(request.id, {
447
654
  socket,
448
655
  method: request.method,
449
656
  source: typeof request.meta?.source === 'string' ? request.meta.source : '',
@@ -451,7 +658,8 @@ export class BridgeDaemon {
451
658
  timeoutId: setTimeout(() => {
452
659
  const pending = this.pendingRequests.get(request.id);
453
660
  if (!pending) return;
454
- this.pendingRequests.delete(request.id);
661
+ this.clearPendingRequest(request.id, pending);
662
+ this.recordRequestCompletion(request.id, false);
455
663
  const response = createFailure(
456
664
  request.id,
457
665
  ERROR_CODES.TIMEOUT,
@@ -463,6 +671,12 @@ export class BridgeDaemon {
463
671
  });
464
672
  }, this.pendingTimeoutMs),
465
673
  });
674
+ this.logger.info('request routed', {
675
+ requestId: request.id,
676
+ method: request.method,
677
+ clientId: socket.__clientId ?? null,
678
+ targetCount: targets.length,
679
+ });
466
680
  const broadcastPayload = { type: 'extension.request', request };
467
681
  await Promise.all(targets.map((extSocket) => writeJsonLine(extSocket, broadcastPayload)));
468
682
  }
@@ -496,12 +710,18 @@ export class BridgeDaemon {
496
710
  * @returns {void}
497
711
  */
498
712
  handleExtensionIdentity(socket, message) {
713
+ let changed = false;
499
714
  if (typeof message.browserName === 'string') {
715
+ changed = changed || socket.__browserName !== message.browserName;
500
716
  socket.__browserName = message.browserName;
501
717
  }
502
718
  if (typeof message.profileLabel === 'string') {
719
+ changed = changed || socket.__profileLabel !== message.profileLabel;
503
720
  socket.__profileLabel = message.profileLabel;
504
721
  }
722
+ if (changed) {
723
+ this.invalidateConnectedExtensionsCache();
724
+ }
505
725
  }
506
726
 
507
727
  /**
@@ -510,7 +730,13 @@ export class BridgeDaemon {
510
730
  * @returns {void}
511
731
  */
512
732
  handleExtensionAccessUpdate(socket, message) {
513
- socket.__accessEnabled = Boolean(message.accessEnabled);
733
+ const accessEnabled = Boolean(message.accessEnabled);
734
+ if (socket.__accessEnabled !== accessEnabled) {
735
+ socket.__accessEnabled = accessEnabled;
736
+ this.invalidateConnectedExtensionsCache();
737
+ return;
738
+ }
739
+ socket.__accessEnabled = accessEnabled;
514
740
  }
515
741
 
516
742
  /**
@@ -539,11 +765,12 @@ export class BridgeDaemon {
539
765
  return;
540
766
  }
541
767
 
542
- pending.targets.delete(socket);
768
+ this.removePendingTarget(responseMessage.id, pending, socket);
543
769
 
544
770
  if (responseMessage.ok) {
545
771
  clearTimeout(pending.timeoutId);
546
- this.pendingRequests.delete(responseMessage.id);
772
+ this.clearPendingRequest(responseMessage.id, pending);
773
+ this.recordRequestCompletion(responseMessage.id, true);
547
774
  this.pushLog({
548
775
  at: new Date().toISOString(),
549
776
  method: responseMessage.meta?.method ?? null,
@@ -559,15 +786,11 @@ export class BridgeDaemon {
559
786
  daemon: 'ok',
560
787
  extensionConnected: true,
561
788
  socketPath: this.socketPath,
562
- connectedExtensions: Array.from(this.extensionSockets.entries()).map(
563
- ([_id, extSocket]) => ({
564
- extensionId: _id,
565
- browserName: extSocket.__browserName ?? null,
566
- profileLabel: extSocket.__profileLabel ?? null,
567
- accessEnabled: extSocket.__accessEnabled ?? false,
568
- })
569
- ),
789
+ transport: formatBridgeTransport(this.transport),
790
+ connectedExtensions: this.getConnectedExtensionsSnapshot(),
570
791
  .../** @type {Record<string, unknown>} */ (responseMessage.result),
792
+ daemonVersion: DAEMON_VERSION,
793
+ daemon_supported_versions: SUPPORTED_VERSIONS,
571
794
  },
572
795
  {
573
796
  ...responseMessage.meta,
@@ -595,21 +818,37 @@ export class BridgeDaemon {
595
818
  */
596
819
  handleSocketClose(socket) {
597
820
  if (socket.__extensionId) {
821
+ this.logger.info('extension disconnected', { extensionId: socket.__extensionId });
598
822
  this.extensionSockets.delete(socket.__extensionId);
823
+ this.invalidateConnectedExtensionsCache();
599
824
  }
600
825
 
601
826
  if (socket.__clientId) {
827
+ this.logger.info('agent disconnected', { clientId: socket.__clientId });
602
828
  this.agentSockets.delete(socket.__clientId);
603
829
  }
604
830
 
605
- for (const [id, pending] of this.pendingRequests.entries()) {
606
- if (pending.socket === socket) {
831
+ const ownedRequestIds = this.pendingRequestsByOwnerSocket.get(socket);
832
+ if (ownedRequestIds) {
833
+ for (const id of ownedRequestIds) {
834
+ const pending = this.pendingRequests.get(id);
835
+ if (!pending) {
836
+ continue;
837
+ }
607
838
  clearTimeout(pending.timeoutId);
608
- this.pendingRequests.delete(id);
609
- continue;
839
+ this.clearPendingRequest(id, pending);
840
+ this.recordRequestCompletion(id, false);
610
841
  }
611
- if (pending.targets.has(socket)) {
612
- pending.targets.delete(socket);
842
+ }
843
+
844
+ const targetRequestIds = this.pendingRequestsByTargetSocket.get(socket);
845
+ if (targetRequestIds) {
846
+ for (const id of targetRequestIds) {
847
+ const pending = this.pendingRequests.get(id);
848
+ if (!pending) {
849
+ continue;
850
+ }
851
+ this.removePendingTarget(id, pending, socket);
613
852
  void this.finishPendingRequestIfExhausted(id, pending);
614
853
  }
615
854
  }
@@ -629,7 +868,8 @@ export class BridgeDaemon {
629
868
  }
630
869
 
631
870
  clearTimeout(pending.timeoutId);
632
- this.pendingRequests.delete(requestId);
871
+ this.clearPendingRequest(requestId, pending);
872
+ this.recordRequestCompletion(requestId, false);
633
873
 
634
874
  const response =
635
875
  pending.lastErrorResponse ??
@@ -655,6 +895,22 @@ export class BridgeDaemon {
655
895
  });
656
896
  }
657
897
 
898
+ /**
899
+ * @param {string} requestId
900
+ * @param {boolean} ok
901
+ * @returns {void}
902
+ */
903
+ recordRequestCompletion(requestId, ok) {
904
+ const startedAt = this.requestStartTimes.get(requestId);
905
+ this.requestsProcessed += 1;
906
+ if (!ok) {
907
+ this.requestsFailed += 1;
908
+ }
909
+ if (typeof startedAt === 'number') {
910
+ this.totalResponseTimeMs += Date.now() - startedAt;
911
+ }
912
+ }
913
+
658
914
  /**
659
915
  * @param {Record<string, unknown>} entry
660
916
  * @returns {void}
@@ -668,36 +924,25 @@ export class BridgeDaemon {
668
924
  }
669
925
 
670
926
  /**
671
- * @param {ClientSocket[]} sockets
672
- * @returns {ClientSocket | null}
673
- */
674
- function selectMostRecentlyActiveExtension(sockets) {
675
- if (sockets.length === 0) {
676
- return null;
677
- }
678
-
679
- return sockets.reduce((best, current) => {
680
- const bestAt = typeof best.__lastActiveAt === 'number' ? best.__lastActiveAt : 0;
681
- const currentAt = typeof current.__lastActiveAt === 'number' ? current.__lastActiveAt : 0;
682
- return currentAt > bestAt ? current : best;
683
- });
684
- }
685
-
686
- /**
687
- * Check whether a daemon is already listening on the given socket path.
927
+ * Check whether a daemon is already listening on the given transport.
688
928
  * Connects, sends a health.ping, and waits up to 500 ms for a response.
689
929
  *
690
- * @param {string} socketPath
930
+ * @param {BridgeTransport | string} transport
691
931
  * @returns {Promise<boolean>}
692
932
  */
693
- async function pingExistingDaemon(socketPath) {
933
+ export async function pingExistingDaemon(transport) {
934
+ const resolvedTransport =
935
+ typeof transport === 'string' ? createSocketBridgeTransport(transport) : transport;
694
936
  return new Promise((resolve) => {
695
937
  const timeout = setTimeout(() => {
696
938
  socket.destroy();
697
939
  resolve(false);
698
940
  }, DAEMON_EXISTING_SOCKET_PING_TIMEOUT_MS);
699
941
 
700
- const socket = net.createConnection(socketPath);
942
+ const socket =
943
+ resolvedTransport.type === 'tcp'
944
+ ? net.createConnection({ host: resolvedTransport.host, port: resolvedTransport.port })
945
+ : net.createConnection(resolvedTransport.socketPath);
701
946
  socket.once('error', () => {
702
947
  clearTimeout(timeout);
703
948
  resolve(false);