@mefistofelix/cdp.js 0.6.0 → 0.7.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.
package/README.md CHANGED
@@ -43,6 +43,8 @@ In Bun, use `bun add @mefistofelix/cdp.js` and the ordinary package import above
43
43
  without browser remote-debugging arguments.
44
44
  - Generic `chrome.x.y` extension API calls on that connection, including scripting,
45
45
  tabs/windows, cookies, storage, downloads and browser settings.
46
+ - Reverse target management and XPath helpers default to browser APIs, with
47
+ optional debugger execution; explicit native CDP commands still execute.
46
48
  - Shared initialization for concurrent calls; reconnection and target recreation
47
49
  on subsequent use, without replaying failed commands.
48
50
  - Public object mappings and directly replaceable custom method handlers.
@@ -119,7 +121,7 @@ import api, { util, jsrpc, browser, cdp } from './cdp.js'
119
121
  | --- | --- |
120
122
  | `new cdp(options = {})` | Create a manager; does not launch a browser yet. |
121
123
  | `client.call({ method, params = {} })` | Resolve routing and return a promise for the native CDP result, or a custom handler's result. |
122
- | `client.evaluate_xpath(params, fn, options)` | Execute a serialized function with a normalized XPath in a target; return the native `Runtime.evaluate` result. |
124
+ | `client.evaluate_xpath(params, fn, options)` | Execute a serialized function with a normalized XPath. Direct/debugger mode returns native `Runtime.evaluate` data; reverse API mode returns `{ result: { type, value } }`. |
123
125
  | `client.close()` | Close owned processes, disconnect attached/reverse browsers, stop the reverse server and clear browser/socket mappings. Profiles and external browsers remain. |
124
126
  | `new jsrpc(urlOrSocket, options = {})` | Open a raw WebSocket JSON-RPC connection or equip an accepted socket with the same RPC methods. |
125
127
  | `client.listen_reverse(options = {})` | Start the native Deno or Bun WebSocket server; returns that server with common lifecycle fields documented below. |
@@ -529,6 +531,19 @@ the local URL and connects to the waiting manager. The user enters no extension
529
531
  ID or WebSocket address. Load the package's `cdp_ext/` once through
530
532
  `chrome://extensions` (developer mode, Load unpacked); reload it after upgrading.
531
533
 
534
+ After accepting a trigger, the extension records `cdp_ext.trigger` in its debug
535
+ sidebar and closes that trigger tab automatically, including when the server is
536
+ currently offline. If it is Chrome's only tab, it becomes the extension's
537
+ **CDP.js connection** page instead, keeping Chrome available for subsequent calls.
538
+ This reuses the debug view and displays the logical browser and live connected/
539
+ waiting status; it never claims a connection before one exists. No extra
540
+ tab is created. Other loopback pages are preserved: only the exact `/cdp_ext`
541
+ route with non-empty `browser`, `websocket_url` and `token` parameters and a
542
+ same-host/port `ws:` endpoint is accepted. The tab URL is checked again before
543
+ cleanup, so a tab already navigated elsewhere is left alone. Duplicate tab updates
544
+ reuse the trigger's diagnostic entry. A conflicting live browser binding is
545
+ reported and its trigger page is cleaned up without replacing the connection.
546
+
532
547
  ```js
533
548
  const client = new cdp({
534
549
  cdp_ext: true,
@@ -648,8 +663,9 @@ native send/close and live ready state. The same `jsrpc` handles both runtimes;
648
663
  registration, request IDs, pending waits, events and session routing are shared.
649
664
  Native Bun WebSocket limits still apply.
650
665
 
651
- Reverse calls reuse the normal target setup, custom handlers, mappings, routed
652
- events, request correlation, timeout and error behavior. `reverse: true` alone
666
+ Reverse calls share target selection, custom handlers, mappings, routed events,
667
+ request correlation, timeout and error behavior. Implicit CDP setup is opt-in
668
+ through `target.execution_mode: 'debugger'`. `reverse: true` alone
653
669
  attaches externally: the manager neither launches nor updates that browser.
654
670
  `cdp_ext_install: true` owns the launched process and applies normal launch preferences.
655
671
  With installation off, Chrome is external and remains open on manager close.
@@ -688,15 +704,27 @@ network diagnostic if the server disappears between the HTTP check and upgrade.
688
704
  ### Debug sidebar
689
705
 
690
706
  Click the extension's toolbar icon to open its native Chrome side panel.
707
+ If the icon is hidden, open Chrome's Extensions menu (the puzzle piece), find
708
+ **CDP reverse connection**, and pin it or click its entry. The panel opens on a
709
+ user action; loading the extension or connecting a browser does not open it.
710
+ After updating the package, an already running Chrome still has its previously
711
+ loaded extension: reload it in `chrome://extensions` after updating its directory,
712
+ or restart that custom-profile browser through a fresh manager. For a manually
713
+ installed extension, update its installed files too. Reopening an old side panel
714
+ alone does not upgrade the worker.
715
+
691
716
  It shows the current connection status, logical browser name, WebSocket endpoint,
692
- native extension ID and number of attached root/child sessions. It works before
717
+ native extension ID, number of routing sessions and number of debugger-attached
718
+ root tabs. A routing session does not imply debugger attachment. It works before
693
719
  the first trigger too, displaying **Waiting for trigger** without starting a
694
720
  server connection.
695
721
 
696
- The most recent 50 incoming commands appear newest first, with request ID,
697
- method, optional session ID, running/done/error status, elapsed milliseconds and
722
+ The most recent 50 commands and trigger entries appear newest first, with request ID,
723
+ method, optional session ID/execution mode, running/done/error status, elapsed milliseconds and
698
724
  error text. This is command metadata only: parameters, results and the trigger
699
- token are not included. Empty heartbeat messages and CDP events are not logged.
725
+ token are not included. Trigger entries show the tab ID, browser label, endpoint
726
+ and cleanup action; the full trigger URL is never recorded. Empty heartbeat
727
+ messages and CDP events are not logged.
700
728
  **Clear** empties this history without cancelling commands or changing the
701
729
  connection. Reopening the panel retrieves the current snapshot; a worker restart
702
730
  or extension reload clears the history. No debug history is written to storage.
@@ -736,8 +764,10 @@ Worker and out-of-process iframe commands still use real flattened CDP sessions.
736
764
  Their native session IDs pass through unchanged, without prefixes. The extension
737
765
  uses one `sessions` object for both roots and children: a tab ID maps to `{ tabId }`,
738
766
  and a native child session ID maps to `{ tabId, sessionId }`. Every target command
739
- looks up that object and passes the debuggee directly to `chrome.debugger.sendCommand`;
740
- there is no fallback that interprets unknown session IDs as tab IDs.
767
+ looks up that object. API-mode root selection only registers the routing reference;
768
+ an explicit session CDP command attaches its root lazily, sharing concurrent
769
+ attachment, then calls `chrome.debugger.sendCommand`. There is no fallback that
770
+ interprets unknown session IDs as tab IDs.
741
771
  Explicit detach takes `params.sessionId` for either a tab or child
742
772
  session. Logical target labels remain inside the manager. Reconnecting invalidates
743
773
  attachment/setup state even though a surviving root tab's routing ID stays the same.
@@ -752,12 +782,13 @@ tab and focuses its window. `Browser.getVersion` returns product/user-agent data
752
782
  its protocol version is `1.3`, with empty `revision` and `jsVersion` fields.
753
783
  Other browser-level commands return explicit unsupported-command errors.
754
784
 
755
- Target/session commands go through
785
+ Explicit native target/session CDP commands go through
756
786
  [chrome.debugger](https://developer.chrome.com/docs/extensions/reference/api/debugger),
757
787
  which supports a restricted set of CDP domains. Runtime, Page, Network, DOM,
758
788
  Input and Emulation are available; ServiceWorker and BackgroundService are not.
759
- Their default setup attempts therefore appear in `target.setup_errors`; disable
760
- those setup flags if desired. Chrome restrictions, policies and debugger detach
789
+ Their setup attempts in debugger target mode therefore appear in
790
+ `target.setup_errors`; disable those setup flags if desired. API targets skip
791
+ that setup. Chrome restrictions, policies and debugger detach
761
792
  events remain visible. Opening DevTools on an attached tab can detach this debugger.
762
793
  Firefox/Camoufox support remains planned.
763
794
 
@@ -1335,7 +1366,8 @@ await client.call({
1335
1366
  | `incognito` | `false` | Create/select a private target; direct mode uses the shared `'incognito'` context by default, reverse uses a native incognito window with the user's extension grant. |
1336
1367
  | `group` | unset | Reverse-only tab group name or first-use `{ name, color }` configuration. False skips grouping. |
1337
1368
  | `create_params` | `{}` | Native creation overrides, merged over `url: 'about:blank'` and `background: true`; start from a blank target without requesting foreground activation. |
1338
- | `initialize` | `true` | Run the convenience setup sequence; false skips it. |
1369
+ | `execution_mode` | `'api'` in reverse | Reverse-only choice for implicit target setup and built-in XPath helpers: `'api'` uses extension APIs; `'debugger'` attaches and runs CDP setup. Direct CDP ignores it. |
1370
+ | `initialize` | `true` | Run the convenience CDP setup sequence in direct/debugger mode; false skips it. API mode skips that sequence regardless. |
1339
1371
  | `runtime` | `'bootstrap'` | Enable Runtime for setup, then disable reporting. `true` leaves it enabled; `false` skips both operations. |
1340
1372
  | `page` | `true` | Enable Page domain reporting for navigation/page operations and events. |
1341
1373
  | `network` | `true` | Enable Network reporting so native network events are available. |
@@ -1344,6 +1376,102 @@ await client.call({
1344
1376
  | `binding` | `'_send_to_cdp'` | Default page-to-host JavaScript binding. String changes its name; false skips installation; true selects the default name. |
1345
1377
  | `background_service` | `true` | Clear, observe and record `pushMessaging` background-service events in Chromium. |
1346
1378
 
1379
+ ### Reverse execution mode
1380
+
1381
+ In extension mode (`cdp_ext: true` or passive `reverse: true`), a target defaults
1382
+ to `execution_mode: 'api'`. Creating/selecting it, grouping it and running
1383
+ `_.find`/`_.click` do not attach the debugger. Root tabs are created through
1384
+ `chrome.tabs`/`chrome.windows`; choosing debugger mode then attaches the selected
1385
+ tab before setup. Chrome's debugger API needs an existing debuggee.
1386
+
1387
+ | Operation | Reverse API target (default) | Reverse debugger target |
1388
+ | --- | --- | --- |
1389
+ | Target selection and root routing | Register the stable tab ID without debugger attachment. | Attach the selected tab and register the same routing ID. |
1390
+ | Implicit initialization | No Runtime/Page/Network enabling, binding, focus emulation, background-service setup or resume command. | Apply the documented initialization options and resume after setup. |
1391
+ | `_.find`, `_.click` | Fixed native `chrome.scripting.executeScript` functions in the isolated world; shared DOM, no string evaluation. | `Runtime.evaluate` in the page world. |
1392
+ | Explicit session CDP, such as `Runtime.evaluate` or `Page.navigate` | Attach lazily and execute the exact CDP request. | Execute the CDP request. |
1393
+ | Explicit `chrome.*` method | Invoke the named native extension API with the supplied positional arguments. | Same native extension API dispatch. |
1394
+
1395
+ The target option is recorded on first use of its logical name, like the other
1396
+ target options. It belongs in `params.target`, not constructor/browser defaults.
1397
+ `runtime: true`, `network: true` or a binding name alone do not opt an API target
1398
+ into debugger setup. To retain the previous reverse convenience initialization,
1399
+ set `target.execution_mode: 'debugger'` explicitly. Direct CDP defaults are unchanged.
1400
+ As with the existing XPath helper, `_.find` does not wait for navigation or retry;
1401
+ wait for your page's readiness before querying it.
1402
+
1403
+ ```js
1404
+ const found = await client.call({
1405
+ method: '_.find',
1406
+ params: {
1407
+ browser: {
1408
+ name: 'work',
1409
+ cdp_ext: true,
1410
+ },
1411
+ target: {
1412
+ name: 'search',
1413
+ create_params: {
1414
+ url: 'https://example.com/',
1415
+ },
1416
+ },
1417
+ xpath: '//h1',
1418
+ },
1419
+ })
1420
+ console.log(found.result.value)
1421
+
1422
+ // Select debugger execution for this helper call only.
1423
+ await client.call({
1424
+ method: '_.click',
1425
+ params: {
1426
+ browser: 'work',
1427
+ target: 'search',
1428
+ execution_mode: 'debugger',
1429
+ xpath: '//button',
1430
+ },
1431
+ })
1432
+
1433
+ // Native CDP executes even though the target's default remains API mode.
1434
+ await client.call({
1435
+ method: 'Runtime.evaluate',
1436
+ params: {
1437
+ browser: 'work',
1438
+ target: 'search',
1439
+ expression: 'document.title',
1440
+ },
1441
+ })
1442
+
1443
+ // A separate target with the full convenience debugger setup.
1444
+ await client.call({
1445
+ method: 'Page.navigate',
1446
+ params: {
1447
+ browser: 'work',
1448
+ target: {
1449
+ name: 'instrumented',
1450
+ execution_mode: 'debugger',
1451
+ runtime: true,
1452
+ },
1453
+ url: 'https://example.com/',
1454
+ },
1455
+ })
1456
+ ```
1457
+
1458
+ For the built-in helpers and `evaluate_xpath`, `params.execution_mode` overrides
1459
+ the target's choice for that call only; it does not enable optional target setup
1460
+ or change the recorded configuration. Valid reverse values are `'api'` and
1461
+ `'debugger'`. Native CDP and `chrome.*` methods keep their explicit dispatch and
1462
+ ignore this helper override; the router removes it from native parameters.
1463
+ Custom handlers still receive the original parameters and choose their own behavior.
1464
+
1465
+ API selection does not detach a debugger previously attached by an explicit
1466
+ command. Use `Target.detachFromTarget` explicitly if needed. After detachment,
1467
+ an API target can be selected and used through API helpers again without
1468
+ reattachment. Native CDP attachment by itself does not enable domains or install
1469
+ bindings; send the desired commands explicitly or choose debugger target setup.
1470
+ Only debugger-attached targets expose flattened worker/iframe sessions. Browser
1471
+ API helpers operate on the registered root tab's main frame; extension permissions
1472
+ and Chrome's restricted-page rules still apply. There is no automatic fallback
1473
+ from API execution to debugger execution.
1474
+
1347
1475
  ### Named tab groups
1348
1476
 
1349
1477
  With `cdp_ext: true` or external `reverse: true`, set `target.group` when first
@@ -1416,6 +1544,10 @@ and [grouping tabs](https://developer.chrome.com/docs/extensions/reference/api/t
1416
1544
 
1417
1545
  ### Target initialization
1418
1546
 
1547
+ The following CDP setup applies to direct mode and reverse targets configured
1548
+ with `execution_mode: 'debugger'`. Reverse API targets skip it entirely, including
1549
+ `Runtime.runIfWaitingForDebugger`; `initialize: false` does not opt into debugging.
1550
+
1419
1551
  Domain options set to false skip setup commands; they do not actively disable a
1420
1552
  domain already enabled by another caller. Likewise, `binding: false` does not
1421
1553
  remove a previously installed binding. Use native CDP commands for immediate
@@ -1488,10 +1620,16 @@ await loaded
1488
1620
  ## XPath custom methods
1489
1621
 
1490
1622
  Both built-ins require `params.target`; `params.browser` defaults to `main`
1491
- through the normal router. They use `Runtime.evaluate` with `returnByValue`,
1623
+ through the normal router. In direct/debugger execution they use `Runtime.evaluate` with `returnByValue`,
1492
1624
  `awaitPromise`, `silent` and `userGesture` set to true: serialize the result,
1493
1625
  await async page code, suppress evaluation exception reporting, and evaluate
1494
- with a user gesture respectively.
1626
+ with a user gesture respectively. In reverse API execution, fixed native scripting
1627
+ functions perform the same DOM operations in the isolated world, without eval,
1628
+ user-script permission or CSP changes. They keep `response.result.value` and the
1629
+ same click/find value shapes. API results contain `result.type` and `result.value`;
1630
+ they do not manufacture CDP remote-object metadata or `exceptionDetails`.
1631
+ Native injection errors reject; failed script execution also rejects. Debugger
1632
+ evaluation retains native `exceptionDetails` for page-side exceptions.
1495
1633
 
1496
1634
  | Method | Parameters | Remote result in `response.result.value` |
1497
1635
  | --- | --- | --- |
@@ -1536,10 +1674,19 @@ const text = await client.evaluate_xpath({
1536
1674
  console.log(text.result.value)
1537
1675
  ```
1538
1676
 
1539
- `params` supplies `browser`, `target` and `xpath`. `fn` is serialized and called in
1677
+ `params` supplies `browser`, `target`, `xpath` and optional per-call
1678
+ `execution_mode`. `fn` is serialized and called in
1540
1679
  the target as `fn(normalizedXPath, options)`; it cannot capture Node.js variables
1541
1680
  or imported helpers. `options` must be JSON-serializable; omission becomes null
1542
- in the argument array. Results retain native `exceptionDetails` if page code throws.
1681
+ in the argument array. Direct/debugger results retain native `exceptionDetails`
1682
+ if page code throws. In reverse API mode this arbitrary-function helper uses the
1683
+ existing string-function scripting adapter in the **MAIN** world and returns
1684
+ `{ result: { type, value } }`. Unlike the two fixed built-ins, arbitrary source
1685
+ evaluation requires the page CSP to allow eval. Injection errors or missing
1686
+ execution results reject; no debugger, CSP bypass or user-script toggle is enabled
1687
+ automatically. Choose `execution_mode: 'debugger'` explicitly when native CDP
1688
+ evaluation is required. Native `chrome.scripting.executeScript` calls retain their
1689
+ existing native world default and native result array.
1543
1690
 
1544
1691
  ### Register, replace or remove handlers
1545
1692
 
@@ -1645,6 +1792,7 @@ await client.call({
1645
1792
  params: {
1646
1793
  target: {
1647
1794
  name: 'bridge',
1795
+ execution_mode: 'debugger',
1648
1796
  binding: 'send_to_host',
1649
1797
  runtime: true,
1650
1798
  },
package/cdp.js CHANGED
@@ -320,7 +320,7 @@ const custom_methods = {
320
320
  if (i + 1 < options.attempts) await new Promise(resolve => setTimeout(resolve, options.interval_ms))
321
321
  }
322
322
  return false
323
- }, options)
323
+ }, options, '_.click')
324
324
  },
325
325
  '_.find'(params) {
326
326
  const limit = params.limit ?? 20
@@ -346,7 +346,7 @@ const custom_methods = {
346
346
  count: nodes.snapshotLength,
347
347
  items,
348
348
  }
349
- }, limit)
349
+ }, limit, '_.find')
350
350
  },
351
351
  }
352
352
 
@@ -472,20 +472,52 @@ export class cdp extends EventTarget {
472
472
  })
473
473
  }
474
474
 
475
- evaluate_xpath(params, fn, options) {
475
+ async evaluate_xpath(params, fn, options, method) {
476
476
  if (params.target == null) throw new Error('Custom method requires target')
477
477
  const xpath = util.normalize_xpath(params.xpath)
478
- return this.call({
478
+ const { record, entry } = await this._resolve(params)
479
+ const reverse = record.reverse || record.cdp_ext
480
+ const execution_mode = params.execution_mode ?? entry.execution_mode ?? 'api'
481
+ if (reverse && !['api', 'debugger'].includes(execution_mode)) throw new Error('execution_mode must be api or debugger')
482
+ if (reverse && execution_mode === 'api') {
483
+ if (method) return record.socket.req({
484
+ method,
485
+ params: {
486
+ xpath,
487
+ options,
488
+ },
489
+ sessionId: entry.sessionId,
490
+ })
491
+ const results = await record.socket.req({
492
+ method: 'chrome.scripting.executeScript',
493
+ params: {
494
+ args: [{
495
+ target: { tabId: Number(entry.targetId) },
496
+ world: 'MAIN',
497
+ func: 'async (...args) => ({ value: await (' + fn + ')(...args) })',
498
+ args: [xpath, options ?? null],
499
+ }],
500
+ },
501
+ })
502
+ const result = results[0]?.result
503
+ if (!result) throw new Error('Browser API evaluation failed; check page CSP and extension access. No debugger fallback was used.')
504
+ return {
505
+ result: {
506
+ type: typeof result.value,
507
+ value: result.value,
508
+ },
509
+ }
510
+ }
511
+ return record.socket.req({
479
512
  method: 'Runtime.evaluate',
480
513
  params: {
481
- browser: params.browser,
482
- target: params.target,
483
514
  expression: '(' + fn + ')(...' + JSON.stringify([xpath, options]) + ')',
484
515
  returnByValue: true,
485
516
  awaitPromise: true,
486
517
  silent: true,
487
518
  userGesture: true,
488
519
  },
520
+ sessionId: entry.sessionId,
489
521
  })
490
522
  }
491
523
 
@@ -513,7 +545,7 @@ export class cdp extends EventTarget {
513
545
  }
514
546
  const info = browser.target_info[targetId]
515
547
  if (message.method === 'Target.attachedToTarget') {
516
- this._detach(browser, info?.sessionId)
548
+ if (info?.sessionId !== sessionId) this._detach(browser, info?.sessionId)
517
549
  info.sessionId = sessionId
518
550
  browser.session_targets[sessionId] = targetId
519
551
  }
@@ -777,6 +809,8 @@ export class cdp extends EventTarget {
777
809
  async _target(browser, target) {
778
810
  const socket = browser.socket
779
811
  const reverse = browser.reverse || browser.cdp_ext
812
+ const execution_mode = target.execution_mode ?? 'api'
813
+ if (reverse && !['api', 'debugger'].includes(execution_mode)) throw new Error('execution_mode must be api or debugger')
780
814
  if (target.group != null && target.group !== false) {
781
815
  const spec = typeof target.group === 'string' ? { name: target.group } : target.group
782
816
  browser.groups[spec.name] ??= {
@@ -819,17 +853,23 @@ export class cdp extends EventTarget {
819
853
  if (!info.incognito) throw new Error('Existing target is not incognito')
820
854
  }
821
855
  if (target.group != null && target.group !== false) info.groupId = await this._group(browser, target)
822
- if (!info.sessionId) {
856
+ if (!info.sessionId || (reverse && execution_mode === 'debugger')) {
823
857
  info.sessionId = (await socket.req({
824
858
  method: 'Target.attachToTarget',
825
859
  params: {
826
860
  targetId: target.targetId,
827
861
  flatten: true,
862
+ ...(reverse ? { execution_mode } : {}),
828
863
  },
829
864
  })).sessionId
830
865
  browser.session_targets[info.sessionId] = target.targetId
831
866
  }
832
867
  const sessionId = info.sessionId
868
+ target.setup_errors = []
869
+ if (reverse && execution_mode === 'api') {
870
+ target.sessionId = sessionId
871
+ return
872
+ }
833
873
  const steps = []
834
874
  if (target.initialize !== false) {
835
875
  for (const [option, domain] of Object.entries({
@@ -853,7 +893,6 @@ export class cdp extends EventTarget {
853
893
  if ((target.runtime ?? 'bootstrap') === 'bootstrap') steps.push(['Runtime.disable'])
854
894
  }
855
895
  steps.push(['Runtime.runIfWaitingForDebugger'])
856
- target.setup_errors = []
857
896
  for (const [method, params] of steps) {
858
897
  try {
859
898
  await socket.req({
@@ -869,13 +908,7 @@ export class cdp extends EventTarget {
869
908
  target.sessionId = sessionId
870
909
  }
871
910
 
872
- async call({ method, params = {} }) {
873
- if (method.startsWith('_.')) {
874
- const handler = this.custom_methods[method]
875
- if (!handler) throw new Error('Unknown CDP extension: ' + method)
876
- return handler.call(this, params)
877
- }
878
- const { browser = 'main', target, ...native } = params
911
+ async _resolve({ browser = 'main', target }) {
879
912
  const spec = typeof browser === 'string' ? { name: browser } : browser
880
913
  const record = this.browsers[spec.name] ??= {
881
914
  user_data_dir: ((spec.reverse ?? this.options.reverse) || (spec.cdp_ext ?? this.options.cdp_ext)) ? null : path.join(this.base_path, spec.name),
@@ -933,6 +966,20 @@ export class cdp extends EventTarget {
933
966
  await entry.connecting
934
967
  }
935
968
  }
969
+ return {
970
+ record,
971
+ entry,
972
+ }
973
+ }
974
+
975
+ async call({ method, params = {} }) {
976
+ if (method.startsWith('_.')) {
977
+ const handler = this.custom_methods[method]
978
+ if (!handler) throw new Error('Unknown CDP extension: ' + method)
979
+ return handler.call(this, params)
980
+ }
981
+ const { browser, target, execution_mode, ...native } = params
982
+ const { record, entry } = await this._resolve(params)
936
983
  if (method === 'Target.disposeBrowserContext' && target == null) {
937
984
  const context = Object.values(record.contexts).find(value => value.browserContextId === native.browserContextId)
938
985
  if (context) return this._dispose_context(record, context, native)
@@ -15,6 +15,18 @@ let connection_message
15
15
  const debug_commands = []
16
16
  const debug_ports = []
17
17
 
18
+ function debug_record(fields) {
19
+ const record = {
20
+ time: Date.now(),
21
+ ...fields,
22
+ status: 'running',
23
+ }
24
+ debug_commands.unshift(record)
25
+ debug_commands.length = Math.min(debug_commands.length, 50)
26
+ debug_update()
27
+ return record
28
+ }
29
+
18
30
  function debug_update() {
19
31
  const state = {
20
32
  status: connection_message ?? (config.browser ? 'Connecting to server' : 'Waiting for trigger'),
@@ -23,6 +35,7 @@ function debug_update() {
23
35
  endpoint: config.websocket_url ?? '',
24
36
  extension_id: chrome.runtime.id,
25
37
  sessions: Object.keys(sessions).length,
38
+ debugger_tabs: Object.keys(attached).length,
26
39
  commands: debug_commands,
27
40
  }
28
41
  for (const port of debug_ports) {
@@ -67,31 +80,62 @@ async function bootstrap(tab) {
67
80
  await configured
68
81
  while (bootstrapping) await bootstrapping
69
82
  return bootstrapping = (async () => {
70
- const changed = ['browser', 'websocket_url', 'token'].some(key => settings[key] !== config[key])
71
- if (changed && config.browser && socket?.readyState < WebSocket.CLOSING) {
72
- const probe = new WebSocket(settings.websocket_url)
73
- probe.onopen = () => probe.send(JSON.stringify({
74
- browser: settings.browser,
75
- extension_id: chrome.runtime.id,
76
- token: settings.token,
77
- error: 'This Chrome profile is already connected as ' + config.browser,
78
- }))
79
- return
80
- }
81
- if (changed) {
82
- Object.assign(config, settings)
83
- connection_message = undefined
84
- debug_update()
85
- await chrome.storage.session.set({ cdp_bootstrap: settings })
86
- if (socket && socket.readyState !== WebSocket.CLOSED) {
87
- await new Promise(resolve => {
88
- socket.addEventListener('close', resolve, { once: true })
89
- socket.close()
90
- })
83
+ const tabs = await chrome.tabs.query({})
84
+ if (!tabs.some(current => current.id === tab.id && (current.pendingUrl ?? current.url) === text)) return
85
+ const trigger = debug_commands.find(record => record.method === 'cdp_ext.trigger' &&
86
+ record.tabId === tab.id && record.browser === settings.browser && record.endpoint === settings.websocket_url) ?? debug_record({
87
+ method: 'cdp_ext.trigger',
88
+ tabId: tab.id,
89
+ browser: settings.browser,
90
+ endpoint: settings.websocket_url,
91
+ })
92
+ try {
93
+ const changed = ['browser', 'websocket_url', 'token'].some(key => settings[key] !== config[key])
94
+ if (changed && config.browser && socket?.readyState < WebSocket.CLOSING) {
95
+ const probe = new WebSocket(settings.websocket_url)
96
+ trigger.error = 'This Chrome profile is already connected as ' + config.browser
97
+ probe.onopen = () => probe.send(JSON.stringify({
98
+ browser: settings.browser,
99
+ extension_id: chrome.runtime.id,
100
+ token: settings.token,
101
+ error: trigger.error,
102
+ }))
103
+ } else {
104
+ if (changed) {
105
+ Object.assign(config, settings)
106
+ connection_message = undefined
107
+ debug_update()
108
+ await chrome.storage.session.set({ cdp_bootstrap: settings })
109
+ if (socket && socket.readyState !== WebSocket.CLOSED) {
110
+ await new Promise(resolve => {
111
+ socket.addEventListener('close', resolve, { once: true })
112
+ socket.close()
113
+ })
114
+ }
115
+ await disconnecting
116
+ }
117
+ await connect()
91
118
  }
92
- await disconnecting
119
+ const current_tabs = await chrome.tabs.query({})
120
+ const current = current_tabs.find(current => current.id === tab.id)
121
+ if ((current?.pendingUrl ?? current?.url) === text) {
122
+ if (current_tabs.length === 1) {
123
+ await chrome.tabs.update(tab.id, { url: chrome.runtime.getURL('sidebar.html?connection') })
124
+ trigger.action = 'opened connection page'
125
+ } else {
126
+ await chrome.tabs.remove(tab.id)
127
+ trigger.action = 'closed tab'
128
+ }
129
+ }
130
+ trigger.status = trigger.error ? 'error' : 'done'
131
+ } catch (error) {
132
+ trigger.status = 'error'
133
+ trigger.error = error.message
134
+ throw error
135
+ } finally {
136
+ trigger.duration_ms = Date.now() - trigger.time
137
+ debug_update()
93
138
  }
94
- await connect()
95
139
  })().finally(() => {
96
140
  bootstrapping = null
97
141
  })
@@ -114,7 +158,7 @@ function target_info(tab) {
114
158
  }
115
159
 
116
160
  function detached(source) {
117
- if (!attached[source.tabId]) return
161
+ if (!sessions[String(source.tabId)]) return
118
162
  delete attached[source.tabId]
119
163
  for (const [id, session] of Object.entries(sessions)) {
120
164
  if (session.tabId === source.tabId) delete sessions[id]
@@ -210,6 +254,12 @@ const methods = {
210
254
  },
211
255
  async 'Target.attachToTarget'(params) {
212
256
  const tabId = Number(params.targetId)
257
+ if (params.execution_mode === 'api') {
258
+ await chrome.tabs.get(tabId)
259
+ sessions[String(tabId)] = { tabId }
260
+ debug_update()
261
+ return { sessionId: String(tabId) }
262
+ }
213
263
  if (attaching[tabId]) return attaching[tabId]
214
264
  if (attached[tabId]) return { sessionId: String(tabId) }
215
265
  return attaching[tabId] = attach_tab(tabId).finally(() => delete attaching[tabId])
@@ -220,7 +270,7 @@ const methods = {
220
270
  if (source.sessionId) {
221
271
  await chrome.debugger.sendCommand({ tabId: source.tabId }, 'Target.detachFromTarget', { sessionId: source.sessionId })
222
272
  } else {
223
- await chrome.debugger.detach(source)
273
+ if (attached[source.tabId]) await chrome.debugger.detach(source)
224
274
  detached(source)
225
275
  }
226
276
  },
@@ -266,6 +316,7 @@ chrome.tabs.onUpdated.addListener((id, changes, tab) => {
266
316
  })
267
317
  })
268
318
  chrome.tabs.onRemoved.addListener(id => {
319
+ detached({ tabId: id })
269
320
  if (discovering) send({
270
321
  method: 'Target.targetDestroyed',
271
322
  params: { targetId: String(id) },
@@ -333,16 +384,12 @@ async function connect() {
333
384
  if (!event.data) return
334
385
  const request = JSON.parse(event.data)
335
386
  const { method, params = {}, sessionId, id } = request
336
- const command = {
337
- time: Date.now(),
387
+ const command = debug_record({
338
388
  id,
339
389
  method,
340
390
  sessionId,
341
- status: 'running',
342
- }
343
- debug_commands.unshift(command)
344
- debug_commands.length = Math.min(debug_commands.length, 50)
345
- debug_update()
391
+ execution_mode: params.execution_mode,
392
+ })
346
393
  let response
347
394
  try {
348
395
  let result
@@ -363,11 +410,62 @@ async function connect() {
363
410
  else resolve(value)
364
411
  }))
365
412
  : await object[name](...args)
413
+ } else if (method === '_.click' || method === '_.find') {
414
+ const source = sessions[sessionId]
415
+ if (!source || source.sessionId) throw new Error('XPath browser API commands require a registered root tab')
416
+ const results = await chrome.scripting.executeScript({
417
+ target: { tabId: source.tabId },
418
+ args: [method, params.xpath, params.options],
419
+ func: async (method, xpath, options) => {
420
+ if (method === '_.click') {
421
+ for (let i = 0; i < options.attempts; i++) {
422
+ const node = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue
423
+ if (node) {
424
+ if (typeof node.click !== 'function') return false
425
+ node.click()
426
+ return true
427
+ }
428
+ if (i + 1 < options.attempts) await new Promise(resolve => setTimeout(resolve, options.interval_ms))
429
+ }
430
+ return false
431
+ }
432
+ const nodes = document.evaluate(xpath, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
433
+ const items = []
434
+ for (let i = 0; i < Math.min(nodes.snapshotLength, options); i++) {
435
+ const node = nodes.snapshotItem(i)
436
+ items.push({
437
+ node_type: node.nodeType,
438
+ node_name: node.nodeName,
439
+ text: String(node.innerText ?? node.textContent ?? '').trim().slice(0, 2000),
440
+ tag: node.tagName?.toLowerCase(),
441
+ id: node.id,
442
+ class: node.className == null ? undefined : String(node.className),
443
+ href: node.href,
444
+ value: node.value,
445
+ rect: node.getBoundingClientRect?.().toJSON(),
446
+ })
447
+ }
448
+ return {
449
+ count: nodes.snapshotLength,
450
+ items,
451
+ }
452
+ },
453
+ })
454
+ if (!Object.hasOwn(results[0] ?? {}, 'result')) throw new Error('XPath browser API execution failed')
455
+ result = {
456
+ result: {
457
+ type: typeof results[0].result,
458
+ value: results[0].result,
459
+ },
460
+ }
366
461
  } else {
367
462
  if (sessionId == null && !methods[method]) throw new Error('Unsupported reverse browser command: ' + method)
368
- result = sessionId != null
369
- ? await chrome.debugger.sendCommand(sessions[sessionId], method, params)
370
- : await methods[method](params)
463
+ if (sessionId != null) {
464
+ const source = sessions[sessionId]
465
+ if (!source) throw new Error('Unknown debugger session: ' + sessionId)
466
+ await methods['Target.attachToTarget']({ targetId: String(source.tabId) })
467
+ result = await chrome.debugger.sendCommand(source, method, params)
468
+ } else result = await methods[method](params)
371
469
  }
372
470
  response = {
373
471
  id,
@@ -391,10 +489,11 @@ async function connect() {
391
489
  connection.onclose = () => {
392
490
  report_connection('Waiting for CDP.js server at ' + endpoint + '; retrying automatically.')
393
491
  discovering = false
394
- disconnecting = Promise.all(Object.keys(attached).map(id => {
492
+ disconnecting = Promise.all(Object.keys(sessions).filter(id => !sessions[id].sessionId).map(id => {
395
493
  const source = { tabId: Number(id) }
494
+ const was_attached = attached[id]
396
495
  detached(source)
397
- return chrome.debugger.detach(source).catch(() => {})
496
+ if (was_attached) return chrome.debugger.detach(source).catch(() => {})
398
497
  })).finally(() => disconnecting = null)
399
498
  }
400
499
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "CDP reverse connection",
4
- "version": "0.6.0",
4
+ "version": "0.7.0",
5
5
  "minimum_chrome_version": "125",
6
6
  "permissions": [
7
7
  "accessibilityFeatures.read",
@@ -20,16 +20,18 @@
20
20
  small { display: block; opacity: .7; margin-top: 5px; font-size: 12px; }
21
21
  .error { color: light-dark(#b42318, #ff938a); white-space: pre-wrap; overflow-wrap: anywhere; }
22
22
  </style>
23
- <header><h1>CDP.js debug</h1><button id="clear" type="button" title="Clear command history">Clear</button></header>
23
+ <header><h1>CDP.js debug</h1><button id="clear" type="button" title="Clear diagnostic history">Clear</button></header>
24
+ <p id="connection-message" hidden>Browser bridge configured. Live connection status is shown below.</p>
24
25
  <p id="status" role="status">Connecting to extension…</p>
25
26
  <dl>
26
27
  <dt>Browser</dt><dd id="browser">—</dd>
27
28
  <dt>Server</dt><dd id="endpoint">—</dd>
28
29
  <dt>Extension</dt><dd id="extension">—</dd>
29
- <dt>Attached sessions</dt><dd id="sessions">0</dd>
30
+ <dt>Routing sessions</dt><dd id="sessions">0</dd>
31
+ <dt>Debugger tabs</dt><dd id="debugger_tabs">0</dd>
30
32
  </dl>
31
- <h2>Recent commands <small>Latest 50 · memory only</small></h2>
32
- <p id="empty">No commands received.</p>
33
+ <h2>Recent activity <small>Latest 50 · memory only</small></h2>
34
+ <p id="empty">No activity received.</p>
33
35
  <ol id="commands"></ol>
34
36
  <script src="sidebar.js"></script>
35
37
  </html>
@@ -1,6 +1,11 @@
1
1
  const port = chrome.runtime.connect({ name: 'cdp_debug' })
2
2
  const status = document.querySelector('#status')
3
3
  const heartbeat = setInterval(() => port.postMessage('ping'), 20000)
4
+ if (location.search === '?connection') {
5
+ document.title = 'CDP.js connection'
6
+ document.querySelector('h1').textContent = document.title
7
+ document.querySelector('#connection-message').hidden = false
8
+ }
4
9
 
5
10
  port.onMessage.addListener(state => {
6
11
  status.textContent = state.status
@@ -10,6 +15,7 @@ port.onMessage.addListener(state => {
10
15
  endpoint: state.endpoint,
11
16
  extension: state.extension_id,
12
17
  sessions: state.sessions,
18
+ debugger_tabs: state.debugger_tabs ?? 0,
13
19
  })) document.getElementById(id).textContent = value === '' ? '—' : value
14
20
  document.querySelector('#empty').hidden = state.commands.length > 0
15
21
  document.querySelector('#commands').replaceChildren(...state.commands.map(command => {
@@ -19,9 +25,14 @@ port.onMessage.addListener(state => {
19
25
  const details = document.createElement('small')
20
26
  details.textContent = [
21
27
  new Date(command.time).toLocaleTimeString(),
22
- '#' + (command.id ?? 'notification'),
28
+ command.id == null ? (command.method === 'cdp_ext.trigger' ? 'trigger' : 'notification') : '#' + command.id,
23
29
  command.sessionId ? 'session ' + command.sessionId : 'browser',
24
30
  command.status,
31
+ command.execution_mode,
32
+ command.tabId == null ? '' : 'tab ' + command.tabId,
33
+ command.browser,
34
+ command.endpoint,
35
+ command.action,
25
36
  command.duration_ms == null ? '' : command.duration_ms + ' ms',
26
37
  ].filter(Boolean).join(' · ')
27
38
  row.append(method, details)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mefistofelix/cdp.js",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Low-level Chromium DevTools Protocol client for Node.js, Deno and Bun, with no runtime dependencies.",
5
5
  "type": "module",
6
6
  "exports": "./cdp.js",