@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.
@@ -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 net from 'node:net';
@@ -132,6 +133,7 @@ export const DAEMON_REGISTRATION_TIMEOUT_MS = 5_000;
132
133
  export const MAX_DAEMON_CONNECTIONS = 64;
133
134
  export const MAX_DAEMON_PENDING_REQUESTS = 128;
134
135
  export const MAX_DAEMON_PENDING_REQUESTS_PER_CLIENT = 16;
136
+ export const MAX_DAEMON_ACTIVE_HANDLERS_PER_CLIENT = 32;
135
137
  export const MAX_DAEMON_INVALID_LINES = 3;
136
138
  const MAX_CLIENT_ID_LENGTH = 128;
137
139
  const MAX_ROUTING_LABEL_LENGTH = 128;
@@ -360,7 +362,11 @@ function createHealthRecoveryPayload(daemon, extensionResult) {
360
362
  function getVersionNegotiationPayload(requestedVersion) {
361
363
  const supportedVersions = getSupportedProtocolVersions();
362
364
  const latestSupported = supportedVersions[0];
363
- if (!requestedVersion || !latestSupported || supportedVersions.includes(requestedVersion)) {
365
+ if (
366
+ !requestedVersion ||
367
+ !latestSupported ||
368
+ isProtocolVersionSupported(requestedVersion, supportedVersions)
369
+ ) {
364
370
  return { supported_versions: supportedVersions };
365
371
  }
366
372
 
@@ -462,6 +468,11 @@ export class BridgeDaemon {
462
468
  this.extensionSockets = new Map();
463
469
  /** @type {Map<string, ClientSocket>} */
464
470
  this.agentSockets = new Map();
471
+ /** @type {Set<ClientSocket>} */
472
+ this.clientSockets = new Set();
473
+ /** @type {Map<ClientSocket, number>} */
474
+ this.activeHandlers = new Map();
475
+ this.activeHandlerCount = 0;
465
476
  /** @type {Map<string, PendingEntry>} */
466
477
  this.pendingRequests = new Map();
467
478
  /** @type {Map<ClientSocket, Set<string>>} */
@@ -568,7 +579,7 @@ export class BridgeDaemon {
568
579
  pending = this.pendingRequests.get(requestId),
569
580
  preserveArtifacts = false
570
581
  ) {
571
- if (!pending) {
582
+ if (!pending || this.pendingRequests.get(requestId) !== pending) {
572
583
  return undefined;
573
584
  }
574
585
  clearTimeout(pending.timeoutId);
@@ -621,6 +632,7 @@ export class BridgeDaemon {
621
632
  * @returns {void}
622
633
  */
623
634
  registerSocket(socket, message) {
635
+ this.clientSockets.add(socket);
624
636
  if (socket.__role) {
625
637
  void writeJsonLine(socket, {
626
638
  type: 'registration_failed',
@@ -628,7 +640,7 @@ export class BridgeDaemon {
628
640
  code: ERROR_CODES.INVALID_REQUEST,
629
641
  message: `Socket is already registered as ${socket.__role}.`,
630
642
  },
631
- });
643
+ }).catch(() => socket.destroy());
632
644
  return;
633
645
  }
634
646
 
@@ -639,7 +651,7 @@ export class BridgeDaemon {
639
651
  code: ERROR_CODES.INVALID_REQUEST,
640
652
  message: 'Socket role must be "agent" or "extension".',
641
653
  },
642
- });
654
+ }).catch(() => socket.destroy());
643
655
  return;
644
656
  }
645
657
 
@@ -657,7 +669,9 @@ export class BridgeDaemon {
657
669
  code: ERROR_CODES.ACCESS_DENIED,
658
670
  message: 'Bridge daemon authentication failed.',
659
671
  },
660
- }).finally(() => socket.destroy());
672
+ })
673
+ .catch(() => {})
674
+ .finally(() => socket.destroy());
661
675
  return;
662
676
  }
663
677
 
@@ -686,7 +700,9 @@ export class BridgeDaemon {
686
700
  browserName: socket.__browserName ?? null,
687
701
  profileLabel: socket.__profileLabel ?? null,
688
702
  });
689
- void writeJsonLine(socket, { type: 'registered', role: 'extension' });
703
+ void writeJsonLine(socket, { type: 'registered', role: 'extension' }).catch(() =>
704
+ socket.destroy()
705
+ );
690
706
  return;
691
707
  }
692
708
 
@@ -700,7 +716,9 @@ export class BridgeDaemon {
700
716
  code: ERROR_CODES.INVALID_REQUEST,
701
717
  message: 'Agent clientId is invalid.',
702
718
  },
703
- }).finally(() => socket.destroy());
719
+ })
720
+ .catch(() => {})
721
+ .finally(() => socket.destroy());
704
722
  return;
705
723
  }
706
724
  this.agentSockets.set(clientId, socket);
@@ -710,7 +728,7 @@ export class BridgeDaemon {
710
728
  type: 'registered',
711
729
  role: 'agent',
712
730
  clientId,
713
- });
731
+ }).catch(() => socket.destroy());
714
732
  return;
715
733
  }
716
734
  }
@@ -734,7 +752,7 @@ export class BridgeDaemon {
734
752
  }
735
753
  try {
736
754
  await fs.promises.access(this.socketPath);
737
- if (await pingExistingDaemon(this.transport)) {
755
+ if (await hasLiveListener(this.transport)) {
738
756
  throw new Error(
739
757
  `Another daemon is already running on ${this.socketPath}. Stop it before starting a new one.`
740
758
  );
@@ -742,17 +760,17 @@ export class BridgeDaemon {
742
760
  this.logger.info('Removing stale socket from previous run', {
743
761
  socketPath: this.socketPath,
744
762
  });
763
+ await fs.promises.rm(this.socketPath, { force: true });
745
764
  } catch (error) {
746
- if (error instanceof Error && error.message.startsWith('Another daemon')) {
765
+ if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'ENOENT')
747
766
  throw error;
748
- }
749
767
  // Socket does not exist - normal startup.
750
768
  }
751
- await fs.promises.rm(this.socketPath, { force: true });
752
769
  }
753
770
 
754
771
  this.server = net.createServer((socket) => {
755
772
  const typedSocket = /** @type {ClientSocket} */ (socket);
773
+ this.clientSockets.add(typedSocket);
756
774
  typedSocket.__invalidLineCount = 0;
757
775
  typedSocket.__registrationTimeoutId = setTimeout(() => {
758
776
  if (!typedSocket.__role) {
@@ -767,12 +785,37 @@ export class BridgeDaemon {
767
785
  parseJsonLines(
768
786
  typedSocket,
769
787
  (raw) => {
788
+ if (typedSocket.destroyed) return;
770
789
  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),
790
+ const admitted =
791
+ message?.type === 'agent.request' || message?.type === 'extension.setup_status.request';
792
+ const active = this.activeHandlers.get(typedSocket) ?? 0;
793
+ if (
794
+ admitted &&
795
+ (active >= MAX_DAEMON_ACTIVE_HANDLERS_PER_CLIENT ||
796
+ this.activeHandlerCount >= MAX_DAEMON_PENDING_REQUESTS)
797
+ ) {
798
+ typedSocket.destroy();
799
+ return;
800
+ }
801
+ if (admitted) {
802
+ this.activeHandlers.set(typedSocket, active + 1);
803
+ this.activeHandlerCount += 1;
804
+ }
805
+ void this.handleClientMessage(typedSocket, message)
806
+ .catch((err) => {
807
+ this.logger.error('handler error', {
808
+ message: err instanceof Error ? err.message : String(err),
809
+ });
810
+ })
811
+ .finally(() => {
812
+ if (admitted) {
813
+ const remaining = (this.activeHandlers.get(typedSocket) ?? 1) - 1;
814
+ if (remaining) this.activeHandlers.set(typedSocket, remaining);
815
+ else this.activeHandlers.delete(typedSocket);
816
+ this.activeHandlerCount -= 1;
817
+ }
774
818
  });
775
- });
776
819
  },
777
820
  {
778
821
  onProtocolError: (error) => {
@@ -845,6 +888,10 @@ export class BridgeDaemon {
845
888
  this.pendingRequests.clear();
846
889
  this.pendingRequestsByOwnerSocket.clear();
847
890
  this.pendingRequestsByTargetSocket.clear();
891
+ this.requestStartTimes.clear();
892
+
893
+ for (const socket of this.clientSockets) socket.destroy();
894
+ this.clientSockets.clear();
848
895
 
849
896
  for (const socket of this.agentSockets.values()) {
850
897
  socket.destroy();
@@ -864,6 +911,7 @@ export class BridgeDaemon {
864
911
 
865
912
  if (this.server) {
866
913
  const server = this.server;
914
+ const ownedListener = server.listening;
867
915
  this.server = null;
868
916
  try {
869
917
  await new Promise((resolve, reject) => {
@@ -876,7 +924,11 @@ export class BridgeDaemon {
876
924
  });
877
925
  });
878
926
  } finally {
879
- if (this.transport.type === 'socket' && !isWindowsNamedPipePath(this.socketPath)) {
927
+ if (
928
+ ownedListener &&
929
+ this.transport.type === 'socket' &&
930
+ !isWindowsNamedPipePath(this.socketPath)
931
+ ) {
880
932
  await fs.promises.rm(this.socketPath, { force: true });
881
933
  }
882
934
  }
@@ -1321,7 +1373,7 @@ export class BridgeDaemon {
1321
1373
  message: error instanceof Error ? error.message : String(error),
1322
1374
  });
1323
1375
  const pending = this.pendingRequests.get(request.id);
1324
- if (!pending) {
1376
+ if (!pending || !pending.targets.has(target)) {
1325
1377
  return;
1326
1378
  }
1327
1379
  this.removePendingTarget(request.id, pending, target);
@@ -1496,6 +1548,20 @@ export class BridgeDaemon {
1496
1548
  const ownerId = pending.socket.__clientId;
1497
1549
  if (!ownerId) return;
1498
1550
  try {
1551
+ if (
1552
+ message.type === 'extension.artifact.chunk' ||
1553
+ message.type === 'extension.artifact.commit'
1554
+ ) {
1555
+ const transfer = this.artifactStore.transfers.get(String(message.artifactId ?? ''));
1556
+ if (
1557
+ !transfer ||
1558
+ transfer.requestId !== requestId ||
1559
+ transfer.ownerId !== ownerId ||
1560
+ transfer.extensionId !== socket.__extensionId
1561
+ ) {
1562
+ throw new Error('Artifact transfer does not belong to this request and extension.');
1563
+ }
1564
+ }
1499
1565
  if (message.type === 'extension.artifact.begin') {
1500
1566
  this.artifactStore.begin({
1501
1567
  artifactId: String(artifact.artifactId ?? ''),
@@ -1591,6 +1657,7 @@ export class BridgeDaemon {
1591
1657
  }
1592
1658
 
1593
1659
  this.removePendingTarget(responseMessage.id, pending, socket);
1660
+ clearTimeout(pending.timeoutId);
1594
1661
 
1595
1662
  if (responseMessage.ok) {
1596
1663
  const result =
@@ -1636,7 +1703,7 @@ export class BridgeDaemon {
1636
1703
  }
1637
1704
  }
1638
1705
  if (!this.domBaselines.register(baselineId, socket, result.expiresAt)) {
1639
- await this.releaseOrphanDomBaseline(socket, responseMessage);
1706
+ await this.releaseOrphanDomBaseline(socket, responseMessage).catch(() => {});
1640
1707
  pending.lastErrorResponse = createFailure(
1641
1708
  responseMessage.id,
1642
1709
  ERROR_CODES.DOM_BASELINE_INVALIDATED,
@@ -1688,11 +1755,12 @@ export class BridgeDaemon {
1688
1755
  if (baselineId) this.domBaselines.delete(baselineId);
1689
1756
  await this.releaseOrphanDomBaseline(socket, responseMessage).catch(() => {});
1690
1757
  }
1691
- this.clearPendingRequest(responseMessage.id, pending);
1692
- this.recordRequestCompletion(responseMessage.id, false, pending);
1758
+ if (this.clearPendingRequest(responseMessage.id, pending)) {
1759
+ this.recordRequestCompletion(responseMessage.id, false, pending);
1760
+ }
1693
1761
  throw error;
1694
1762
  }
1695
- this.clearPendingRequest(responseMessage.id, pending, true);
1763
+ if (!this.clearPendingRequest(responseMessage.id, pending, true)) return;
1696
1764
  this.recordRequestCompletion(responseMessage.id, true, pending);
1697
1765
  this.pushLog({
1698
1766
  at: new Date().toISOString(),
@@ -1719,6 +1787,7 @@ export class BridgeDaemon {
1719
1787
  * @returns {void}
1720
1788
  */
1721
1789
  handleSocketClose(socket) {
1790
+ this.clientSockets.delete(socket);
1722
1791
  if (socket.__registrationTimeoutId) {
1723
1792
  clearTimeout(socket.__registrationTimeoutId);
1724
1793
  socket.__registrationTimeoutId = undefined;
@@ -1781,7 +1850,7 @@ export class BridgeDaemon {
1781
1850
  * @returns {Promise<void>}
1782
1851
  */
1783
1852
  async finishPendingRequestIfExhausted(requestId, pending) {
1784
- if (pending.targets.size > 0 || !this.pendingRequests.has(requestId)) {
1853
+ if (pending.targets.size > 0 || this.pendingRequests.get(requestId) !== pending) {
1785
1854
  return;
1786
1855
  }
1787
1856
 
@@ -1975,6 +2044,37 @@ export async function pingExistingDaemon(transport) {
1975
2044
  });
1976
2045
  }
1977
2046
 
2047
+ /**
2048
+ * Only a refused or missing endpoint proves a Unix socket is stale.
2049
+ * @param {BridgeTransport} transport
2050
+ * @returns {Promise<boolean>}
2051
+ */
2052
+ export function hasLiveListener(transport) {
2053
+ return new Promise((resolve, reject) => {
2054
+ const socket =
2055
+ transport.type === 'tcp'
2056
+ ? net.createConnection({ host: transport.host, port: transport.port })
2057
+ : net.createConnection(transport.socketPath);
2058
+ const timer = setTimeout(() => finish(true), DAEMON_EXISTING_SOCKET_PING_TIMEOUT_MS);
2059
+ /** @param {boolean} live @param {Error} [error] */
2060
+ function finish(live, error) {
2061
+ clearTimeout(timer);
2062
+ socket.destroy();
2063
+ if (error) reject(error);
2064
+ else resolve(live);
2065
+ }
2066
+ socket.once('connect', () => finish(true));
2067
+ socket.once('error', (error) => {
2068
+ if (
2069
+ 'code' in error &&
2070
+ (error.code === 'ENOENT' || error.code === 'ECONNREFUSED' || error.code === 'ENOTSOCK')
2071
+ )
2072
+ finish(false);
2073
+ else finish(true, error);
2074
+ });
2075
+ });
2076
+ }
2077
+
1978
2078
  /**
1979
2079
  * @param {Record<string, unknown>} params
1980
2080
  * @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
@@ -133,6 +133,20 @@ export function deriveProtocolVersion(packageVersion) {
133
133
  return match ? `${match[1]}.${match[2]}` : '0.0';
134
134
  }
135
135
 
136
+ /**
137
+ * Minor and patch releases are compatible in either direction within a major.
138
+ * @param {string} requestedVersion
139
+ * @param {readonly string[]} supportedVersions
140
+ * @returns {boolean}
141
+ */
142
+ export function isProtocolVersionSupported(requestedVersion, supportedVersions) {
143
+ const major = /^(\d+)\.\d+$/.exec(requestedVersion)?.[1];
144
+ return (
145
+ major !== undefined &&
146
+ supportedVersions.some((version) => /^(\d+)\.\d+$/.exec(version)?.[1] === major)
147
+ );
148
+ }
149
+
136
150
  /**
137
151
  * @returns {string | null}
138
152
  */