@browserbridge/bbx 1.0.1 → 1.1.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 (63) 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 +116 -41
  5. package/packages/agent-client/src/client.js +29 -4
  6. package/packages/agent-client/src/command-registry.js +3 -0
  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.js +28 -7
  13. package/packages/mcp-server/src/server.js +12 -2
  14. package/packages/native-host/bin/bridge-daemon.js +33 -4
  15. package/packages/native-host/bin/install-manifest.js +24 -2
  16. package/packages/native-host/src/config.js +131 -6
  17. package/packages/native-host/src/daemon-process.js +396 -0
  18. package/packages/native-host/src/daemon.js +217 -68
  19. package/packages/native-host/src/framing.js +131 -11
  20. package/packages/native-host/src/install-manifest.js +121 -7
  21. package/packages/native-host/src/native-host.js +110 -73
  22. package/packages/protocol/src/capabilities.js +3 -0
  23. package/packages/protocol/src/defaults.js +1 -0
  24. package/packages/protocol/src/errors.js +4 -0
  25. package/packages/protocol/src/payload-cost.js +19 -6
  26. package/packages/protocol/src/protocol.js +143 -7
  27. package/packages/protocol/src/registry.js +11 -0
  28. package/packages/protocol/src/summary.js +18 -10
  29. package/packages/protocol/src/types.js +28 -3
  30. package/skills/browser-bridge/SKILL.md +2 -1
  31. package/skills/browser-bridge/references/interaction.md +1 -0
  32. package/skills/browser-bridge/references/protocol.md +2 -1
  33. package/CHANGELOG.md +0 -55
  34. package/assets/banner.jpg +0 -0
  35. package/assets/logo.png +0 -0
  36. package/assets/logo.svg +0 -65
  37. package/docs/api-reference.md +0 -157
  38. package/docs/cli-guide.md +0 -128
  39. package/docs/index.md +0 -25
  40. package/docs/manual-setup.md +0 -140
  41. package/docs/mcp-vs-cli.md +0 -258
  42. package/docs/publishing.md +0 -112
  43. package/docs/quickstart.md +0 -104
  44. package/docs/troubleshooting.md +0 -59
  45. package/docs/unpacked-extension.md +0 -72
  46. package/docs/usage-scenarios.md +0 -136
  47. package/manifest.json +0 -38
  48. package/packages/extension/assets/icon-128.png +0 -0
  49. package/packages/extension/assets/icon-16.png +0 -0
  50. package/packages/extension/assets/icon-32.png +0 -0
  51. package/packages/extension/assets/icon-48.png +0 -0
  52. package/packages/extension/src/background-helpers.js +0 -474
  53. package/packages/extension/src/background-routing.js +0 -89
  54. package/packages/extension/src/background.js +0 -3490
  55. package/packages/extension/src/content-script-helpers.js +0 -282
  56. package/packages/extension/src/content-script.js +0 -2043
  57. package/packages/extension/src/debugger-coordinator.js +0 -188
  58. package/packages/extension/src/sidepanel-helpers.js +0 -104
  59. package/packages/extension/ui/popup.html +0 -35
  60. package/packages/extension/ui/popup.js +0 -298
  61. package/packages/extension/ui/sidepanel.html +0 -102
  62. package/packages/extension/ui/sidepanel.js +0 -1771
  63. package/packages/extension/ui/ui.css +0 -1160
@@ -29,13 +29,20 @@ 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';
33
39
  import { writeJsonLine } from './framing.js';
34
40
 
35
41
  /** @typedef {import('../../protocol/src/types.js').BridgeRequest} BridgeRequest */
36
42
  /** @typedef {import('../../protocol/src/types.js').SetupInstallParams} SetupInstallParams */
37
43
  /** @typedef {import('../../protocol/src/types.js').SetupInstallResult} SetupInstallResult */
38
44
  /** @typedef {import('../../protocol/src/types.js').SetupStatus} SetupStatus */
45
+ /** @typedef {import('./config.js').BridgeTransport} BridgeTransport */
39
46
  /** @typedef {import('node:net').Socket & { __clientId?: string, __extensionId?: string, __browserName?: string, __profileLabel?: string, __accessEnabled?: boolean, __lastActiveAt?: number }} ClientSocket */
40
47
  /** @typedef {{ socket: ClientSocket, timeoutId: NodeJS.Timeout, source?: string, method?: string, targets: Set<ClientSocket>, lastErrorResponse?: import('../../protocol/src/types.js').BridgeResponse }} PendingEntry */
41
48
  /**
@@ -88,6 +95,14 @@ function getVersionNegotiationPayload(requestedVersion) {
88
95
  };
89
96
  }
90
97
 
98
+ /**
99
+ * @param {string} socketPath
100
+ * @returns {boolean}
101
+ */
102
+ function isWindowsNamedPipePath(socketPath) {
103
+ return socketPath.startsWith('\\\\.\\pipe\\');
104
+ }
105
+
91
106
  /**
92
107
  * @typedef {{
93
108
  * type?: string,
@@ -109,6 +124,7 @@ function getVersionNegotiationPayload(requestedVersion) {
109
124
  export class BridgeDaemon {
110
125
  /**
111
126
  * @param {{
127
+ * transport?: BridgeTransport,
112
128
  * socketPath?: string,
113
129
  * listenOptions?: import('node:net').ListenOptions | null,
114
130
  * setupStatusLoader?: () => Promise<SetupStatus>,
@@ -117,14 +133,17 @@ export class BridgeDaemon {
117
133
  * }} [options={}]
118
134
  */
119
135
  constructor({
120
- socketPath = getSocketPath(),
136
+ transport = getBridgeTransport(),
137
+ socketPath = undefined,
121
138
  listenOptions = null,
122
139
  setupStatusLoader = collectSetupStatus,
123
140
  setupInstaller = installSetupTarget,
124
141
  logger = console,
125
142
  } = {}) {
126
- this.socketPath = socketPath;
127
- this.listenOptions = listenOptions;
143
+ this.transport = socketPath ? createSocketBridgeTransport(socketPath) : transport;
144
+ this.socketPath =
145
+ this.transport.type === 'socket' ? this.transport.socketPath : getSocketPath();
146
+ this.listenOptions = listenOptions ?? getBridgeListenTarget(this.transport);
128
147
  this.setupStatusLoader = setupStatusLoader;
129
148
  this.setupInstaller = setupInstaller;
130
149
  this.logger = logger;
@@ -138,13 +157,120 @@ export class BridgeDaemon {
138
157
  this.agentSockets = new Map();
139
158
  /** @type {Map<string, PendingEntry>} */
140
159
  this.pendingRequests = new Map();
160
+ /** @type {Map<ClientSocket, Set<string>>} */
161
+ this.pendingRequestsByOwnerSocket = new Map();
162
+ /** @type {Map<ClientSocket, Set<string>>} */
163
+ this.pendingRequestsByTargetSocket = new Map();
141
164
  this.pendingTimeoutMs = DEFAULT_DAEMON_PENDING_TIMEOUT_MS;
142
165
  /** @type {Record<string, unknown>[]} */
143
166
  this.recentLog = [];
167
+ /** @type {Array<{ extensionId: string, browserName: string | null, profileLabel: string | null, accessEnabled: boolean }> | null} */
168
+ this.connectedExtensionsCache = null;
144
169
  /** @type {Promise<void> | null} */
145
170
  this.stopPromise = null;
146
171
  }
147
172
 
173
+ /**
174
+ * @returns {void}
175
+ */
176
+ invalidateConnectedExtensionsCache() {
177
+ this.connectedExtensionsCache = null;
178
+ }
179
+
180
+ /**
181
+ * @param {Map<ClientSocket, Set<string>>} index
182
+ * @param {ClientSocket} socket
183
+ * @param {string} requestId
184
+ * @returns {void}
185
+ */
186
+ addPendingRequestIndex(index, socket, requestId) {
187
+ const requestIds = index.get(socket);
188
+ if (requestIds) {
189
+ requestIds.add(requestId);
190
+ return;
191
+ }
192
+ index.set(socket, new Set([requestId]));
193
+ }
194
+
195
+ /**
196
+ * @param {Map<ClientSocket, Set<string>>} index
197
+ * @param {ClientSocket} socket
198
+ * @param {string} requestId
199
+ * @returns {void}
200
+ */
201
+ removePendingRequestIndex(index, socket, requestId) {
202
+ const requestIds = index.get(socket);
203
+ if (!requestIds) {
204
+ return;
205
+ }
206
+ requestIds.delete(requestId);
207
+ if (requestIds.size === 0) {
208
+ index.delete(socket);
209
+ }
210
+ }
211
+
212
+ /**
213
+ * @param {string} requestId
214
+ * @param {PendingEntry} pending
215
+ * @returns {void}
216
+ */
217
+ trackPendingRequest(requestId, pending) {
218
+ this.pendingRequests.set(requestId, pending);
219
+ this.addPendingRequestIndex(this.pendingRequestsByOwnerSocket, pending.socket, requestId);
220
+ for (const targetSocket of pending.targets) {
221
+ this.addPendingRequestIndex(this.pendingRequestsByTargetSocket, targetSocket, requestId);
222
+ }
223
+ }
224
+
225
+ /**
226
+ * @param {string} requestId
227
+ * @param {PendingEntry | undefined} [pending]
228
+ * @returns {PendingEntry | undefined}
229
+ */
230
+ clearPendingRequest(requestId, pending = this.pendingRequests.get(requestId)) {
231
+ if (!pending) {
232
+ return undefined;
233
+ }
234
+ this.pendingRequests.delete(requestId);
235
+ this.removePendingRequestIndex(this.pendingRequestsByOwnerSocket, pending.socket, requestId);
236
+ for (const targetSocket of pending.targets) {
237
+ this.removePendingRequestIndex(this.pendingRequestsByTargetSocket, targetSocket, requestId);
238
+ }
239
+ return pending;
240
+ }
241
+
242
+ /**
243
+ * @param {string} requestId
244
+ * @param {PendingEntry} pending
245
+ * @param {ClientSocket} targetSocket
246
+ * @returns {void}
247
+ */
248
+ removePendingTarget(requestId, pending, targetSocket) {
249
+ if (!pending.targets.delete(targetSocket)) {
250
+ return;
251
+ }
252
+ this.removePendingRequestIndex(this.pendingRequestsByTargetSocket, targetSocket, requestId);
253
+ }
254
+
255
+ /**
256
+ * @returns {Array<{ extensionId: string, browserName: string | null, profileLabel: string | null, accessEnabled: boolean }>}
257
+ */
258
+ getConnectedExtensionsSnapshot() {
259
+ if (this.connectedExtensionsCache) {
260
+ return this.connectedExtensionsCache;
261
+ }
262
+
263
+ this.connectedExtensionsCache = Array.from(this.extensionSockets.entries()).map(
264
+ ([extensionId, extSocket]) => ({
265
+ extensionId,
266
+ browserName: extSocket.__browserName ?? null,
267
+ profileLabel: extSocket.__profileLabel ?? null,
268
+ accessEnabled: extSocket.__accessEnabled ?? false,
269
+ })
270
+ );
271
+ return this.connectedExtensionsCache;
272
+ }
273
+
148
274
  /**
149
275
  * @param {ClientSocket} socket
150
276
  * @param {DaemonMessage} message
@@ -160,6 +286,7 @@ export class BridgeDaemon {
160
286
  typeof message.profileLabel === 'string' ? message.profileLabel : undefined;
161
287
  socket.__lastActiveAt = Date.now();
162
288
  this.extensionSockets.set(extensionId, socket);
289
+ this.invalidateConnectedExtensionsCache();
163
290
  void writeJsonLine(socket, { type: 'registered', role: 'extension' });
164
291
  return;
165
292
  }
@@ -180,7 +307,7 @@ export class BridgeDaemon {
180
307
  * @returns {Promise<BridgeDaemon>}
181
308
  */
182
309
  async start() {
183
- if (!this.listenOptions) {
310
+ if (this.transport.type === 'socket' && !isWindowsNamedPipePath(this.socketPath)) {
184
311
  const socketDir = path.dirname(this.socketPath);
185
312
  await fs.promises.mkdir(socketDir, { recursive: true });
186
313
  if (process.platform !== 'win32') {
@@ -188,7 +315,7 @@ export class BridgeDaemon {
188
315
  }
189
316
  try {
190
317
  await fs.promises.access(this.socketPath);
191
- if (await pingExistingDaemon(this.socketPath)) {
318
+ if (await pingExistingDaemon(this.transport)) {
192
319
  throw new Error(
193
320
  `Another daemon is already running on ${this.socketPath}. Stop it before starting a new one.`
194
321
  );
@@ -228,14 +355,10 @@ export class BridgeDaemon {
228
355
  this.serverAddress = server.address();
229
356
  resolve(undefined);
230
357
  };
231
- if (this.listenOptions) {
232
- server.listen(this.listenOptions, onListen);
233
- } else {
234
- server.listen(this.socketPath, onListen);
235
- }
358
+ server.listen(this.listenOptions, onListen);
236
359
  });
237
360
 
238
- if (!this.listenOptions && process.platform !== 'win32') {
361
+ if (this.transport.type === 'socket' && process.platform !== 'win32') {
239
362
  await fs.promises.chmod(this.socketPath, 0o600);
240
363
  }
241
364
 
@@ -266,6 +389,8 @@ export class BridgeDaemon {
266
389
  clearTimeout(pending.timeoutId);
267
390
  }
268
391
  this.pendingRequests.clear();
392
+ this.pendingRequestsByOwnerSocket.clear();
393
+ this.pendingRequestsByTargetSocket.clear();
269
394
 
270
395
  for (const socket of this.agentSockets.values()) {
271
396
  socket.destroy();
@@ -276,6 +401,7 @@ export class BridgeDaemon {
276
401
  socket.destroy();
277
402
  }
278
403
  this.extensionSockets.clear();
404
+ this.invalidateConnectedExtensionsCache();
279
405
 
280
406
  if (this.server) {
281
407
  const server = this.server;
@@ -291,7 +417,7 @@ export class BridgeDaemon {
291
417
  });
292
418
  });
293
419
  } finally {
294
- if (!this.listenOptions) {
420
+ if (this.transport.type === 'socket' && !isWindowsNamedPipePath(this.socketPath)) {
295
421
  await fs.promises.rm(this.socketPath, { force: true });
296
422
  }
297
423
  }
@@ -359,6 +485,7 @@ export class BridgeDaemon {
359
485
  daemon: 'ok',
360
486
  extensionConnected: false,
361
487
  socketPath: this.socketPath,
488
+ transport: formatBridgeTransport(this.transport),
362
489
  connectedExtensions: [],
363
490
  ...getVersionNegotiationPayload(request.meta?.protocol_version),
364
491
  });
@@ -412,24 +539,38 @@ export class BridgeDaemon {
412
539
  typeof request.meta?.target_profile === 'string' ? request.meta.target_profile : null;
413
540
  const hasExplicitTarget = Boolean(targetBrowser || targetProfile);
414
541
 
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];
542
+ /** @type {ClientSocket[]} */
543
+ const targets = [];
544
+ /** @type {ClientSocket | null} */
545
+ let mostRecent = null;
546
+ for (const extSocket of this.extensionSockets.values()) {
547
+ if (targetBrowser || targetProfile) {
548
+ if (targetBrowser && extSocket.__browserName !== targetBrowser) {
549
+ continue;
550
+ }
551
+ if (targetProfile && extSocket.__profileLabel !== targetProfile) {
552
+ continue;
430
553
  }
554
+ targets.push(extSocket);
555
+ continue;
556
+ }
557
+
558
+ if (extSocket.__accessEnabled) {
559
+ targets.push(extSocket);
560
+ continue;
561
+ }
562
+
563
+ if (
564
+ !mostRecent ||
565
+ (typeof extSocket.__lastActiveAt === 'number' ? extSocket.__lastActiveAt : 0) >
566
+ (typeof mostRecent.__lastActiveAt === 'number' ? mostRecent.__lastActiveAt : 0)
567
+ ) {
568
+ mostRecent = extSocket;
431
569
  }
432
570
  }
571
+ if (!hasExplicitTarget && targets.length === 0 && mostRecent) {
572
+ targets.push(mostRecent);
573
+ }
433
574
 
434
575
  if (targets.length === 0) {
435
576
  const response = createFailure(
@@ -443,7 +584,7 @@ export class BridgeDaemon {
443
584
  return;
444
585
  }
445
586
 
446
- this.pendingRequests.set(request.id, {
587
+ this.trackPendingRequest(request.id, {
447
588
  socket,
448
589
  method: request.method,
449
590
  source: typeof request.meta?.source === 'string' ? request.meta.source : '',
@@ -451,7 +592,7 @@ export class BridgeDaemon {
451
592
  timeoutId: setTimeout(() => {
452
593
  const pending = this.pendingRequests.get(request.id);
453
594
  if (!pending) return;
454
- this.pendingRequests.delete(request.id);
595
+ this.clearPendingRequest(request.id, pending);
455
596
  const response = createFailure(
456
597
  request.id,
457
598
  ERROR_CODES.TIMEOUT,
@@ -496,12 +637,18 @@ export class BridgeDaemon {
496
637
  * @returns {void}
497
638
  */
498
639
  handleExtensionIdentity(socket, message) {
640
+ let changed = false;
499
641
  if (typeof message.browserName === 'string') {
642
+ changed = changed || socket.__browserName !== message.browserName;
500
643
  socket.__browserName = message.browserName;
501
644
  }
502
645
  if (typeof message.profileLabel === 'string') {
646
+ changed = changed || socket.__profileLabel !== message.profileLabel;
503
647
  socket.__profileLabel = message.profileLabel;
504
648
  }
649
+ if (changed) {
650
+ this.invalidateConnectedExtensionsCache();
651
+ }
505
652
  }
506
653
 
507
654
  /**
@@ -510,7 +657,13 @@ export class BridgeDaemon {
510
657
  * @returns {void}
511
658
  */
512
659
  handleExtensionAccessUpdate(socket, message) {
513
- socket.__accessEnabled = Boolean(message.accessEnabled);
660
+ const accessEnabled = Boolean(message.accessEnabled);
661
+ if (socket.__accessEnabled !== accessEnabled) {
662
+ socket.__accessEnabled = accessEnabled;
663
+ this.invalidateConnectedExtensionsCache();
664
+ return;
665
+ }
666
+ socket.__accessEnabled = accessEnabled;
514
667
  }
515
668
 
516
669
  /**
@@ -539,11 +692,11 @@ export class BridgeDaemon {
539
692
  return;
540
693
  }
541
694
 
542
- pending.targets.delete(socket);
695
+ this.removePendingTarget(responseMessage.id, pending, socket);
543
696
 
544
697
  if (responseMessage.ok) {
545
698
  clearTimeout(pending.timeoutId);
546
- this.pendingRequests.delete(responseMessage.id);
699
+ this.clearPendingRequest(responseMessage.id, pending);
547
700
  this.pushLog({
548
701
  at: new Date().toISOString(),
549
702
  method: responseMessage.meta?.method ?? null,
@@ -559,14 +712,8 @@ export class BridgeDaemon {
559
712
  daemon: 'ok',
560
713
  extensionConnected: true,
561
714
  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
- ),
715
+ transport: formatBridgeTransport(this.transport),
716
+ connectedExtensions: this.getConnectedExtensionsSnapshot(),
570
717
  .../** @type {Record<string, unknown>} */ (responseMessage.result),
571
718
  },
572
719
  {
@@ -596,20 +743,33 @@ export class BridgeDaemon {
596
743
  handleSocketClose(socket) {
597
744
  if (socket.__extensionId) {
598
745
  this.extensionSockets.delete(socket.__extensionId);
746
+ this.invalidateConnectedExtensionsCache();
599
747
  }
600
748
 
601
749
  if (socket.__clientId) {
602
750
  this.agentSockets.delete(socket.__clientId);
603
751
  }
604
752
 
605
- for (const [id, pending] of this.pendingRequests.entries()) {
606
- if (pending.socket === socket) {
753
+ const ownedRequestIds = this.pendingRequestsByOwnerSocket.get(socket);
754
+ if (ownedRequestIds) {
755
+ for (const id of ownedRequestIds) {
756
+ const pending = this.pendingRequests.get(id);
757
+ if (!pending) {
758
+ continue;
759
+ }
607
760
  clearTimeout(pending.timeoutId);
608
- this.pendingRequests.delete(id);
609
- continue;
761
+ this.clearPendingRequest(id, pending);
610
762
  }
611
- if (pending.targets.has(socket)) {
612
- pending.targets.delete(socket);
763
+ }
764
+
765
+ const targetRequestIds = this.pendingRequestsByTargetSocket.get(socket);
766
+ if (targetRequestIds) {
767
+ for (const id of targetRequestIds) {
768
+ const pending = this.pendingRequests.get(id);
769
+ if (!pending) {
770
+ continue;
771
+ }
772
+ this.removePendingTarget(id, pending, socket);
613
773
  void this.finishPendingRequestIfExhausted(id, pending);
614
774
  }
615
775
  }
@@ -629,7 +789,7 @@ export class BridgeDaemon {
629
789
  }
630
790
 
631
791
  clearTimeout(pending.timeoutId);
632
- this.pendingRequests.delete(requestId);
792
+ this.clearPendingRequest(requestId, pending);
633
793
 
634
794
  const response =
635
795
  pending.lastErrorResponse ??
@@ -668,36 +828,25 @@ export class BridgeDaemon {
668
828
  }
669
829
 
670
830
  /**
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.
831
+ * Check whether a daemon is already listening on the given transport.
688
832
  * Connects, sends a health.ping, and waits up to 500 ms for a response.
689
833
  *
690
- * @param {string} socketPath
834
+ * @param {BridgeTransport | string} transport
691
835
  * @returns {Promise<boolean>}
692
836
  */
693
- async function pingExistingDaemon(socketPath) {
837
+ export async function pingExistingDaemon(transport) {
838
+ const resolvedTransport =
839
+ typeof transport === 'string' ? createSocketBridgeTransport(transport) : transport;
694
840
  return new Promise((resolve) => {
695
841
  const timeout = setTimeout(() => {
696
842
  socket.destroy();
697
843
  resolve(false);
698
844
  }, DAEMON_EXISTING_SOCKET_PING_TIMEOUT_MS);
699
845
 
700
- const socket = net.createConnection(socketPath);
846
+ const socket =
847
+ resolvedTransport.type === 'tcp'
848
+ ? net.createConnection({ host: resolvedTransport.host, port: resolvedTransport.port })
849
+ : net.createConnection(resolvedTransport.socketPath);
701
850
  socket.once('error', () => {
702
851
  clearTimeout(timeout);
703
852
  resolve(false);
@@ -11,6 +11,9 @@ import { MAX_NATIVE_MESSAGE_BYTES } from '../../protocol/src/index.js';
11
11
  */
12
12
  export async function writeNativeMessage(stream, message) {
13
13
  const payload = Buffer.from(JSON.stringify(message), 'utf8');
14
+ if (payload.length > MAX_NATIVE_MESSAGE_BYTES) {
15
+ throw new Error(`Native message exceeds ${MAX_NATIVE_MESSAGE_BYTES} bytes: ${payload.length}`);
16
+ }
14
17
  const header = Buffer.alloc(4);
15
18
  header.writeUInt32LE(payload.length, 0);
16
19
  if (!stream.write(header)) {
@@ -24,34 +27,151 @@ export async function writeNativeMessage(stream, message) {
24
27
  /**
25
28
  * @param {NodeJS.ReadableStream} stream
26
29
  * @param {(message: unknown) => void} onMessage
30
+ * @param {(error: Error) => void} [onProtocolError]
27
31
  * @returns {void}
28
32
  */
29
- export function createNativeMessageReader(stream, onMessage) {
30
- let buffer = Buffer.alloc(0);
33
+ export function createNativeMessageReader(stream, onMessage, onProtocolError) {
34
+ /** @type {Buffer[]} */
35
+ const chunks = [];
36
+ let bufferedBytes = 0;
37
+ let closed = false;
38
+
39
+ /**
40
+ * @param {number} length
41
+ * @returns {Buffer | null}
42
+ */
43
+ function peekBytes(length) {
44
+ if (length === 0) {
45
+ return Buffer.alloc(0);
46
+ }
47
+ if (bufferedBytes < length || chunks.length === 0) {
48
+ return null;
49
+ }
50
+
51
+ const firstChunk = chunks[0];
52
+ if (firstChunk.length >= length) {
53
+ return firstChunk.subarray(0, length);
54
+ }
55
+
56
+ const combined = Buffer.allocUnsafe(length);
57
+ let offset = 0;
58
+ for (const chunk of chunks) {
59
+ const copyLength = Math.min(chunk.length, length - offset);
60
+ chunk.copy(combined, offset, 0, copyLength);
61
+ offset += copyLength;
62
+ if (offset === length) {
63
+ return combined;
64
+ }
65
+ }
66
+
67
+ return null;
68
+ }
69
+
70
+ /**
71
+ * @param {number} length
72
+ * @returns {Buffer | null}
73
+ */
74
+ function consumeBytes(length) {
75
+ if (length === 0) {
76
+ return Buffer.alloc(0);
77
+ }
78
+ if (bufferedBytes < length || chunks.length === 0) {
79
+ return null;
80
+ }
81
+
82
+ const firstChunk = chunks[0];
83
+ if (firstChunk.length === length) {
84
+ chunks.shift();
85
+ bufferedBytes -= length;
86
+ return firstChunk;
87
+ }
88
+ if (firstChunk.length > length) {
89
+ const consumed = firstChunk.subarray(0, length);
90
+ chunks[0] = firstChunk.subarray(length);
91
+ bufferedBytes -= length;
92
+ return consumed;
93
+ }
94
+
95
+ const combined = Buffer.allocUnsafe(length);
96
+ let offset = 0;
97
+ let remaining = length;
98
+ while (remaining > 0 && chunks.length > 0) {
99
+ const chunk = chunks[0];
100
+ const copyLength = Math.min(chunk.length, remaining);
101
+ chunk.copy(combined, offset, 0, copyLength);
102
+ offset += copyLength;
103
+ remaining -= copyLength;
104
+ if (copyLength === chunk.length) {
105
+ chunks.shift();
106
+ } else {
107
+ chunks[0] = chunk.subarray(copyLength);
108
+ }
109
+ }
110
+
111
+ bufferedBytes -= length;
112
+ return combined;
113
+ }
114
+
115
+ /**
116
+ * @param {Error} error
117
+ * @returns {void}
118
+ */
119
+ function closeReader(error) {
120
+ if (closed) {
121
+ return;
122
+ }
123
+ closed = true;
124
+ stream.removeListener('data', handleData);
125
+ onProtocolError?.(error);
126
+ const destroy = /** @type {{ destroy?: (() => void) | undefined }} */ (stream).destroy;
127
+ if (typeof destroy === 'function') {
128
+ destroy.call(stream);
129
+ }
130
+ }
31
131
 
32
132
  /** @param {Buffer} chunk */
33
- stream.on('data', (chunk) => {
34
- buffer = Buffer.concat([buffer, chunk]);
133
+ function handleData(chunk) {
134
+ if (closed || chunk.length === 0) {
135
+ return;
136
+ }
137
+
138
+ chunks.push(chunk);
139
+ bufferedBytes += chunk.length;
35
140
 
36
- while (buffer.length >= 4) {
37
- const length = buffer.readUInt32LE(0);
141
+ while (bufferedBytes >= 4) {
142
+ const header = peekBytes(4);
143
+ if (!header) {
144
+ return;
145
+ }
146
+
147
+ const length = header.readUInt32LE(0);
38
148
  if (length > MAX_NATIVE_MESSAGE_BYTES) {
39
- buffer = Buffer.alloc(0);
149
+ closeReader(
150
+ new Error(`Native message exceeds ${MAX_NATIVE_MESSAGE_BYTES} bytes: ${length}`)
151
+ );
40
152
  return;
41
153
  }
42
- if (buffer.length < 4 + length) {
154
+
155
+ const frameLength = 4 + length;
156
+ if (bufferedBytes < frameLength) {
43
157
  return;
44
158
  }
45
159
 
46
- const payload = buffer.subarray(4, 4 + length);
47
- buffer = buffer.subarray(4 + length);
160
+ const frame = consumeBytes(frameLength);
161
+ if (!frame) {
162
+ return;
163
+ }
164
+
165
+ const payload = frame.subarray(4);
48
166
  try {
49
167
  onMessage(JSON.parse(payload.toString('utf8')));
50
168
  } catch {
51
169
  // Malformed JSON payload - skip it.
52
170
  }
53
171
  }
54
- });
172
+ }
173
+
174
+ stream.on('data', handleData);
55
175
  }
56
176
 
57
177
  /**