@browserbridge/bbx 1.7.1 → 1.7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserbridge/bbx",
3
- "version": "1.7.1",
3
+ "version": "1.7.3",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "agent-tools",
@@ -61,8 +61,9 @@
61
61
  "postinstall": "node packages/native-host/bin/postinstall.js",
62
62
  "package:extension": "node scripts/package-extension.mjs",
63
63
  "check:extension-zip": "node scripts/check-extension-zip.mjs",
64
- "release:check": "npm run lint && npm run typecheck && npm test && npm run coverage:check && npm run package:extension && npm run check:extension-zip && npm pack --dry-run",
65
- "prepublishOnly": "npm run lint && npm run typecheck && npm test && npm run coverage:check",
64
+ "check:release-version": "node scripts/check-release-version.mjs",
65
+ "release:check": "npm run check:release-version && npm run lint && npm run typecheck && npm test && npm run coverage:check && npm run package:extension && npm run check:extension-zip && npm pack --dry-run",
66
+ "prepublishOnly": "npm run check:release-version && npm run lint && npm run typecheck && npm test && npm run coverage:check",
66
67
  "status": "node packages/agent-client/src/cli.js status",
67
68
  "daemon": "node packages/native-host/bin/bridge-daemon.js",
68
69
  "install-manifest": "node packages/native-host/bin/install-manifest.js"
@@ -512,6 +512,9 @@ async function main() {
512
512
 
513
513
  if (command === 'doctor') {
514
514
  const report = await getDoctorReport();
515
+ if (report.issues.length > 0) {
516
+ process.exitCode = 1;
517
+ }
515
518
  printJson({
516
519
  ok: report.issues.length === 0,
517
520
  summary:
@@ -1298,6 +1301,9 @@ async function ensureClientConnection() {
1298
1301
  * @returns {Promise<void>}
1299
1302
  */
1300
1303
  async function printSummary(response, method) {
1304
+ if (!response.ok) {
1305
+ process.exitCode = 1;
1306
+ }
1301
1307
  printJson(annotateBridgeSummary(summarizeBridgeResponse(response, method), response));
1302
1308
  }
1303
1309
 
@@ -3,7 +3,6 @@
3
3
  import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
- import { fileURLToPath } from 'node:url';
7
6
 
8
7
  /** @typedef {import('./types.js').McpClientName} McpClientName */
9
8
 
@@ -40,8 +39,8 @@ export function isMcpClientName(value) {
40
39
  }
41
40
 
42
41
  const BROWSER_BRIDGE_SERVER_NAME = 'browser-bridge';
43
- const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
44
- const mcpServerBinPath = path.join(packageRoot, 'packages', 'mcp-server', 'src', 'bin.js');
42
+ const MCP_COMMAND = 'bbx';
43
+ const MCP_ARGS = Object.freeze(['mcp', 'serve']);
45
44
 
46
45
  /**
47
46
  * @returns {string}
@@ -76,7 +75,7 @@ function getLegacyCopilotVsCodeConfigPath() {
76
75
 
77
76
  /**
78
77
  * @param {McpClientName} clientName
79
- * @returns {{ key: string, includeType: boolean, legacyKeys?: string[], keepEmptyBlock?: boolean }}
78
+ * @returns {{ key: string, includeType: boolean, keepEmptyBlock?: boolean }}
80
79
  */
81
80
  export function getMcpConfigShape(clientName) {
82
81
  return MCP_CONFIG_SHAPES[clientName] ?? { key: 'mcpServers', includeType: false };
@@ -95,21 +94,21 @@ export function getMcpConfigShape(clientName) {
95
94
  */
96
95
  function createBaseServerConfig(clientName) {
97
96
  const serverConfig = {
98
- command: process.execPath,
99
- args: [mcpServerBinPath],
97
+ command: MCP_COMMAND,
98
+ args: [...MCP_ARGS],
100
99
  env: {},
101
100
  };
102
101
 
103
102
  if (clientName === 'opencode') {
104
103
  return {
105
104
  type: 'local',
106
- command: [process.execPath, mcpServerBinPath],
105
+ command: [MCP_COMMAND, ...MCP_ARGS],
107
106
  };
108
107
  }
109
108
  return serverConfig;
110
109
  }
111
110
 
112
- /** @type {Record<McpClientName, { key: string, includeType: boolean, legacyKeys?: string[], keepEmptyBlock?: boolean }>} */
111
+ /** @type {Record<McpClientName, { key: string, includeType: boolean, keepEmptyBlock?: boolean }>} */
113
112
  const MCP_CONFIG_SHAPES = {
114
113
  antigravity: { key: 'mcpServers', includeType: false },
115
114
  agents: { key: 'mcpServers', includeType: true },
@@ -117,7 +116,6 @@ const MCP_CONFIG_SHAPES = {
117
116
  copilot: {
118
117
  key: 'mcpServers',
119
118
  includeType: true,
120
- legacyKeys: ['servers'],
121
119
  keepEmptyBlock: true,
122
120
  },
123
121
  cursor: { key: 'mcpServers', includeType: false },
@@ -126,6 +124,24 @@ const MCP_CONFIG_SHAPES = {
126
124
  opencode: { key: 'mcp', includeType: false },
127
125
  };
128
126
 
127
+ /**
128
+ * Copilot CLI and VS Code use different top-level keys. Select the schema from
129
+ * the destination file instead of migrating one valid schema into the other.
130
+ *
131
+ * @param {McpClientName} clientName
132
+ * @param {string} configPath
133
+ * @returns {{ key: string, includeType: boolean, keepEmptyBlock?: boolean }}
134
+ */
135
+ export function getMcpConfigShapeForPath(clientName, configPath) {
136
+ if (
137
+ clientName === 'copilot' &&
138
+ path.resolve(configPath) !== path.resolve(getCopilotUserConfigPath())
139
+ ) {
140
+ return { key: 'servers', includeType: true, keepEmptyBlock: true };
141
+ }
142
+ return getMcpConfigShape(clientName);
143
+ }
144
+
129
145
  /**
130
146
  * @param {McpClientName} clientName
131
147
  * @returns {Record<string, unknown>}
@@ -135,8 +151,8 @@ export function buildMcpConfig(clientName) {
135
151
  return {
136
152
  mcp_servers: {
137
153
  [BROWSER_BRIDGE_SERVER_NAME]: {
138
- command: process.execPath,
139
- args: [mcpServerBinPath],
154
+ command: MCP_COMMAND,
155
+ args: [...MCP_ARGS],
140
156
  },
141
157
  },
142
158
  };
@@ -267,8 +283,8 @@ export async function getMcpConfigPaths(clientName, options) {
267
283
  function formatCodexServerBlock() {
268
284
  return [
269
285
  `[mcp_servers."${BROWSER_BRIDGE_SERVER_NAME}"]`,
270
- `command = ${JSON.stringify(process.execPath)}`,
271
- `args = ${JSON.stringify([mcpServerBinPath])}`,
286
+ `command = ${JSON.stringify(MCP_COMMAND)}`,
287
+ `args = ${JSON.stringify(MCP_ARGS)}`,
272
288
  '',
273
289
  ].join('\n');
274
290
  }
@@ -377,37 +393,6 @@ function removeCodexServerBlock(raw) {
377
393
  return trimmed ? `${trimmed}\n` : '';
378
394
  }
379
395
 
380
- /**
381
- * @param {McpClientName} clientName
382
- * @returns {string[]}
383
- */
384
- function getJsonMcpBlockKeys(clientName) {
385
- const shape = getMcpConfigShape(clientName);
386
- return [...(shape.legacyKeys ?? []), shape.key];
387
- }
388
-
389
- /**
390
- * Merge MCP entries from the current config key plus any legacy aliases.
391
- *
392
- * @param {Record<string, unknown>} config
393
- * @param {McpClientName} clientName
394
- * @returns {Record<string, unknown>}
395
- */
396
- function getMergedJsonMcpBlock(config, clientName) {
397
- /** @type {Record<string, unknown>} */
398
- const merged = {};
399
-
400
- for (const key of getJsonMcpBlockKeys(clientName)) {
401
- const block = config[key];
402
- if (!block || typeof block !== 'object' || Array.isArray(block)) {
403
- continue;
404
- }
405
- Object.assign(merged, /** @type {Record<string, unknown>} */ (block));
406
- }
407
-
408
- return merged;
409
- }
410
-
411
396
  /**
412
397
  * Merge the browser-bridge entry into an existing client config file, or
413
398
  * create it if it does not exist. Existing unrelated entries are preserved.
@@ -446,7 +431,7 @@ export async function findConfiguredMcpClients(options) {
446
431
  for (const configPath of configPaths) {
447
432
  try {
448
433
  const raw = await fs.promises.readFile(configPath, 'utf8');
449
- if (parseInstalledMcpConfig(clientName, raw).configured) {
434
+ if (parseInstalledMcpConfig(clientName, raw, configPath).configured) {
450
435
  configured = true;
451
436
  break;
452
437
  }
@@ -499,8 +484,6 @@ export async function removeMcpConfig(clientName, options) {
499
484
  * @returns {Promise<void>}
500
485
  */
501
486
  async function installJsonMcpConfig(clientName, configPath, stdout) {
502
- const newEntry = buildMcpConfig(clientName);
503
-
504
487
  /** @type {Record<string, unknown>} */
505
488
  let existing = {};
506
489
  try {
@@ -518,21 +501,23 @@ async function installJsonMcpConfig(clientName, configPath, stdout) {
518
501
  // File missing - start fresh.
519
502
  }
520
503
 
521
- const shape = getMcpConfigShape(clientName);
504
+ const shape = getMcpConfigShapeForPath(clientName, configPath);
522
505
  const topKey = shape.key;
523
- const entryBlock = /** @type {Record<string, unknown>} */ (newEntry[topKey]);
524
- const existingBlock = getMergedJsonMcpBlock(existing, clientName);
506
+ const serverConfig = createBaseServerConfig(clientName);
507
+ const entry = shape.includeType ? { type: 'stdio', ...serverConfig } : serverConfig;
508
+ const currentBlock = existing[topKey];
509
+ const existingBlock =
510
+ currentBlock && typeof currentBlock === 'object' && !Array.isArray(currentBlock)
511
+ ? /** @type {Record<string, unknown>} */ (currentBlock)
512
+ : {};
525
513
 
526
514
  const merged = {
527
515
  ...existing,
528
516
  [topKey]: {
529
517
  ...existingBlock,
530
- ...entryBlock,
518
+ [BROWSER_BRIDGE_SERVER_NAME]: entry,
531
519
  },
532
520
  };
533
- for (const key of shape.legacyKeys ?? []) {
534
- delete merged[key];
535
- }
536
521
 
537
522
  await fs.promises.mkdir(path.dirname(configPath), { recursive: true });
538
523
  await fs.promises.writeFile(configPath, `${JSON.stringify(merged, null, 2)}\n`, 'utf8');
@@ -579,9 +564,13 @@ async function removeJsonMcpConfig(clientName, configPath, stdout) {
579
564
  return false;
580
565
  }
581
566
 
582
- const shape = getMcpConfigShape(clientName);
567
+ const shape = getMcpConfigShapeForPath(clientName, configPath);
583
568
  const topKey = shape.key;
584
- const block = getMergedJsonMcpBlock(existing, clientName);
569
+ const currentBlock = existing[topKey];
570
+ const block =
571
+ currentBlock && typeof currentBlock === 'object' && !Array.isArray(currentBlock)
572
+ ? /** @type {Record<string, unknown>} */ (currentBlock)
573
+ : {};
585
574
  if (!Object.hasOwn(block, BROWSER_BRIDGE_SERVER_NAME)) {
586
575
  return false;
587
576
  }
@@ -590,9 +579,7 @@ async function removeJsonMcpConfig(clientName, configPath, stdout) {
590
579
  delete updatedBlock[BROWSER_BRIDGE_SERVER_NAME];
591
580
 
592
581
  const merged = { ...existing };
593
- for (const key of getJsonMcpBlockKeys(clientName)) {
594
- delete merged[key];
595
- }
582
+ delete merged[topKey];
596
583
  if (Object.keys(updatedBlock).length > 0 || shape.keepEmptyBlock) {
597
584
  merged[topKey] = updatedBlock;
598
585
  }
@@ -630,23 +617,35 @@ async function removeCodexMcpConfig(configPath, stdout) {
630
617
  /**
631
618
  * @param {McpClientName} clientName
632
619
  * @param {string} raw
620
+ * @param {string} [configPath]
633
621
  * @returns {{ configured: boolean }}
634
622
  */
635
- export function parseInstalledMcpConfig(clientName, raw) {
623
+ export function parseInstalledMcpConfig(clientName, raw, configPath) {
636
624
  if (clientName === 'codex') {
637
625
  return { configured: hasCodexServerBlock(raw) };
638
626
  }
639
627
 
640
628
  try {
641
629
  const parsed = JSON.parse(raw);
642
- const block =
630
+ const config =
643
631
  parsed && typeof parsed === 'object' && !Array.isArray(parsed)
644
- ? getMergedJsonMcpBlock(/** @type {Record<string, unknown>} */ (parsed), clientName)
632
+ ? /** @type {Record<string, unknown>} */ (parsed)
645
633
  : {};
634
+ const keys = configPath
635
+ ? [getMcpConfigShapeForPath(clientName, configPath).key]
636
+ : clientName === 'copilot'
637
+ ? ['mcpServers', 'servers']
638
+ : [getMcpConfigShape(clientName).key];
646
639
  return {
647
- configured: Boolean(
648
- block && typeof block === 'object' && Object.hasOwn(block, BROWSER_BRIDGE_SERVER_NAME)
649
- ),
640
+ configured: keys.some((key) => {
641
+ const block = config[key];
642
+ return Boolean(
643
+ block &&
644
+ typeof block === 'object' &&
645
+ !Array.isArray(block) &&
646
+ Object.hasOwn(block, BROWSER_BRIDGE_SERVER_NAME)
647
+ );
648
+ }),
650
649
  };
651
650
  } catch {
652
651
  return { configured: false };
@@ -18,6 +18,12 @@ import {
18
18
  summarizeDaemonRestarts,
19
19
  } from '../../native-host/src/daemon-process.js';
20
20
  import { resolveDefaultExtensionId } from '../../native-host/src/install-manifest.js';
21
+ import {
22
+ CLIENT_REQUEST_TIMEOUT_MARGIN_MS,
23
+ DEFAULT_CLIENT_REQUEST_TIMEOUT_MS,
24
+ getBridgeOperationTimeoutMs,
25
+ MAX_CLIENT_REQUEST_TIMEOUT_MS,
26
+ } from '../../protocol/src/index.js';
21
27
  import { methodNeedsTab } from './cli-helpers.js';
22
28
  import { BridgeClient } from './client.js';
23
29
 
@@ -53,11 +59,24 @@ export async function ensureClientConnected(client) {
53
59
  */
54
60
  export async function requestBridge(client, method, params = {}, options = {}) {
55
61
  await ensureClientConnected(client);
62
+ const operationTimeoutMs = getBridgeOperationTimeoutMs(method, params);
63
+ const clientDefaultTimeoutMs =
64
+ typeof client.defaultTimeoutMs === 'number'
65
+ ? client.defaultTimeoutMs
66
+ : DEFAULT_CLIENT_REQUEST_TIMEOUT_MS;
67
+ const timeoutMs =
68
+ operationTimeoutMs === null
69
+ ? undefined
70
+ : Math.min(
71
+ MAX_CLIENT_REQUEST_TIMEOUT_MS,
72
+ Math.max(clientDefaultTimeoutMs, operationTimeoutMs + CLIENT_REQUEST_TIMEOUT_MARGIN_MS)
73
+ );
56
74
  return client.request({
57
75
  method,
58
76
  params,
59
77
  tabId: methodNeedsTab(method) ? (options.tabId ?? null) : null,
60
78
  meta: withRequestMeta(options.source, options.tokenBudget),
79
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
61
80
  });
62
81
  }
63
82
 
@@ -10,9 +10,9 @@ export const MCP_SERVER_INSTRUCTIONS = [
10
10
  'In permission-ask hosts, use browser_call as the default Browser Bridge MCP tool so the user can approve one BBX tool instead of separate browser_status, browser_page, browser_dom, browser_input, and other tools.',
11
11
  'Prefer Browser Bridge MCP tools over shelling out to bbx. Use bbx only for explicit CLI setup, doctor, logs, or raw debugging requests.',
12
12
  'Start with browser_call method health.ping. If window access is disabled, call browser_call method access.request once, ask the user to click Enable in the Browser Bridge popup or side panel, then retry once.',
13
- 'Use structured reads first through browser_call: page.get_state, dom.query, page.get_text, styles.get_computed, layout.get_box_model, or batch. Keep budgets quick or normal before widening.',
13
+ 'Use structured reads first through browser_call: page.get_state, dom.query, page.get_text, styles.get_computed, or layout.get_box_model. Use browser_batch for parallel reads and keep limits tight before widening.',
14
14
  'Reuse elementRef values returned by DOM tools. Use attribute allowlists for focused DOM reads.',
15
- 'Escalate to screenshot.capture_element, screenshot.capture_region, accessibility_tree, page.evaluate, viewport.resize, or CDP only when structured reads cannot answer the question.',
15
+ 'Escalate to screenshot.capture_element, screenshot.capture_region, dom.get_accessibility_tree, page.evaluate, viewport.resize, or CDP only when structured reads cannot answer the question.',
16
16
  'Use patch.apply_styles or patch.apply_dom for temporary experiments, and rollback patches before finishing unless the user asks to keep them.',
17
17
  'Only use the specialized Browser Bridge MCP tools directly when the host has already allowed them or the user explicitly wants typed tool calls.',
18
18
  ].join('\n');
@@ -102,8 +102,8 @@ function createGuidePrompt() {
102
102
  '1. Prefer MCP over `bbx`; do not shell out unless setup, doctor, logs, or raw CLI debugging is explicitly needed.',
103
103
  '2. In permission-ask hosts, use `browser_call` as the default tool so the user can approve one BBX MCP tool instead of separate tools for status, page, DOM, input, and patches.',
104
104
  '3. Start with `browser_call` method `health.ping`. If access is disabled, call `browser_call` method `access.request` once, ask the user to click Enable, then retry once.',
105
- '4. Start with structured reads via `browser_call`: `page.get_state`, `dom.query`, `dom.find_by_text`, `dom.find_by_role`, `styles.get_computed`, and `batch`.',
106
- '5. Keep budgets tight with `budgetPreset: "quick"` or `"normal"`; widen only when results are truncated.',
105
+ '4. Start with structured reads via `browser_call`: `page.get_state`, `dom.query`, `dom.find_by_text`, `dom.find_by_role`, and `styles.get_computed`. Use `browser_batch` for parallel reads.',
106
+ '5. `browser_call` accepts explicit method limits, not `budgetPreset`; keep limits tight and widen only when results are truncated.',
107
107
  '6. Reuse `elementRef` values returned by DOM tools instead of rescanning.',
108
108
  '7. Escalate to screenshots, accessibility tree, `page.evaluate`, viewport resize, or CDP only when structured reads cannot answer.',
109
109
  '8. Use `patch.apply_styles` or `patch.apply_dom` for temporary experiments and rollback before finishing unless the user asks to keep patches.',
@@ -1,7 +1,7 @@
1
1
  // @ts-check
2
2
 
3
3
  import {
4
- dispatchToolAction,
4
+ createToolResult,
5
5
  getToolTokenBudget,
6
6
  REQUEST_SOURCE,
7
7
  requestBridgeWithRetry,
@@ -13,6 +13,7 @@ import {
13
13
  } from './handlers-utils.js';
14
14
 
15
15
  /** @typedef {import('../../protocol/src/types.js').BridgeMethod} BridgeMethod */
16
+ /** @typedef {import('../../protocol/src/types.js').BridgeResponse} BridgeResponse */
16
17
  /** @typedef {import('./handlers-utils.js').ToolAction} ToolAction */
17
18
  /** @typedef {import('./handlers-utils.js').ToolResult} ToolResult */
18
19
 
@@ -97,7 +98,170 @@ export async function handleCaptureTool(args) {
97
98
  return summarizeToolError('nodeId must be a finite number.');
98
99
  }
99
100
 
100
- return dispatchToolAction(CAPTURE_ACTIONS, args, 'capture');
101
+ const entry = CAPTURE_ACTIONS[args.action];
102
+ if (!entry) {
103
+ return summarizeToolError(`Unsupported capture action "${args.action}".`);
104
+ }
105
+
106
+ return withToolClient(
107
+ async (client) => {
108
+ const requestedTabId = typeof args.tabId === 'number' ? args.tabId : null;
109
+ const ref = entry.ref ? await resolveToolRef(client, args, requestedTabId) : undefined;
110
+ const response = await requestBridgeWithRetry(client, entry.method, entry.params(args, ref), {
111
+ tabId: requestedTabId,
112
+ source: REQUEST_SOURCE,
113
+ tokenBudget: getToolTokenBudget(args),
114
+ });
115
+ if (!response.ok) {
116
+ return summarizeToolResponse(response, entry.method);
117
+ }
118
+ return entry.method.startsWith('screenshot.')
119
+ ? createScreenshotResult(response, entry.method)
120
+ : createCdpCaptureResult(response, entry.method);
121
+ },
122
+ { destinationId: args.destinationId ?? null }
123
+ );
124
+ }
125
+
126
+ /**
127
+ * @param {BridgeResponse & { ok: true }} response
128
+ * @param {BridgeMethod} method
129
+ * @returns {ToolResult}
130
+ */
131
+ function createScreenshotResult(response, method) {
132
+ const result = toRecord(response.result);
133
+ if (typeof result.image !== 'string') {
134
+ return summarizeToolError(`${method} returned no image data.`);
135
+ }
136
+
137
+ const image = normalizeBase64Image(result.image);
138
+ if (!image) {
139
+ return summarizeToolError(`${method} returned invalid base64 image data.`);
140
+ }
141
+
142
+ const rect = boundedRect(result.rect);
143
+ return createToolResult(
144
+ `Captured ${image.mimeType} screenshot (${image.byteLength} bytes).`,
145
+ {
146
+ ok: true,
147
+ method,
148
+ mimeType: image.mimeType,
149
+ byteLength: image.byteLength,
150
+ ...(rect ? { rect } : {}),
151
+ },
152
+ false,
153
+ [{ type: 'image', data: image.data, mimeType: image.mimeType }]
154
+ );
155
+ }
156
+
157
+ /**
158
+ * @param {BridgeResponse & { ok: true }} response
159
+ * @param {BridgeMethod} method
160
+ * @returns {ToolResult}
161
+ */
162
+ function createCdpCaptureResult(response, method) {
163
+ const bounded = boundStructuredValue(response.result);
164
+ return createToolResult(`Captured bounded structured data from ${method}.`, {
165
+ ok: true,
166
+ method,
167
+ data: bounded.value,
168
+ truncated: bounded.truncated,
169
+ });
170
+ }
171
+
172
+ /**
173
+ * @param {string} value
174
+ * @returns {{ data: string, mimeType: string, byteLength: number } | null}
175
+ */
176
+ function normalizeBase64Image(value) {
177
+ const dataUrl = /^data:([^;,]+);base64,(.*)$/su.exec(value);
178
+ const mimeType = dataUrl?.[1] ?? 'image/png';
179
+ if (!mimeType.startsWith('image/')) return null;
180
+ const data = (dataUrl?.[2] ?? value).replace(/\s+/gu, '');
181
+ if (!data || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(data)) {
182
+ return null;
183
+ }
184
+ const bytes = Buffer.from(data, 'base64');
185
+ if (bytes.toString('base64') !== data) return null;
186
+ return { data, mimeType, byteLength: bytes.length };
187
+ }
188
+
189
+ /**
190
+ * @param {unknown} value
191
+ * @returns {Record<string, number> | null}
192
+ */
193
+ function boundedRect(value) {
194
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
195
+ const source = /** @type {Record<string, unknown>} */ (value);
196
+ /** @type {Record<string, number>} */
197
+ const rect = {};
198
+ for (const key of ['x', 'y', 'width', 'height', 'scale']) {
199
+ if (isFiniteNumber(source[key])) rect[key] = source[key];
200
+ }
201
+ return Object.keys(rect).length > 0 ? rect : null;
202
+ }
203
+
204
+ /**
205
+ * Bound debugger payloads by depth, entries, and string length while retaining
206
+ * their actual values rather than reducing them to field names.
207
+ *
208
+ * @param {unknown} input
209
+ * @returns {{ value: unknown, truncated: boolean }}
210
+ */
211
+ function boundStructuredValue(input) {
212
+ const state = { entries: 0, characters: 0, truncated: false };
213
+ const maxEntries = 500;
214
+ const maxCharacters = 50_000;
215
+ /** @type {(value: unknown, depth: number) => unknown} */
216
+ const visit = (value, depth) => {
217
+ if (state.entries >= maxEntries || state.characters >= maxCharacters || depth > 8) {
218
+ state.truncated = true;
219
+ return '[truncated]';
220
+ }
221
+ state.entries += 1;
222
+ if (typeof value === 'string') {
223
+ const remaining = maxCharacters - state.characters;
224
+ const limit = Math.min(value.length, remaining, 4000);
225
+ state.characters += limit;
226
+ if (limit === value.length) return value;
227
+ state.truncated = true;
228
+ return `${value.slice(0, limit)}[truncated]`;
229
+ }
230
+ if (Array.isArray(value)) {
231
+ const remaining = maxEntries - state.entries;
232
+ const limit = Math.min(value.length, remaining, 100);
233
+ if (limit < value.length) state.truncated = true;
234
+ return value.slice(0, limit).map((entry) => visit(entry, depth + 1));
235
+ }
236
+ if (value && typeof value === 'object') {
237
+ /** @type {Record<string, unknown>} */
238
+ const output = {};
239
+ const entries = Object.entries(/** @type {Record<string, unknown>} */ (value));
240
+ const limit = Math.min(entries.length, 100);
241
+ if (limit < entries.length) state.truncated = true;
242
+ for (const [key, entry] of entries.slice(0, limit)) {
243
+ if (state.entries >= maxEntries || state.characters >= maxCharacters) {
244
+ state.truncated = true;
245
+ break;
246
+ }
247
+ state.characters += key.length;
248
+ output[key] = visit(entry, depth + 1);
249
+ }
250
+ return output;
251
+ }
252
+ return value;
253
+ };
254
+ return { value: visit(input, 0), truncated: state.truncated };
255
+ }
256
+
257
+ /**
258
+ * @param {unknown} value
259
+ * @returns {Record<string, unknown>}
260
+ */
261
+ function toRecord(value) {
262
+ return value && typeof value === 'object' && !Array.isArray(value)
263
+ ? /** @type {Record<string, unknown>} */ (value)
264
+ : {};
101
265
  }
102
266
 
103
267
  /** @type {Record<string, BridgeMethod>} */