@browserbridge/bbx 1.11.1 → 1.11.2

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.
@@ -132,6 +132,7 @@ export const DAEMON_REGISTRATION_TIMEOUT_MS = 5_000;
132
132
  export const MAX_DAEMON_CONNECTIONS = 64;
133
133
  export const MAX_DAEMON_PENDING_REQUESTS = 128;
134
134
  export const MAX_DAEMON_PENDING_REQUESTS_PER_CLIENT = 16;
135
+ export const MAX_DAEMON_ACTIVE_HANDLERS_PER_CLIENT = 32;
135
136
  export const MAX_DAEMON_INVALID_LINES = 3;
136
137
  const MAX_CLIENT_ID_LENGTH = 128;
137
138
  const MAX_ROUTING_LABEL_LENGTH = 128;
@@ -462,6 +463,11 @@ export class BridgeDaemon {
462
463
  this.extensionSockets = new Map();
463
464
  /** @type {Map<string, ClientSocket>} */
464
465
  this.agentSockets = new Map();
466
+ /** @type {Set<ClientSocket>} */
467
+ this.clientSockets = new Set();
468
+ /** @type {Map<ClientSocket, number>} */
469
+ this.activeHandlers = new Map();
470
+ this.activeHandlerCount = 0;
465
471
  /** @type {Map<string, PendingEntry>} */
466
472
  this.pendingRequests = new Map();
467
473
  /** @type {Map<ClientSocket, Set<string>>} */
@@ -568,7 +574,7 @@ export class BridgeDaemon {
568
574
  pending = this.pendingRequests.get(requestId),
569
575
  preserveArtifacts = false
570
576
  ) {
571
- if (!pending) {
577
+ if (!pending || this.pendingRequests.get(requestId) !== pending) {
572
578
  return undefined;
573
579
  }
574
580
  clearTimeout(pending.timeoutId);
@@ -621,6 +627,7 @@ export class BridgeDaemon {
621
627
  * @returns {void}
622
628
  */
623
629
  registerSocket(socket, message) {
630
+ this.clientSockets.add(socket);
624
631
  if (socket.__role) {
625
632
  void writeJsonLine(socket, {
626
633
  type: 'registration_failed',
@@ -628,7 +635,7 @@ export class BridgeDaemon {
628
635
  code: ERROR_CODES.INVALID_REQUEST,
629
636
  message: `Socket is already registered as ${socket.__role}.`,
630
637
  },
631
- });
638
+ }).catch(() => socket.destroy());
632
639
  return;
633
640
  }
634
641
 
@@ -639,7 +646,7 @@ export class BridgeDaemon {
639
646
  code: ERROR_CODES.INVALID_REQUEST,
640
647
  message: 'Socket role must be "agent" or "extension".',
641
648
  },
642
- });
649
+ }).catch(() => socket.destroy());
643
650
  return;
644
651
  }
645
652
 
@@ -657,7 +664,9 @@ export class BridgeDaemon {
657
664
  code: ERROR_CODES.ACCESS_DENIED,
658
665
  message: 'Bridge daemon authentication failed.',
659
666
  },
660
- }).finally(() => socket.destroy());
667
+ })
668
+ .catch(() => {})
669
+ .finally(() => socket.destroy());
661
670
  return;
662
671
  }
663
672
 
@@ -686,7 +695,9 @@ export class BridgeDaemon {
686
695
  browserName: socket.__browserName ?? null,
687
696
  profileLabel: socket.__profileLabel ?? null,
688
697
  });
689
- void writeJsonLine(socket, { type: 'registered', role: 'extension' });
698
+ void writeJsonLine(socket, { type: 'registered', role: 'extension' }).catch(() =>
699
+ socket.destroy()
700
+ );
690
701
  return;
691
702
  }
692
703
 
@@ -700,7 +711,9 @@ export class BridgeDaemon {
700
711
  code: ERROR_CODES.INVALID_REQUEST,
701
712
  message: 'Agent clientId is invalid.',
702
713
  },
703
- }).finally(() => socket.destroy());
714
+ })
715
+ .catch(() => {})
716
+ .finally(() => socket.destroy());
704
717
  return;
705
718
  }
706
719
  this.agentSockets.set(clientId, socket);
@@ -710,7 +723,7 @@ export class BridgeDaemon {
710
723
  type: 'registered',
711
724
  role: 'agent',
712
725
  clientId,
713
- });
726
+ }).catch(() => socket.destroy());
714
727
  return;
715
728
  }
716
729
  }
@@ -734,7 +747,7 @@ export class BridgeDaemon {
734
747
  }
735
748
  try {
736
749
  await fs.promises.access(this.socketPath);
737
- if (await pingExistingDaemon(this.transport)) {
750
+ if (await hasLiveListener(this.transport)) {
738
751
  throw new Error(
739
752
  `Another daemon is already running on ${this.socketPath}. Stop it before starting a new one.`
740
753
  );
@@ -742,17 +755,17 @@ export class BridgeDaemon {
742
755
  this.logger.info('Removing stale socket from previous run', {
743
756
  socketPath: this.socketPath,
744
757
  });
758
+ await fs.promises.rm(this.socketPath, { force: true });
745
759
  } catch (error) {
746
- if (error instanceof Error && error.message.startsWith('Another daemon')) {
760
+ if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'ENOENT')
747
761
  throw error;
748
- }
749
762
  // Socket does not exist - normal startup.
750
763
  }
751
- await fs.promises.rm(this.socketPath, { force: true });
752
764
  }
753
765
 
754
766
  this.server = net.createServer((socket) => {
755
767
  const typedSocket = /** @type {ClientSocket} */ (socket);
768
+ this.clientSockets.add(typedSocket);
756
769
  typedSocket.__invalidLineCount = 0;
757
770
  typedSocket.__registrationTimeoutId = setTimeout(() => {
758
771
  if (!typedSocket.__role) {
@@ -767,12 +780,37 @@ export class BridgeDaemon {
767
780
  parseJsonLines(
768
781
  typedSocket,
769
782
  (raw) => {
783
+ if (typedSocket.destroyed) return;
770
784
  const message = /** @type {DaemonMessage} */ (raw);
771
- void this.handleClientMessage(typedSocket, message).catch((err) => {
772
- this.logger.error('handler error', {
773
- message: err instanceof Error ? err.message : String(err),
785
+ const admitted =
786
+ message?.type === 'agent.request' || message?.type === 'extension.setup_status.request';
787
+ const active = this.activeHandlers.get(typedSocket) ?? 0;
788
+ if (
789
+ admitted &&
790
+ (active >= MAX_DAEMON_ACTIVE_HANDLERS_PER_CLIENT ||
791
+ this.activeHandlerCount >= MAX_DAEMON_PENDING_REQUESTS)
792
+ ) {
793
+ typedSocket.destroy();
794
+ return;
795
+ }
796
+ if (admitted) {
797
+ this.activeHandlers.set(typedSocket, active + 1);
798
+ this.activeHandlerCount += 1;
799
+ }
800
+ void this.handleClientMessage(typedSocket, message)
801
+ .catch((err) => {
802
+ this.logger.error('handler error', {
803
+ message: err instanceof Error ? err.message : String(err),
804
+ });
805
+ })
806
+ .finally(() => {
807
+ if (admitted) {
808
+ const remaining = (this.activeHandlers.get(typedSocket) ?? 1) - 1;
809
+ if (remaining) this.activeHandlers.set(typedSocket, remaining);
810
+ else this.activeHandlers.delete(typedSocket);
811
+ this.activeHandlerCount -= 1;
812
+ }
774
813
  });
775
- });
776
814
  },
777
815
  {
778
816
  onProtocolError: (error) => {
@@ -845,6 +883,10 @@ export class BridgeDaemon {
845
883
  this.pendingRequests.clear();
846
884
  this.pendingRequestsByOwnerSocket.clear();
847
885
  this.pendingRequestsByTargetSocket.clear();
886
+ this.requestStartTimes.clear();
887
+
888
+ for (const socket of this.clientSockets) socket.destroy();
889
+ this.clientSockets.clear();
848
890
 
849
891
  for (const socket of this.agentSockets.values()) {
850
892
  socket.destroy();
@@ -864,6 +906,7 @@ export class BridgeDaemon {
864
906
 
865
907
  if (this.server) {
866
908
  const server = this.server;
909
+ const ownedListener = server.listening;
867
910
  this.server = null;
868
911
  try {
869
912
  await new Promise((resolve, reject) => {
@@ -876,7 +919,11 @@ export class BridgeDaemon {
876
919
  });
877
920
  });
878
921
  } finally {
879
- if (this.transport.type === 'socket' && !isWindowsNamedPipePath(this.socketPath)) {
922
+ if (
923
+ ownedListener &&
924
+ this.transport.type === 'socket' &&
925
+ !isWindowsNamedPipePath(this.socketPath)
926
+ ) {
880
927
  await fs.promises.rm(this.socketPath, { force: true });
881
928
  }
882
929
  }
@@ -1321,7 +1368,7 @@ export class BridgeDaemon {
1321
1368
  message: error instanceof Error ? error.message : String(error),
1322
1369
  });
1323
1370
  const pending = this.pendingRequests.get(request.id);
1324
- if (!pending) {
1371
+ if (!pending || !pending.targets.has(target)) {
1325
1372
  return;
1326
1373
  }
1327
1374
  this.removePendingTarget(request.id, pending, target);
@@ -1496,6 +1543,20 @@ export class BridgeDaemon {
1496
1543
  const ownerId = pending.socket.__clientId;
1497
1544
  if (!ownerId) return;
1498
1545
  try {
1546
+ if (
1547
+ message.type === 'extension.artifact.chunk' ||
1548
+ message.type === 'extension.artifact.commit'
1549
+ ) {
1550
+ const transfer = this.artifactStore.transfers.get(String(message.artifactId ?? ''));
1551
+ if (
1552
+ !transfer ||
1553
+ transfer.requestId !== requestId ||
1554
+ transfer.ownerId !== ownerId ||
1555
+ transfer.extensionId !== socket.__extensionId
1556
+ ) {
1557
+ throw new Error('Artifact transfer does not belong to this request and extension.');
1558
+ }
1559
+ }
1499
1560
  if (message.type === 'extension.artifact.begin') {
1500
1561
  this.artifactStore.begin({
1501
1562
  artifactId: String(artifact.artifactId ?? ''),
@@ -1591,6 +1652,7 @@ export class BridgeDaemon {
1591
1652
  }
1592
1653
 
1593
1654
  this.removePendingTarget(responseMessage.id, pending, socket);
1655
+ clearTimeout(pending.timeoutId);
1594
1656
 
1595
1657
  if (responseMessage.ok) {
1596
1658
  const result =
@@ -1636,7 +1698,7 @@ export class BridgeDaemon {
1636
1698
  }
1637
1699
  }
1638
1700
  if (!this.domBaselines.register(baselineId, socket, result.expiresAt)) {
1639
- await this.releaseOrphanDomBaseline(socket, responseMessage);
1701
+ await this.releaseOrphanDomBaseline(socket, responseMessage).catch(() => {});
1640
1702
  pending.lastErrorResponse = createFailure(
1641
1703
  responseMessage.id,
1642
1704
  ERROR_CODES.DOM_BASELINE_INVALIDATED,
@@ -1688,11 +1750,12 @@ export class BridgeDaemon {
1688
1750
  if (baselineId) this.domBaselines.delete(baselineId);
1689
1751
  await this.releaseOrphanDomBaseline(socket, responseMessage).catch(() => {});
1690
1752
  }
1691
- this.clearPendingRequest(responseMessage.id, pending);
1692
- this.recordRequestCompletion(responseMessage.id, false, pending);
1753
+ if (this.clearPendingRequest(responseMessage.id, pending)) {
1754
+ this.recordRequestCompletion(responseMessage.id, false, pending);
1755
+ }
1693
1756
  throw error;
1694
1757
  }
1695
- this.clearPendingRequest(responseMessage.id, pending, true);
1758
+ if (!this.clearPendingRequest(responseMessage.id, pending, true)) return;
1696
1759
  this.recordRequestCompletion(responseMessage.id, true, pending);
1697
1760
  this.pushLog({
1698
1761
  at: new Date().toISOString(),
@@ -1719,6 +1782,7 @@ export class BridgeDaemon {
1719
1782
  * @returns {void}
1720
1783
  */
1721
1784
  handleSocketClose(socket) {
1785
+ this.clientSockets.delete(socket);
1722
1786
  if (socket.__registrationTimeoutId) {
1723
1787
  clearTimeout(socket.__registrationTimeoutId);
1724
1788
  socket.__registrationTimeoutId = undefined;
@@ -1781,7 +1845,7 @@ export class BridgeDaemon {
1781
1845
  * @returns {Promise<void>}
1782
1846
  */
1783
1847
  async finishPendingRequestIfExhausted(requestId, pending) {
1784
- if (pending.targets.size > 0 || !this.pendingRequests.has(requestId)) {
1848
+ if (pending.targets.size > 0 || this.pendingRequests.get(requestId) !== pending) {
1785
1849
  return;
1786
1850
  }
1787
1851
 
@@ -1975,6 +2039,37 @@ export async function pingExistingDaemon(transport) {
1975
2039
  });
1976
2040
  }
1977
2041
 
2042
+ /**
2043
+ * Only a refused or missing endpoint proves a Unix socket is stale.
2044
+ * @param {BridgeTransport} transport
2045
+ * @returns {Promise<boolean>}
2046
+ */
2047
+ export function hasLiveListener(transport) {
2048
+ return new Promise((resolve, reject) => {
2049
+ const socket =
2050
+ transport.type === 'tcp'
2051
+ ? net.createConnection({ host: transport.host, port: transport.port })
2052
+ : net.createConnection(transport.socketPath);
2053
+ const timer = setTimeout(() => finish(true), DAEMON_EXISTING_SOCKET_PING_TIMEOUT_MS);
2054
+ /** @param {boolean} live @param {Error} [error] */
2055
+ function finish(live, error) {
2056
+ clearTimeout(timer);
2057
+ socket.destroy();
2058
+ if (error) reject(error);
2059
+ else resolve(live);
2060
+ }
2061
+ socket.once('connect', () => finish(true));
2062
+ socket.once('error', (error) => {
2063
+ if (
2064
+ 'code' in error &&
2065
+ (error.code === 'ENOENT' || error.code === 'ECONNREFUSED' || error.code === 'ENOTSOCK')
2066
+ )
2067
+ finish(false);
2068
+ else finish(true, error);
2069
+ });
2070
+ });
2071
+ }
2072
+
1978
2073
  /**
1979
2074
  * @param {Record<string, unknown>} params
1980
2075
  * @returns {SetupInstallParams & { action: 'install' | 'uninstall', kind: 'mcp' | 'skill', target: string }}
@@ -1,9 +1,111 @@
1
1
  // @ts-check
2
2
 
3
- import { once } from 'node:events';
4
-
5
3
  import { MAX_JSON_LINE_BYTES, MAX_NATIVE_MESSAGE_BYTES } from '../../protocol/src/index.js';
6
4
 
5
+ export const MAX_OUTPUT_QUEUE_BYTES = 4 * MAX_JSON_LINE_BYTES;
6
+ export const MAX_OUTPUT_QUEUE_MESSAGES = 128;
7
+ export const OUTPUT_DRAIN_TIMEOUT_MS = 30_000;
8
+
9
+ /** @typedef {NodeJS.WritableStream & { destroyed?: boolean, writableEnded?: boolean, destroy?: () => unknown }} OutputStream */
10
+ /** @typedef {{ parts: Array<string | Buffer>, bytes: number, resolve: () => void, reject: (error: Error) => void }} QueuedWrite */
11
+
12
+ /** @param {OutputStream} stream @returns {(parts: Array<string | Buffer>) => Promise<void>} */
13
+ function createBoundedWriter(stream) {
14
+ /** @type {QueuedWrite[]} */
15
+ const queue = [];
16
+ let bytes = 0;
17
+ let running = false;
18
+ /** @type {Error | null} */
19
+ let failure = null;
20
+ /** @type {((error: Error) => void) | null} */
21
+ let cancelDrain = null;
22
+
23
+ /** @param {Error} error */
24
+ function fail(error) {
25
+ failure ??= error;
26
+ cancelDrain?.(error);
27
+ for (const entry of queue.splice(0)) entry.reject(error);
28
+ bytes = 0;
29
+ stream.destroy?.();
30
+ }
31
+
32
+ async function pump() {
33
+ if (running) return;
34
+ running = true;
35
+ try {
36
+ while (queue.length && !failure) {
37
+ const entry = queue[0];
38
+ for (const part of entry.parts) {
39
+ if (failure) throw failure;
40
+ if (stream.destroyed || stream.writableEnded) throw new Error('Output stream closed.');
41
+ if (!stream.write(part)) {
42
+ await new Promise((resolve, reject) => {
43
+ const timer = setTimeout(
44
+ () => finish(new Error('Output drain timed out.')),
45
+ OUTPUT_DRAIN_TIMEOUT_MS
46
+ );
47
+ timer.unref?.();
48
+ /** @param {Error} [error] */
49
+ function finish(error) {
50
+ clearTimeout(timer);
51
+ stream.removeListener('drain', drained);
52
+ stream.removeListener('close', closed);
53
+ stream.removeListener('error', finish);
54
+ cancelDrain = null;
55
+ if (error) reject(error);
56
+ else resolve(undefined);
57
+ }
58
+ const drained = () => finish();
59
+ const closed = () => finish(new Error('Output stream closed while writing.'));
60
+ cancelDrain = finish;
61
+ stream.once('drain', drained);
62
+ stream.once('close', closed);
63
+ stream.once('error', finish);
64
+ });
65
+ }
66
+ }
67
+ if (failure) break;
68
+ queue.shift();
69
+ bytes -= entry.bytes;
70
+ entry.resolve();
71
+ }
72
+ } catch (error) {
73
+ fail(error instanceof Error ? error : new Error(String(error)));
74
+ } finally {
75
+ running = false;
76
+ }
77
+ }
78
+
79
+ return (parts) => {
80
+ if (failure) return Promise.reject(failure);
81
+ const size = parts.reduce((total, part) => total + Buffer.byteLength(part), 0);
82
+ if (queue.length >= MAX_OUTPUT_QUEUE_MESSAGES || bytes + size > MAX_OUTPUT_QUEUE_BYTES) {
83
+ const error = new Error('Output queue limit exceeded.');
84
+ fail(error);
85
+ return Promise.reject(error);
86
+ }
87
+ const result = new Promise((resolve, reject) => {
88
+ queue.push({ parts, bytes: size, resolve: () => resolve(undefined), reject });
89
+ bytes += size;
90
+ });
91
+ void pump();
92
+ return result;
93
+ };
94
+ }
95
+
96
+ /** @type {WeakMap<OutputStream, ReturnType<typeof createBoundedWriter>>} */
97
+ const outputWriters = new WeakMap();
98
+
99
+ /** @param {OutputStream} stream @param {Array<string | Buffer>} parts */
100
+ function writeParts(stream, parts) {
101
+ let writer = outputWriters.get(stream);
102
+ if (!writer) {
103
+ writer = createBoundedWriter(stream);
104
+ outputWriters.set(stream, writer);
105
+ }
106
+ return writer(parts);
107
+ }
108
+
7
109
  /**
8
110
  * @param {NodeJS.WritableStream} stream
9
111
  * @param {unknown} message
@@ -16,12 +118,7 @@ export async function writeNativeMessage(stream, message) {
16
118
  }
17
119
  const header = Buffer.alloc(4);
18
120
  header.writeUInt32LE(payload.length, 0);
19
- if (!stream.write(header)) {
20
- await once(stream, 'drain');
21
- }
22
- if (!stream.write(payload)) {
23
- await once(stream, 'drain');
24
- }
121
+ await writeParts(stream, [header, payload]);
25
122
  }
26
123
 
27
124
  /**
@@ -29,12 +126,7 @@ export async function writeNativeMessage(stream, message) {
29
126
  * @returns {(message: unknown) => Promise<void>}
30
127
  */
31
128
  export function createNativeMessageWriter(stream) {
32
- let queue = Promise.resolve();
33
- return (message) => {
34
- const writePromise = queue.then(() => writeNativeMessage(stream, message));
35
- queue = writePromise.catch(() => {});
36
- return writePromise;
37
- };
129
+ return (message) => writeNativeMessage(stream, message);
38
130
  }
39
131
 
40
132
  /**
@@ -198,7 +290,5 @@ export async function writeJsonLine(socket, message) {
198
290
  if (byteLength > MAX_JSON_LINE_BYTES) {
199
291
  throw new Error(`JSON line exceeds ${MAX_JSON_LINE_BYTES} bytes: ${byteLength}`);
200
292
  }
201
- if (!socket.write(line)) {
202
- await once(socket, 'drain');
203
- }
293
+ await writeParts(socket, [line]);
204
294
  }
@@ -173,6 +173,7 @@ export async function runNativeHost({
173
173
  parseJsonLines(
174
174
  socket,
175
175
  (raw) => {
176
+ if (socket.destroyed) return;
176
177
  const message = /** @type {Record<string, unknown>} */ (raw);
177
178
  void (async () => {
178
179
  if (message.type === 'extension.request') {
@@ -201,6 +202,7 @@ export async function runNativeHost({
201
202
  });
202
203
  }
203
204
  })().catch((err) => {
205
+ socket.destroy();
204
206
  console.error(
205
207
  'native-host: socket message handler failed:',
206
208
  err instanceof Error ? err.message : err
@@ -220,6 +222,7 @@ export async function runNativeHost({
220
222
  createNativeMessageReader(
221
223
  process.stdin,
222
224
  (message) => {
225
+ if (socket.destroyed) return;
223
226
  void (async () => {
224
227
  if (isHostBridgeRequest(message)) {
225
228
  await writeJsonLine(socket, {
@@ -271,6 +274,7 @@ export async function runNativeHost({
271
274
  response: message,
272
275
  });
273
276
  })().catch((err) => {
277
+ socket.destroy();
274
278
  console.error(
275
279
  'native-host: stdin message handler failed:',
276
280
  err instanceof Error ? err.message : err