@browserbridge/bbx 1.7.2 → 1.7.4

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.
@@ -20,12 +20,14 @@ import {
20
20
  createFailure,
21
21
  createSuccess,
22
22
  DAEMON_EXISTING_SOCKET_PING_TIMEOUT_MS,
23
+ DAEMON_PENDING_TIMEOUT_MARGIN_MS,
23
24
  DAEMON_RECENT_LOG_LIMIT,
24
25
  DEFAULT_DAEMON_PENDING_TIMEOUT_MS,
25
26
  DEFAULT_LOG_TAIL_LIMIT,
26
27
  ERROR_CODES,
27
28
  getProtocolVersion,
28
29
  getSupportedProtocolVersions,
30
+ MAX_DAEMON_PENDING_TIMEOUT_MS,
29
31
  parseJsonLines,
30
32
  setProtocolPackageVersion,
31
33
  validateBridgeRequest,
@@ -54,7 +56,8 @@ setProtocolPackageVersion(DAEMON_VERSION);
54
56
  /** @typedef {import('../../protocol/src/types.js').SetupStatus} SetupStatus */
55
57
  /** @typedef {import('./config.js').BridgeTransport} BridgeTransport */
56
58
  /** @typedef {import('./daemon-logger.js').DaemonLoggerLike} DaemonLoggerLike */
57
- /** @typedef {import('node:net').Socket & { __clientId?: string, __extensionId?: string, __browserName?: string, __profileLabel?: string, __accessEnabled?: boolean, __lastActiveAt?: number, __authenticated?: boolean }} ClientSocket */
59
+ /** @typedef {'agent' | 'extension'} SocketRole */
60
+ /** @typedef {import('node:net').Socket & { readonly __role?: SocketRole, __clientId?: string, __extensionId?: string, __browserName?: string, __profileLabel?: string, __accessEnabled?: boolean, __lastActiveAt?: number }} ClientSocket */
58
61
  /** @typedef {{ socket: ClientSocket, timeoutId: NodeJS.Timeout, source?: string, method?: string, protocolVersion?: string, targets: Set<ClientSocket>, lastErrorResponse?: import('../../protocol/src/types.js').BridgeResponse }} PendingEntry */
59
62
  /**
60
63
  * @typedef {{
@@ -327,6 +330,17 @@ export class BridgeDaemon {
327
330
  * @returns {void}
328
331
  */
329
332
  registerSocket(socket, message) {
333
+ if (socket.__role) {
334
+ void writeJsonLine(socket, {
335
+ type: 'registration_failed',
336
+ error: {
337
+ code: ERROR_CODES.INVALID_REQUEST,
338
+ message: `Socket is already registered as ${socket.__role}.`,
339
+ },
340
+ });
341
+ return;
342
+ }
343
+
330
344
  if (this.isAuthRequired() && normalizeBridgeAuthToken(message.authToken) !== this.authToken) {
331
345
  this.logger.error('socket registration rejected', { role: message.role ?? null });
332
346
  void writeJsonLine(socket, {
@@ -339,8 +353,24 @@ export class BridgeDaemon {
339
353
  return;
340
354
  }
341
355
 
356
+ if (message.role !== 'extension' && message.role !== 'agent') {
357
+ void writeJsonLine(socket, {
358
+ type: 'registration_failed',
359
+ error: {
360
+ code: ERROR_CODES.INVALID_REQUEST,
361
+ message: 'Socket role must be "agent" or "extension".',
362
+ },
363
+ });
364
+ return;
365
+ }
366
+
367
+ Object.defineProperty(socket, '__role', {
368
+ value: message.role,
369
+ enumerable: false,
370
+ configurable: false,
371
+ writable: false,
372
+ });
342
373
  if (message.role === 'extension') {
343
- socket.__authenticated = true;
344
374
  const extensionId = randomUUID();
345
375
  socket.__extensionId = extensionId;
346
376
  socket.__browserName =
@@ -360,7 +390,6 @@ export class BridgeDaemon {
360
390
  }
361
391
 
362
392
  if (message.role === 'agent') {
363
- socket.__authenticated = true;
364
393
  const clientId = message.clientId || randomUUID();
365
394
  this.agentSockets.set(clientId, socket);
366
395
  socket.__clientId = clientId;
@@ -524,36 +553,61 @@ export class BridgeDaemon {
524
553
  return this.registerSocket(socket, message);
525
554
  }
526
555
 
527
- if (this.isAuthRequired() && !socket.__authenticated) {
528
- return this.rejectUnauthenticatedMessage(socket, message);
556
+ if (!socket.__role) {
557
+ return this.rejectUnregisteredMessage(socket, message);
529
558
  }
530
559
 
531
560
  if (message?.type === 'log') {
561
+ if (socket.__role !== 'extension') {
562
+ return this.rejectMessageForRole(socket, message);
563
+ }
532
564
  this.pushLog(message.entry ?? {});
533
565
  return;
534
566
  }
535
567
 
536
568
  if (message?.type === 'extension.response') {
569
+ if (socket.__role !== 'extension') {
570
+ return this.rejectMessageForRole(socket, message);
571
+ }
537
572
  return this.handleExtensionResponse(socket, message);
538
573
  }
539
574
 
540
575
  if (message?.type === 'extension.identity') {
576
+ if (socket.__role !== 'extension') {
577
+ return this.rejectMessageForRole(socket, message);
578
+ }
541
579
  return this.handleExtensionIdentity(socket, message);
542
580
  }
543
581
 
544
582
  if (message?.type === 'extension.access_update') {
583
+ if (socket.__role !== 'extension') {
584
+ return this.rejectMessageForRole(socket, message);
585
+ }
545
586
  return this.handleExtensionAccessUpdate(socket, message);
546
587
  }
547
588
 
548
589
  if (message?.type === 'extension.activity') {
590
+ if (socket.__role !== 'extension') {
591
+ return this.rejectMessageForRole(socket, message);
592
+ }
549
593
  return this.handleExtensionActivity(socket, message);
550
594
  }
551
595
 
552
596
  if (message?.type === 'extension.setup_status.request') {
597
+ if (socket.__role !== 'extension') {
598
+ return this.rejectMessageForRole(socket, message);
599
+ }
553
600
  return this.handleExtensionSetupStatus(socket, message);
554
601
  }
555
602
 
556
603
  if (message?.type === 'agent.request') {
604
+ const isExtensionSetupRequest =
605
+ socket.__role === 'extension' &&
606
+ (message.request?.method === 'setup.get_status' ||
607
+ message.request?.method === 'setup.install');
608
+ if (socket.__role !== 'agent' && !isExtensionSetupRequest) {
609
+ return this.rejectMessageForRole(socket, message);
610
+ }
557
611
  return this.handleAgentRequest(socket, message);
558
612
  }
559
613
 
@@ -571,7 +625,8 @@ export class BridgeDaemon {
571
625
  * @param {DaemonMessage} message
572
626
  * @returns {Promise<void>}
573
627
  */
574
- async rejectUnauthenticatedMessage(socket, message) {
628
+ async rejectUnregisteredMessage(socket, message) {
629
+ const authRequired = this.isAuthRequired();
575
630
  if (message?.type === 'agent.request') {
576
631
  const candidate =
577
632
  message.request && typeof message.request === 'object'
@@ -579,8 +634,10 @@ export class BridgeDaemon {
579
634
  : {};
580
635
  const response = createFailure(
581
636
  typeof candidate.id === 'string' && candidate.id.trim() ? candidate.id : 'unauthenticated',
582
- ERROR_CODES.ACCESS_DENIED,
583
- 'Register with the daemon auth token before sending bridge requests.',
637
+ authRequired ? ERROR_CODES.ACCESS_DENIED : ERROR_CODES.INVALID_REQUEST,
638
+ authRequired
639
+ ? 'Register with the daemon auth token before sending bridge requests.'
640
+ : 'Register the socket before sending bridge requests.',
584
641
  null,
585
642
  typeof candidate.method === 'string' ? { method: candidate.method } : {}
586
643
  );
@@ -591,8 +648,25 @@ export class BridgeDaemon {
591
648
  await writeJsonLine(socket, {
592
649
  type: 'error',
593
650
  error: {
594
- code: ERROR_CODES.ACCESS_DENIED,
595
- message: 'Register with the daemon auth token before sending bridge messages.',
651
+ code: authRequired ? ERROR_CODES.ACCESS_DENIED : ERROR_CODES.INVALID_REQUEST,
652
+ message: authRequired
653
+ ? 'Register with the daemon auth token before sending bridge messages.'
654
+ : 'Register the socket before sending bridge messages.',
655
+ },
656
+ });
657
+ }
658
+
659
+ /**
660
+ * @param {ClientSocket} socket
661
+ * @param {DaemonMessage} message
662
+ * @returns {Promise<void>}
663
+ */
664
+ async rejectMessageForRole(socket, message) {
665
+ await writeJsonLine(socket, {
666
+ type: 'error',
667
+ error: {
668
+ code: ERROR_CODES.INVALID_REQUEST,
669
+ message: `Message type ${JSON.stringify(message.type ?? null)} is not allowed for ${socket.__role} sockets.`,
596
670
  },
597
671
  });
598
672
  }
@@ -690,6 +764,17 @@ export class BridgeDaemon {
690
764
  }
691
765
 
692
766
  if (request.method === 'setup.install') {
767
+ if (socket.__role === 'agent' && !this.isLocalSocket(socket)) {
768
+ const response = createFailure(
769
+ request.id,
770
+ ERROR_CODES.ACCESS_DENIED,
771
+ 'setup.install is restricted to local Browser Bridge clients.',
772
+ null,
773
+ { method: request.method }
774
+ );
775
+ await writeJsonLine(socket, { type: 'agent.response', response });
776
+ return;
777
+ }
693
778
  try {
694
779
  const response = createSuccess(
695
780
  request.id,
@@ -718,40 +803,9 @@ export class BridgeDaemon {
718
803
  typeof request.meta?.target_profile === 'string' ? request.meta.target_profile : null;
719
804
  const hasExplicitTarget = Boolean(targetBrowser || targetProfile);
720
805
 
721
- /** @type {ClientSocket[]} */
722
- const targets = [];
723
- /** @type {ClientSocket | null} */
724
- let mostRecent = null;
725
- for (const extSocket of this.extensionSockets.values()) {
726
- if (targetBrowser || targetProfile) {
727
- if (targetBrowser && extSocket.__browserName !== targetBrowser) {
728
- continue;
729
- }
730
- if (targetProfile && extSocket.__profileLabel !== targetProfile) {
731
- continue;
732
- }
733
- targets.push(extSocket);
734
- continue;
735
- }
736
-
737
- if (extSocket.__accessEnabled) {
738
- targets.push(extSocket);
739
- continue;
740
- }
741
-
742
- if (
743
- !mostRecent ||
744
- (typeof extSocket.__lastActiveAt === 'number' ? extSocket.__lastActiveAt : 0) >
745
- (typeof mostRecent.__lastActiveAt === 'number' ? mostRecent.__lastActiveAt : 0)
746
- ) {
747
- mostRecent = extSocket;
748
- }
749
- }
750
- if (!hasExplicitTarget && targets.length === 0 && mostRecent) {
751
- targets.push(mostRecent);
752
- }
806
+ const target = this.selectExtensionTarget(targetBrowser, targetProfile);
753
807
 
754
- if (targets.length === 0) {
808
+ if (!target) {
755
809
  const response = createFailure(
756
810
  request.id,
757
811
  ERROR_CODES.EXTENSION_DISCONNECTED,
@@ -768,7 +822,7 @@ export class BridgeDaemon {
768
822
  method: request.method,
769
823
  protocolVersion: request.meta?.protocol_version,
770
824
  source: typeof request.meta?.source === 'string' ? request.meta.source : '',
771
- targets: new Set(targets),
825
+ targets: new Set([target]),
772
826
  timeoutId: setTimeout(() => {
773
827
  const pending = this.pendingRequests.get(request.id);
774
828
  if (!pending) return;
@@ -789,38 +843,101 @@ export class BridgeDaemon {
789
843
  message: error instanceof Error ? error.message : String(error),
790
844
  });
791
845
  });
792
- }, this.pendingTimeoutMs),
846
+ }, this.getPendingTimeoutMs(request)),
793
847
  });
794
848
  this.logger.info('request routed', {
795
849
  requestId: request.id,
796
850
  method: request.method,
797
851
  clientId: socket.__clientId ?? null,
798
- targetCount: targets.length,
852
+ targetCount: 1,
799
853
  });
800
- const broadcastPayload = { type: 'extension.request', request };
801
- await Promise.all(
802
- targets.map(async (extSocket) => {
803
- try {
804
- await writeJsonLine(extSocket, broadcastPayload);
805
- } catch (error) {
806
- this.logger.error('request route failed', {
807
- requestId: request.id,
808
- method: request.method,
809
- message: error instanceof Error ? error.message : String(error),
810
- });
811
- const pending = this.pendingRequests.get(request.id);
812
- if (!pending) {
813
- return;
814
- }
815
- this.removePendingTarget(request.id, pending, extSocket);
816
- if (extSocket.__extensionId) {
817
- this.extensionSockets.delete(extSocket.__extensionId);
818
- this.invalidateConnectedExtensionsCache();
819
- }
820
- extSocket.destroy(error instanceof Error ? error : undefined);
821
- await this.finishPendingRequestIfExhausted(request.id, pending);
822
- }
823
- })
854
+ try {
855
+ await writeJsonLine(target, { type: 'extension.request', request });
856
+ } catch (error) {
857
+ this.logger.error('request route failed', {
858
+ requestId: request.id,
859
+ method: request.method,
860
+ message: error instanceof Error ? error.message : String(error),
861
+ });
862
+ const pending = this.pendingRequests.get(request.id);
863
+ if (!pending) {
864
+ return;
865
+ }
866
+ this.removePendingTarget(request.id, pending, target);
867
+ if (target.__extensionId && this.extensionSockets.get(target.__extensionId) === target) {
868
+ this.extensionSockets.delete(target.__extensionId);
869
+ this.invalidateConnectedExtensionsCache();
870
+ }
871
+ target.destroy(error instanceof Error ? error : undefined);
872
+ await this.finishPendingRequestIfExhausted(request.id, pending);
873
+ }
874
+ }
875
+
876
+ /**
877
+ * @param {ClientSocket} socket
878
+ * @returns {boolean}
879
+ */
880
+ isLocalSocket(socket) {
881
+ if (this.transport.type === 'socket') {
882
+ return true;
883
+ }
884
+ const address = socket.remoteAddress ?? '';
885
+ return (
886
+ address === '127.0.0.1' ||
887
+ address === '::1' ||
888
+ address === 'localhost' ||
889
+ address === '::ffff:127.0.0.1'
890
+ );
891
+ }
892
+
893
+ /**
894
+ * Select from a snapshot so routing neither mutates nor observes mutations to
895
+ * the live extension map while candidates are ordered.
896
+ *
897
+ * @param {string | null} targetBrowser
898
+ * @param {string | null} targetProfile
899
+ * @returns {ClientSocket | null}
900
+ */
901
+ selectExtensionTarget(targetBrowser, targetProfile) {
902
+ const candidates = Array.from(this.extensionSockets.entries()).filter(([, extSocket]) => {
903
+ return (
904
+ (!targetBrowser || extSocket.__browserName === targetBrowser) &&
905
+ (!targetProfile || extSocket.__profileLabel === targetProfile)
906
+ );
907
+ });
908
+ candidates.sort(([leftId, left], [rightId, right]) => {
909
+ const accessDelta =
910
+ Number(Boolean(right.__accessEnabled)) - Number(Boolean(left.__accessEnabled));
911
+ if (accessDelta !== 0) {
912
+ return accessDelta;
913
+ }
914
+ const activityDelta =
915
+ (typeof right.__lastActiveAt === 'number' ? right.__lastActiveAt : 0) -
916
+ (typeof left.__lastActiveAt === 'number' ? left.__lastActiveAt : 0);
917
+ if (activityDelta !== 0) {
918
+ return activityDelta;
919
+ }
920
+ return leftId < rightId ? -1 : leftId > rightId ? 1 : 0;
921
+ });
922
+ return candidates[0]?.[1] ?? null;
923
+ }
924
+
925
+ /**
926
+ * @param {BridgeRequest} request
927
+ * @returns {number}
928
+ */
929
+ getPendingTimeoutMs(request) {
930
+ const configuredBase = Math.min(
931
+ MAX_DAEMON_PENDING_TIMEOUT_MS,
932
+ Math.max(1, this.pendingTimeoutMs)
933
+ );
934
+ const operationTimeout = request.params.timeoutMs;
935
+ if (typeof operationTimeout !== 'number' || !Number.isFinite(operationTimeout)) {
936
+ return configuredBase;
937
+ }
938
+ return Math.min(
939
+ MAX_DAEMON_PENDING_TIMEOUT_MS,
940
+ Math.max(configuredBase, operationTimeout + DAEMON_PENDING_TIMEOUT_MARGIN_MS)
824
941
  );
825
942
  }
826
943
 
@@ -922,7 +1039,7 @@ export class BridgeDaemon {
922
1039
  }
923
1040
 
924
1041
  const pending = this.pendingRequests.get(responseMessage.id);
925
- if (!pending) {
1042
+ if (!pending || !pending.targets.has(socket)) {
926
1043
  return;
927
1044
  }
928
1045
 
@@ -968,7 +1085,7 @@ export class BridgeDaemon {
968
1085
  return;
969
1086
  }
970
1087
 
971
- // Error response — wait for other extensions before forwarding.
1088
+ // A routed request has one target, so its error is final.
972
1089
  pending.lastErrorResponse = responseMessage;
973
1090
 
974
1091
  await this.finishPendingRequestIfExhausted(responseMessage.id, pending);
@@ -981,8 +1098,10 @@ export class BridgeDaemon {
981
1098
  handleSocketClose(socket) {
982
1099
  if (socket.__extensionId) {
983
1100
  this.logger.info('extension disconnected', { extensionId: socket.__extensionId });
984
- this.extensionSockets.delete(socket.__extensionId);
985
- this.invalidateConnectedExtensionsCache();
1101
+ if (this.extensionSockets.get(socket.__extensionId) === socket) {
1102
+ this.extensionSockets.delete(socket.__extensionId);
1103
+ this.invalidateConnectedExtensionsCache();
1104
+ }
986
1105
  }
987
1106
 
988
1107
  if (socket.__clientId) {
@@ -1151,11 +1270,14 @@ export async function pingExistingDaemon(transport) {
1151
1270
  });
1152
1271
 
1153
1272
  socket.once('connect', () => {
1154
- if (authToken) {
1155
- socket.write(
1156
- `${JSON.stringify({ type: 'register', role: 'agent', clientId: 'ping_probe', authToken })}\n`
1157
- );
1158
- }
1273
+ socket.write(
1274
+ `${JSON.stringify({
1275
+ type: 'register',
1276
+ role: 'agent',
1277
+ clientId: 'ping_probe',
1278
+ ...(authToken ? { authToken } : {}),
1279
+ })}\n`
1280
+ );
1159
1281
  socket.write(
1160
1282
  `${JSON.stringify({
1161
1283
  type: 'agent.request',
@@ -64,7 +64,7 @@ export const METHOD_CAPABILITIES = Object.freeze({
64
64
  'page.get_storage': CAPABILITIES.PAGE_READ,
65
65
  'page.get_text': CAPABILITIES.PAGE_READ,
66
66
  'page.get_network': CAPABILITIES.NETWORK_READ,
67
- // Interception mutates traffic (block/forge responses) — distinct from
67
+ // Interception mutates traffic (block/forge responses) - distinct from
68
68
  // passive network reads so hosts can gate it independently.
69
69
  'network.intercept.add': CAPABILITIES.NETWORK_INTERCEPT,
70
70
  'network.intercept.remove': CAPABILITIES.NETWORK_INTERCEPT,
@@ -30,9 +30,21 @@ export const MAX_JSON_LINE_BYTES = MAX_NATIVE_MESSAGE_BYTES;
30
30
  /** Default timeout for a bridge request awaiting an extension response (ms). */
31
31
  export const DEFAULT_DAEMON_PENDING_TIMEOUT_MS = 30_000;
32
32
 
33
+ /** Transport allowance added after an operation's own normalized timeout. */
34
+ export const DAEMON_PENDING_TIMEOUT_MARGIN_MS = 2_000;
35
+
36
+ /** Upper bound for a daemon request awaiting an extension response. */
37
+ export const MAX_DAEMON_PENDING_TIMEOUT_MS = 122_000;
38
+
33
39
  /** Default timeout for a BridgeClient request (ms). */
34
40
  export const DEFAULT_CLIENT_REQUEST_TIMEOUT_MS = 8_000;
35
41
 
42
+ /** Transport allowance added after an operation timeout on the client side. */
43
+ export const CLIENT_REQUEST_TIMEOUT_MARGIN_MS = 4_000;
44
+
45
+ /** Upper bound for a client request, kept beyond the daemon's maximum deadline. */
46
+ export const MAX_CLIENT_REQUEST_TIMEOUT_MS = 124_000;
47
+
36
48
  /** Maximum number of recent log entries retained by the daemon. */
37
49
  export const DAEMON_RECENT_LOG_LIMIT = 200;
38
50
 
@@ -64,6 +76,11 @@ export const DEBUGGER_BACKED_METHODS = new Set([
64
76
  'performance.get_metrics',
65
77
  'screenshot.capture_element',
66
78
  'screenshot.capture_region',
79
+ 'screenshot.capture_full_page',
80
+ 'network.intercept.add',
81
+ 'network.intercept.remove',
82
+ 'network.intercept.list',
83
+ 'network.intercept.clear',
67
84
  'cdp.get_document',
68
85
  'cdp.get_dom_snapshot',
69
86
  'cdp.get_box_model',
@@ -271,7 +271,23 @@ export function validateBridgeRequest(request) {
271
271
  ) {
272
272
  throw new BridgeError(ERROR_CODES.INVALID_REQUEST, 'Request params must be an object.');
273
273
  }
274
- const parsedTabId = Number(candidate.tab_id);
274
+ const hasTabId = Object.hasOwn(candidate, 'tab_id');
275
+ const candidateTabId = candidate.tab_id;
276
+ /** @type {number | null} */
277
+ let tabId = null;
278
+ if (hasTabId && candidateTabId !== null) {
279
+ if (
280
+ typeof candidateTabId !== 'number' ||
281
+ !Number.isSafeInteger(candidateTabId) ||
282
+ candidateTabId <= 0
283
+ ) {
284
+ throw new BridgeError(
285
+ ERROR_CODES.INVALID_REQUEST,
286
+ 'Request tab_id must be null or a positive safe integer.'
287
+ );
288
+ }
289
+ tabId = candidateTabId;
290
+ }
275
291
 
276
292
  const method = /** @type {BridgeMethod} */ (candidate.method);
277
293
  const params =
@@ -282,7 +298,7 @@ export function validateBridgeRequest(request) {
282
298
  return {
283
299
  id: candidate.id,
284
300
  method,
285
- tab_id: Number.isFinite(parsedTabId) && parsedTabId > 0 ? parsedTabId : null,
301
+ tab_id: tabId,
286
302
  params: normalizeRequestParams(method, params),
287
303
  meta: {
288
304
  ...meta,
@@ -345,6 +361,7 @@ function normalizeRequestParams(method, params) {
345
361
  case 'input.click':
346
362
  case 'input.focus':
347
363
  case 'input.type':
364
+ case 'input.fill':
348
365
  case 'input.press_key':
349
366
  return normalizeInputAction(params);
350
367
  case 'cdp.dispatch_key_event':
@@ -375,16 +392,45 @@ function normalizeRequestParams(method, params) {
375
392
  }
376
393
  }
377
394
 
395
+ /**
396
+ * Return the browser-operation timeout for methods that wait asynchronously.
397
+ * This lets clients and the daemon keep transport deadlines outside the
398
+ * normalized operation deadline, including when callers omit timeoutMs.
399
+ *
400
+ * @param {BridgeMethod} method
401
+ * @param {Record<string, unknown>} [params={}]
402
+ * @returns {number | null}
403
+ */
404
+ export function getBridgeOperationTimeoutMs(method, params = {}) {
405
+ switch (method) {
406
+ case 'navigation.navigate':
407
+ case 'navigation.reload':
408
+ case 'navigation.go_back':
409
+ case 'navigation.go_forward':
410
+ return normalizeNavigationAction(params).timeoutMs;
411
+ case 'page.wait_for_load_state':
412
+ return normalizeWaitForLoadStateParams(params).timeoutMs;
413
+ case 'page.evaluate':
414
+ return normalizeEvaluateParams(params).timeoutMs;
415
+ case 'dom.wait_for':
416
+ return clampInt(params.timeoutMs, 100, 30_000, 5_000);
417
+ default:
418
+ return null;
419
+ }
420
+ }
421
+
378
422
  /**
379
423
  * @param {DomQueryParams} [params={}]
380
424
  * @returns {NormalizedDomQuery}
381
425
  */
382
426
  export function normalizeDomQuery(params = {}) {
427
+ const nestedBudget =
428
+ params.budget && typeof params.budget === 'object' ? params.budget : undefined;
383
429
  return {
384
430
  selector:
385
431
  typeof params.selector === 'string' && params.selector.trim() ? params.selector : 'body',
386
432
  withinRef: typeof params.withinRef === 'string' ? params.withinRef : null,
387
- budget: applyBudget(params),
433
+ budget: applyBudget({ ...nestedBudget, ...params }),
388
434
  };
389
435
  }
390
436
 
@@ -663,9 +709,18 @@ export function normalizeConsoleParams(params = {}) {
663
709
  */
664
710
  export function normalizeWaitForParams(params = {}) {
665
711
  const validStates = ['attached', 'detached', 'visible', 'hidden'];
712
+ const selector =
713
+ typeof params.selector === 'string' && params.selector.trim() ? params.selector : '';
714
+ const text = typeof params.text === 'string' && params.text.trim() ? params.text : null;
715
+ if (!selector && text === null) {
716
+ throw new BridgeError(
717
+ ERROR_CODES.INVALID_REQUEST,
718
+ 'selector or text is required for dom.wait_for.'
719
+ );
720
+ }
666
721
  return {
667
- selector: typeof params.selector === 'string' && params.selector.trim() ? params.selector : '',
668
- text: params.text != null ? String(params.text) : null,
722
+ selector: selector || '*',
723
+ text,
669
724
  state: validStates.includes(String(params.state ?? ''))
670
725
  ? /** @type {'attached' | 'detached' | 'visible' | 'hidden'} */ (String(params.state))
671
726
  : 'attached',