@mefistofelix/cdp.js 0.6.0 → 0.8.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. |
@@ -244,6 +246,7 @@ local configuration.
244
246
  | `websocket_url` | string, unset | Attach directly to a browser WebSocket endpoint. |
245
247
  | `cdp_ext` | boolean, `false` | Use the extension transport under Deno/Bun: start the shared reverse listener and open a local bootstrap URL containing the logical browser name and WebSocket endpoint. No extension ID is required. |
246
248
  | `cdp_ext_install` | boolean, presence of an effective user-data directory | Prepare an extension copy and startup loading arguments. Defaults on for an explicit custom directory, off for the system profile. `false` uses an already installed extension. Only applies with `cdp_ext: true`. |
249
+ | `cdp_ext_user_scripts` | boolean, `cdp_ext_install` plus an effective custom directory | Automatically enable Allow User Scripts for the managed bridge. False preserves the current grant. True requires installation and a custom directory; system/passive browsers use manual consent. See activation below. |
247
250
  | `reverse` | boolean, `false` | Attach to an externally launched extension instead of launching or discovering a browser. Starts the shared listener on first use under Deno or Bun. |
248
251
  | `extension_id` | string, browser name for passive `reverse: true` attachment | Optional legacy incoming extension identity. Not needed with `cdp_ext: true`; observed automatically from the connection. |
249
252
  | `http_url` | string, unset | Discover the endpoint from an HTTP(S) base URL or exact `/json/version` URL. |
@@ -529,6 +532,19 @@ the local URL and connects to the waiting manager. The user enters no extension
529
532
  ID or WebSocket address. Load the package's `cdp_ext/` once through
530
533
  `chrome://extensions` (developer mode, Load unpacked); reload it after upgrading.
531
534
 
535
+ After accepting a trigger, the extension records `cdp_ext.trigger` in its debug
536
+ sidebar and closes that trigger tab automatically, including when the server is
537
+ currently offline. If it is Chrome's only tab, it becomes the extension's
538
+ **CDP.js connection** page instead, keeping Chrome available for subsequent calls.
539
+ This reuses the debug view and displays the logical browser and live connected/
540
+ waiting status; it never claims a connection before one exists. No extra
541
+ tab is created. Other loopback pages are preserved: only the exact `/cdp_ext`
542
+ route with non-empty `browser`, `websocket_url` and `token` parameters and a
543
+ same-host/port `ws:` endpoint is accepted. The tab URL is checked again before
544
+ cleanup, so a tab already navigated elsewhere is left alone. Duplicate tab updates
545
+ reuse the trigger's diagnostic entry. A conflicting live browser binding is
546
+ reported and its trigger page is cleaned up without replacing the connection.
547
+
532
548
  ```js
533
549
  const client = new cdp({
534
550
  cdp_ext: true,
@@ -648,8 +664,9 @@ native send/close and live ready state. The same `jsrpc` handles both runtimes;
648
664
  registration, request IDs, pending waits, events and session routing are shared.
649
665
  Native Bun WebSocket limits still apply.
650
666
 
651
- Reverse calls reuse the normal target setup, custom handlers, mappings, routed
652
- events, request correlation, timeout and error behavior. `reverse: true` alone
667
+ Reverse calls share target selection, custom handlers, mappings, routed events,
668
+ request correlation, timeout and error behavior. Implicit CDP setup is opt-in
669
+ through `target.execution_mode: 'debugger'`. `reverse: true` alone
653
670
  attaches externally: the manager neither launches nor updates that browser.
654
671
  `cdp_ext_install: true` owns the launched process and applies normal launch preferences.
655
672
  With installation off, Chrome is external and remains open on manager close.
@@ -688,15 +705,27 @@ network diagnostic if the server disappears between the HTTP check and upgrade.
688
705
  ### Debug sidebar
689
706
 
690
707
  Click the extension's toolbar icon to open its native Chrome side panel.
708
+ If the icon is hidden, open Chrome's Extensions menu (the puzzle piece), find
709
+ **CDP reverse connection**, and pin it or click its entry. The panel opens on a
710
+ user action; loading the extension or connecting a browser does not open it.
711
+ After updating the package, an already running Chrome still has its previously
712
+ loaded extension: reload it in `chrome://extensions` after updating its directory,
713
+ or restart that custom-profile browser through a fresh manager. For a manually
714
+ installed extension, update its installed files too. Reopening an old side panel
715
+ alone does not upgrade the worker.
716
+
691
717
  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
718
+ native extension ID, number of routing sessions and number of debugger-attached
719
+ root tabs. A routing session does not imply debugger attachment. It works before
693
720
  the first trigger too, displaying **Waiting for trigger** without starting a
694
721
  server connection.
695
722
 
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
723
+ The most recent 50 commands and trigger entries appear newest first, with request ID,
724
+ method, optional session ID/execution mode, running/done/error status, elapsed milliseconds and
698
725
  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.
726
+ token are not included. Trigger entries show the tab ID, browser label, endpoint
727
+ and cleanup action; the full trigger URL is never recorded. Empty heartbeat
728
+ messages and CDP events are not logged.
700
729
  **Clear** empties this history without cancelling commands or changing the
701
730
  connection. Reopening the panel retrieves the current snapshot; a worker restart
702
731
  or extension reload clears the history. No debug history is written to storage.
@@ -736,8 +765,10 @@ Worker and out-of-process iframe commands still use real flattened CDP sessions.
736
765
  Their native session IDs pass through unchanged, without prefixes. The extension
737
766
  uses one `sessions` object for both roots and children: a tab ID maps to `{ tabId }`,
738
767
  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.
768
+ looks up that object. API-mode root selection only registers the routing reference;
769
+ an explicit session CDP command attaches its root lazily, sharing concurrent
770
+ attachment, then calls `chrome.debugger.sendCommand`. There is no fallback that
771
+ interprets unknown session IDs as tab IDs.
741
772
  Explicit detach takes `params.sessionId` for either a tab or child
742
773
  session. Logical target labels remain inside the manager. Reconnecting invalidates
743
774
  attachment/setup state even though a surviving root tab's routing ID stays the same.
@@ -752,12 +783,13 @@ tab and focuses its window. `Browser.getVersion` returns product/user-agent data
752
783
  its protocol version is `1.3`, with empty `revision` and `jsVersion` fields.
753
784
  Other browser-level commands return explicit unsupported-command errors.
754
785
 
755
- Target/session commands go through
786
+ Explicit native target/session CDP commands go through
756
787
  [chrome.debugger](https://developer.chrome.com/docs/extensions/reference/api/debugger),
757
788
  which supports a restricted set of CDP domains. Runtime, Page, Network, DOM,
758
789
  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
790
+ Their setup attempts in debugger target mode therefore appear in
791
+ `target.setup_errors`; disable those setup flags if desired. API targets skip
792
+ that setup. Chrome restrictions, policies and debugger detach
761
793
  events remain visible. Opening DevTools on an attached tab can detach this debugger.
762
794
  Firefox/Camoufox support remains planned.
763
795
 
@@ -893,7 +925,8 @@ every past attachment or identify the caller by itself.
893
925
  For native script-text injection without changing the page's CSP,
894
926
  `chrome.userScripts.execute` (Chrome 135+) accepts `js[].code`. Its default
895
927
  `USER_SCRIPT` world shares the DOM but has separate JavaScript globals and is exempt
896
- from the page's CSP. `MAIN` instead shares the page's globals and CSP.
928
+ from the page's CSP. `MAIN` shares the page's globals; source injection and later
929
+ calls to eval/new Function have different CSP behavior, explained below.
897
930
 
898
931
  ```js
899
932
  const frames = await client.call({
@@ -913,18 +946,102 @@ const frames = await client.call({
913
946
  console.log(frames[0].result) // 42
914
947
  ```
915
948
 
916
- The manifest includes the permission, but Chrome also requires explicit user
917
- activation: Developer mode before Chrome 138, or **Allow User Scripts** on the
918
- extension's details page from Chrome 138 onward. Open
919
- `chrome://extensions/?id=<extension_id>` using `client.browsers.work.extension_id`.
920
- If necessary, reload the extension after enabling the toggle. Neither `cdp_ext: true`
921
- nor `extensions` enables it or adds a default profile setting. A previously enabled
922
- profile retains its choice. Chrome stores the extension-specific
923
- `extensions.settings.<extension_id>.user_scripts_enabled` setting; on Windows it
924
- is in protected `Secure Preferences`, and simply writing the key into a fresh
925
- profile did not enable access in our test. Use Chrome's toggle rather than assuming
926
- a normal `preferences` entry can update protected extension settings.
927
- See the native [userScripts API and activation steps](https://developer.chrome.com/docs/extensions/reference/api/userScripts).
949
+ ### Arbitrary client code in MAIN, without debugger
950
+
951
+ `chrome.userScripts.execute` also accepts `world: 'MAIN'`. With user-script access
952
+ enabled, Chrome compiles the supplied `js[].code` directly in the page's JavaScript
953
+ world. The source can arrive from the JSON-RPC client at runtime. It can read/write
954
+ page globals and the DOM, including on a page with `script-src 'none'`, without
955
+ debugger attachment or changing the page's CSP:
956
+
957
+ ```js
958
+ const frames = await client.call({
959
+ method: 'chrome.userScripts.execute',
960
+ params: {
961
+ browser: 'work',
962
+ args: [{
963
+ target: { tabId },
964
+ world: 'MAIN',
965
+ js: [{
966
+ code: 'window.clientValue = 42; document.querySelector("h1").dataset.source = "client"; Promise.resolve(window.clientValue)',
967
+ }],
968
+ }],
969
+ },
970
+ })
971
+ console.log(frames[0].result) // 42
972
+ ```
973
+
974
+ This is native source injection, not a call to `eval`. If the injected source
975
+ itself calls `eval` or `new Function` in MAIN, those operations still obey the
976
+ page's CSP and can remain blocked. The Allow User Scripts toggle enables this
977
+ API; it does not globally enable string evaluation in MAIN, the worker, or normal
978
+ `chrome.scripting.executeScript` calls. No additional RPC method is needed.
979
+
980
+ In `USER_SCRIPT`, the DOM is shared but page JavaScript globals are separate.
981
+ That world's own CSP can permit eval/new Function through
982
+ `chrome.userScripts.configureWorld`, with a CSP such as
983
+ `script-src 'self' 'unsafe-eval'; object-src 'self'`. Configure the world before its
984
+ first injection into a document, or configure a fresh `worldId` and use that ID
985
+ when injecting. Changing the configuration after a world already exists did not
986
+ relax that existing world's CSP in our Chrome test. `configureWorld` does not
987
+ configure MAIN's CSP. Plain `js[].code` injection needs neither eval nor this
988
+ additional world configuration.
989
+
990
+ These behaviors were verified on real Chrome with a restrictive page CSP:
991
+ MAIN source execution returned a Promise result, shared page globals and changed
992
+ the DOM while the tab remained debugger-detached; eval and new Function inside
993
+ that MAIN source remained blocked. The native API and result arrays already pass
994
+ through the bridge unchanged. See [Chrome userScripts](https://developer.chrome.com/docs/extensions/reference/api/userScripts).
995
+ The migration discussion in [Tampermonkey #644](https://github.com/Tampermonkey/tampermonkey/issues/644#issuecomment-2094925643)
996
+ and its [Content Script API documentation](https://www.tampermonkey.net/documentation.php?locale=en&q=content_script_api)
997
+ describe the corresponding native user-script injection mechanism.
998
+
999
+ ### User-script consent: system and managed profiles
1000
+
1001
+ The shipped manifest already declares `userScripts`, but a manifest permission
1002
+ alone cannot turn on Chrome's user consent. Before Chrome 138 the gate is Developer
1003
+ mode; from Chrome 138 it is **Allow User Scripts** for the individual extension.
1004
+
1005
+ For a **system profile**, passive `reverse: true`, or `cdp_ext_install: false`,
1006
+ enable it manually in `chrome://extensions/?id=<extension_id>` using the observed
1007
+ `client.browsers.work.extension_id`. This is a per-extension/profile choice, not
1008
+ something to repeat at every connection. Reload the extension if the API is still
1009
+ unavailable after enabling it. Use the shipped `cdp_ext/` folder for manual installs.
1010
+
1011
+ For **managed custom profiles**, `cdp_ext_user_scripts` defaults to true when
1012
+ `cdp_ext: true`, `cdp_ext_install: true` and an effective custom user-data directory
1013
+ are all present. Effective raw directory overrides count, including null/false
1014
+ overrides that remove the directory. Before announcing a new bootstrap binding,
1015
+ the bridge checks access and, if needed, uses Chrome's own extensions-settings UI
1016
+ API to enable it for its own extension. It briefly opens an inactive internal
1017
+ settings tab, runs a fixed native scripting function, and closes that tab. This
1018
+ uses no debugger and no remote-debugging flags.
1019
+
1020
+ This developer setup needs generated `extensions-on-chrome-urls: true` and
1021
+ `DisableExtensionsOnChromeUrlsSwitch` in `disable-features`. The manager adds
1022
+ `chrome://extensions/*` only to the **managed copy** of the manifest. The shipped
1023
+ manifest remains suitable for a normal manual installation. The worker and sidebar
1024
+ files are copied unchanged. Chrome writes its own protected permission settings;
1025
+ the library does not edit `Secure Preferences` or compute preference signatures.
1026
+ Legacy Chrome uses the internal Developer mode setting instead of the newer
1027
+ per-extension setting; the automatic path is tested on Chrome 154.
1028
+
1029
+ Set `cdp_ext_user_scripts: false` to skip the permission step, extra host access
1030
+ and development flags. Existing grants are preserved. Explicit true without both
1031
+ installation and a custom directory rejects before launching; it never infers or
1032
+ modifies the system profile. `update_preferences: false` controls JSON file writes
1033
+ independently and does not turn off this native permission setup. Raw CLI overrides
1034
+ still win; disabling a required switch can make setup fail. Such failures reach
1035
+ the caller through the initial reverse announcement and appear in the local
1036
+ trigger history, rather than being reported as a successful connection.
1037
+ An already running Chrome cannot adopt new command-line capabilities or a changed
1038
+ loaded manifest; restart that custom-profile browser after enabling this option.
1039
+
1040
+ The internal settings API and developer switches are Chrome implementation
1041
+ details; unsupported browsers/policies produce errors. Simply writing
1042
+ `extensions.settings.<extension_id>.user_scripts_enabled` into `Preferences` did
1043
+ not enable access in our Windows test because Chrome protects these settings.
1044
+ See the native [activation steps](https://developer.chrome.com/docs/extensions/reference/api/userScripts).
928
1045
 
929
1046
  Chrome's native `scripting.executeScript` still rejects strings supplied directly
930
1047
  as `func`; the bridge's fixed function makes this adaptation possible. Overriding
@@ -1276,6 +1393,10 @@ See [Chrome's debugging-port restrictions](https://developer.chrome.com/blog/rem
1276
1393
  | `login: true` plus `args['disable-sync']: true` | Allow profile sign-in through generated preferences while still requesting sync to be disabled. |
1277
1394
  | Any high-level settings plus a raw `disable-features` array | Replace the generated list, including translation/sign-in additions; an active `disable-extensions-except` still appends its required feature ID. |
1278
1395
  | `user_data_dir` plus `args['user-data-dir']` | The raw argument determines both the profile write location and the launched browser directory. |
1396
+ | Managed extension plus effective custom directory | Default `cdp_ext_user_scripts` to true; grant through Chrome's own UI API before accepting the reverse binding. |
1397
+ | `cdp_ext_user_scripts: false` plus an existing grant | Preserve the grant, skip the permission step and omit its extra generated flags/manifest host. |
1398
+ | `cdp_ext_user_scripts: true` without custom directory or installation | Reject before launch; the system profile remains a manual choice. |
1399
+ | `update_preferences: false` plus automatic user-script consent | Skip JSON preference-file writes; still let Chrome apply its native per-extension grant. |
1279
1400
  | `port` plus `args['remote-debugging-port']` | Attach using `port`; no launch occurs, so the raw launch argument is unused. |
1280
1401
  | Constructor options plus an existing browser label | Reuse the stored record; configuration is not merged again on every call. |
1281
1402
  | `initialize: false` plus `runtime: true` or a binding name | Skip optional target setup, including domain enabling and binding installation. |
@@ -1335,7 +1456,8 @@ await client.call({
1335
1456
  | `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
1457
  | `group` | unset | Reverse-only tab group name or first-use `{ name, color }` configuration. False skips grouping. |
1337
1458
  | `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. |
1459
+ | `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. |
1460
+ | `initialize` | `true` | Run the convenience CDP setup sequence in direct/debugger mode; false skips it. API mode skips that sequence regardless. |
1339
1461
  | `runtime` | `'bootstrap'` | Enable Runtime for setup, then disable reporting. `true` leaves it enabled; `false` skips both operations. |
1340
1462
  | `page` | `true` | Enable Page domain reporting for navigation/page operations and events. |
1341
1463
  | `network` | `true` | Enable Network reporting so native network events are available. |
@@ -1344,6 +1466,102 @@ await client.call({
1344
1466
  | `binding` | `'_send_to_cdp'` | Default page-to-host JavaScript binding. String changes its name; false skips installation; true selects the default name. |
1345
1467
  | `background_service` | `true` | Clear, observe and record `pushMessaging` background-service events in Chromium. |
1346
1468
 
1469
+ ### Reverse execution mode
1470
+
1471
+ In extension mode (`cdp_ext: true` or passive `reverse: true`), a target defaults
1472
+ to `execution_mode: 'api'`. Creating/selecting it, grouping it and running
1473
+ `_.find`/`_.click` do not attach the debugger. Root tabs are created through
1474
+ `chrome.tabs`/`chrome.windows`; choosing debugger mode then attaches the selected
1475
+ tab before setup. Chrome's debugger API needs an existing debuggee.
1476
+
1477
+ | Operation | Reverse API target (default) | Reverse debugger target |
1478
+ | --- | --- | --- |
1479
+ | Target selection and root routing | Register the stable tab ID without debugger attachment. | Attach the selected tab and register the same routing ID. |
1480
+ | 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. |
1481
+ | `_.find`, `_.click` | Fixed native `chrome.scripting.executeScript` functions in the isolated world; shared DOM, no string evaluation. | `Runtime.evaluate` in the page world. |
1482
+ | Explicit session CDP, such as `Runtime.evaluate` or `Page.navigate` | Attach lazily and execute the exact CDP request. | Execute the CDP request. |
1483
+ | Explicit `chrome.*` method | Invoke the named native extension API with the supplied positional arguments. | Same native extension API dispatch. |
1484
+
1485
+ The target option is recorded on first use of its logical name, like the other
1486
+ target options. It belongs in `params.target`, not constructor/browser defaults.
1487
+ `runtime: true`, `network: true` or a binding name alone do not opt an API target
1488
+ into debugger setup. To retain the previous reverse convenience initialization,
1489
+ set `target.execution_mode: 'debugger'` explicitly. Direct CDP defaults are unchanged.
1490
+ As with the existing XPath helper, `_.find` does not wait for navigation or retry;
1491
+ wait for your page's readiness before querying it.
1492
+
1493
+ ```js
1494
+ const found = await client.call({
1495
+ method: '_.find',
1496
+ params: {
1497
+ browser: {
1498
+ name: 'work',
1499
+ cdp_ext: true,
1500
+ },
1501
+ target: {
1502
+ name: 'search',
1503
+ create_params: {
1504
+ url: 'https://example.com/',
1505
+ },
1506
+ },
1507
+ xpath: '//h1',
1508
+ },
1509
+ })
1510
+ console.log(found.result.value)
1511
+
1512
+ // Select debugger execution for this helper call only.
1513
+ await client.call({
1514
+ method: '_.click',
1515
+ params: {
1516
+ browser: 'work',
1517
+ target: 'search',
1518
+ execution_mode: 'debugger',
1519
+ xpath: '//button',
1520
+ },
1521
+ })
1522
+
1523
+ // Native CDP executes even though the target's default remains API mode.
1524
+ await client.call({
1525
+ method: 'Runtime.evaluate',
1526
+ params: {
1527
+ browser: 'work',
1528
+ target: 'search',
1529
+ expression: 'document.title',
1530
+ },
1531
+ })
1532
+
1533
+ // A separate target with the full convenience debugger setup.
1534
+ await client.call({
1535
+ method: 'Page.navigate',
1536
+ params: {
1537
+ browser: 'work',
1538
+ target: {
1539
+ name: 'instrumented',
1540
+ execution_mode: 'debugger',
1541
+ runtime: true,
1542
+ },
1543
+ url: 'https://example.com/',
1544
+ },
1545
+ })
1546
+ ```
1547
+
1548
+ For the built-in helpers and `evaluate_xpath`, `params.execution_mode` overrides
1549
+ the target's choice for that call only; it does not enable optional target setup
1550
+ or change the recorded configuration. Valid reverse values are `'api'` and
1551
+ `'debugger'`. Native CDP and `chrome.*` methods keep their explicit dispatch and
1552
+ ignore this helper override; the router removes it from native parameters.
1553
+ Custom handlers still receive the original parameters and choose their own behavior.
1554
+
1555
+ API selection does not detach a debugger previously attached by an explicit
1556
+ command. Use `Target.detachFromTarget` explicitly if needed. After detachment,
1557
+ an API target can be selected and used through API helpers again without
1558
+ reattachment. Native CDP attachment by itself does not enable domains or install
1559
+ bindings; send the desired commands explicitly or choose debugger target setup.
1560
+ Only debugger-attached targets expose flattened worker/iframe sessions. Browser
1561
+ API helpers operate on the registered root tab's main frame; extension permissions
1562
+ and Chrome's restricted-page rules still apply. There is no automatic fallback
1563
+ from API execution to debugger execution.
1564
+
1347
1565
  ### Named tab groups
1348
1566
 
1349
1567
  With `cdp_ext: true` or external `reverse: true`, set `target.group` when first
@@ -1416,6 +1634,10 @@ and [grouping tabs](https://developer.chrome.com/docs/extensions/reference/api/t
1416
1634
 
1417
1635
  ### Target initialization
1418
1636
 
1637
+ The following CDP setup applies to direct mode and reverse targets configured
1638
+ with `execution_mode: 'debugger'`. Reverse API targets skip it entirely, including
1639
+ `Runtime.runIfWaitingForDebugger`; `initialize: false` does not opt into debugging.
1640
+
1419
1641
  Domain options set to false skip setup commands; they do not actively disable a
1420
1642
  domain already enabled by another caller. Likewise, `binding: false` does not
1421
1643
  remove a previously installed binding. Use native CDP commands for immediate
@@ -1488,10 +1710,16 @@ await loaded
1488
1710
  ## XPath custom methods
1489
1711
 
1490
1712
  Both built-ins require `params.target`; `params.browser` defaults to `main`
1491
- through the normal router. They use `Runtime.evaluate` with `returnByValue`,
1713
+ through the normal router. In direct/debugger execution they use `Runtime.evaluate` with `returnByValue`,
1492
1714
  `awaitPromise`, `silent` and `userGesture` set to true: serialize the result,
1493
1715
  await async page code, suppress evaluation exception reporting, and evaluate
1494
- with a user gesture respectively.
1716
+ with a user gesture respectively. In reverse API execution, fixed native scripting
1717
+ functions perform the same DOM operations in the isolated world, without eval,
1718
+ user-script permission or CSP changes. They keep `response.result.value` and the
1719
+ same click/find value shapes. API results contain `result.type` and `result.value`;
1720
+ they do not manufacture CDP remote-object metadata or `exceptionDetails`.
1721
+ Native injection errors reject; failed script execution also rejects. Debugger
1722
+ evaluation retains native `exceptionDetails` for page-side exceptions.
1495
1723
 
1496
1724
  | Method | Parameters | Remote result in `response.result.value` |
1497
1725
  | --- | --- | --- |
@@ -1520,7 +1748,13 @@ XPath snapshot length, even when `items` is limited or empty.
1520
1748
  not a complete parser; arbitrary nested or comma-containing arguments are outside
1521
1749
  that rewriting logic. Ordinary XPath expressions pass through unchanged.
1522
1750
 
1523
- `client.evaluate_xpath(params, fn, options)` is available for other helpers:
1751
+ `_.find` and `_.click` already contain fixed browser-side functions. In API mode
1752
+ only the XPath string and data options are sent to them; neither needs user-script
1753
+ consent, eval or new Function. This is the appropriate path for those two built-ins.
1754
+
1755
+ `client.evaluate_xpath(params, fn, options)` is a separate, general-purpose callback
1756
+ helper: its `fn` parameter is executable code supplied by its caller, not just an
1757
+ XPath string. It is available for other helpers:
1524
1758
 
1525
1759
  ```js
1526
1760
  const text = await client.evaluate_xpath({
@@ -1536,10 +1770,22 @@ const text = await client.evaluate_xpath({
1536
1770
  console.log(text.result.value)
1537
1771
  ```
1538
1772
 
1539
- `params` supplies `browser`, `target` and `xpath`. `fn` is serialized and called in
1773
+ `params` supplies `browser`, `target`, `xpath` and optional per-call
1774
+ `execution_mode`. `fn` is serialized and called in
1540
1775
  the target as `fn(normalizedXPath, options)`; it cannot capture Node.js variables
1541
1776
  or imported helpers. `options` must be JSON-serializable; omission becomes null
1542
- in the argument array. Results retain native `exceptionDetails` if page code throws.
1777
+ in the argument array. Direct/debugger results retain native `exceptionDetails`
1778
+ if page code throws. In reverse API mode this arbitrary-function helper compiles
1779
+ the serialized invocation directly through `chrome.userScripts.execute` in the
1780
+ **MAIN** world and returns `{ result: { type, value } }`. It requires user-script
1781
+ access, automatically prepared for managed profiles as described above. The
1782
+ wrapper awaits the callback and preserves undefined values; callback exceptions
1783
+ reject with their message. It does not use eval/new Function, change CSP, or fall
1784
+ back to the debugger. A callback that itself invokes eval still obeys page CSP.
1785
+ Choose `execution_mode: 'debugger'` explicitly when native CDP evaluation is
1786
+ required. Native `chrome.scripting.executeScript` calls keep their existing string
1787
+ adapter, native world default and native result array; the fixed XPath built-ins
1788
+ do not use that string adapter either.
1543
1789
 
1544
1790
  ### Register, replace or remove handlers
1545
1791
 
@@ -1645,6 +1891,7 @@ await client.call({
1645
1891
  params: {
1646
1892
  target: {
1647
1893
  name: 'bridge',
1894
+ execution_mode: 'debugger',
1648
1895
  binding: 'send_to_host',
1649
1896
  runtime: true,
1650
1897
  },
@@ -1839,10 +2086,13 @@ cover native string rejection, ignored custom string conversions, the bridge's
1839
2086
  string-function adapter and async arguments/results. Real page CSP checks cover
1840
2087
  both evaluators in both worlds, explicit debugger bypass after reload, and explicit
1841
2088
  header removal through session rules. User-script checks cover default denied
1842
- access and explicit activation, DOM changes and Promise results under restrictive
1843
- page CSP. Only disposable-profile preparation uses direct CDP to operate Chrome's
1844
- Allow User Scripts and Allow in incognito toggles; subsequent reverse execution
1845
- uses no remote-debugging flags and never enables these grants automatically.
2089
+ access in external/opt-out profiles, automatic consent in managed custom profiles,
2090
+ MAIN globals, DOM changes and Promise results under restrictive page CSP, and
2091
+ continued CSP restrictions on eval/new Function. Generic XPath callbacks run through
2092
+ native userScripts while fixed XPath commands work even with userScripts denied.
2093
+ Only disposable incognito-grant preparation uses direct CDP to operate Chrome's
2094
+ Allow in incognito toggle; reverse execution and automatic user-script consent
2095
+ use no remote-debugging flags or debugger attachment.
1846
2096
  Managed-launch coverage checks concurrent first use, profile-specific identity,
1847
2097
  connection timeout without process replacement, lazy listener startup/shutdown
1848
2098
  independent of direct browsers, and relaunch after confirmed exit. External reverse
package/cdp.js CHANGED
@@ -188,6 +188,10 @@ export class browser {
188
188
  }
189
189
  if (options.cdp_ext) {
190
190
  args['remote-debugging-port'] = false
191
+ if (options.cdp_ext_user_scripts) {
192
+ args['extensions-on-chrome-urls'] = true
193
+ args['disable-features'].push('DisableExtensionsOnChromeUrlsSwitch')
194
+ }
191
195
  delete args['disable-extensions']
192
196
  delete args['disable-component-extensions-with-background-pages']
193
197
  if (options.extension_path) args['disable-extensions-except'] = [
@@ -320,7 +324,7 @@ const custom_methods = {
320
324
  if (i + 1 < options.attempts) await new Promise(resolve => setTimeout(resolve, options.interval_ms))
321
325
  }
322
326
  return false
323
- }, options)
327
+ }, options, '_.click')
324
328
  },
325
329
  '_.find'(params) {
326
330
  const limit = params.limit ?? 20
@@ -346,7 +350,7 @@ const custom_methods = {
346
350
  count: nodes.snapshotLength,
347
351
  items,
348
352
  }
349
- }, limit)
353
+ }, limit, '_.find')
350
354
  },
351
355
  }
352
356
 
@@ -383,7 +387,7 @@ export class cdp extends EventTarget {
383
387
  const error = new Error(message.error)
384
388
  if (record) record.reverse_error = error
385
389
  util.emit(this, 'reverse_' + encodeURIComponent(id), error)
386
- return socket.close(1008, 'Chrome profile already connected')
390
+ return socket.close(1008, 'Reverse browser initialization failed')
387
391
  }
388
392
  if (record) record.extension_id = extension_id
389
393
  this.reverse_sockets[id] = socket
@@ -472,20 +476,59 @@ export class cdp extends EventTarget {
472
476
  })
473
477
  }
474
478
 
475
- evaluate_xpath(params, fn, options) {
479
+ async evaluate_xpath(params, fn, options, method) {
476
480
  if (params.target == null) throw new Error('Custom method requires target')
477
481
  const xpath = util.normalize_xpath(params.xpath)
478
- return this.call({
482
+ const { record, entry } = await this._resolve(params)
483
+ const reverse = record.reverse || record.cdp_ext
484
+ const execution_mode = params.execution_mode ?? entry.execution_mode ?? 'api'
485
+ if (reverse && !['api', 'debugger'].includes(execution_mode)) throw new Error('execution_mode must be api or debugger')
486
+ if (reverse && execution_mode === 'api') {
487
+ if (method) return record.socket.req({
488
+ method,
489
+ params: {
490
+ xpath,
491
+ options,
492
+ },
493
+ sessionId: entry.sessionId,
494
+ })
495
+ const results = await record.socket.req({
496
+ method: 'chrome.userScripts.execute',
497
+ params: {
498
+ args: [{
499
+ target: { tabId: Number(entry.targetId) },
500
+ world: 'MAIN',
501
+ js: [{
502
+ code: `(async () => {
503
+ try {
504
+ return { value: await (${fn})(...${JSON.stringify([xpath, options])}) }
505
+ } catch (error) {
506
+ return { error: String(error) }
507
+ }
508
+ })()`,
509
+ }],
510
+ }],
511
+ },
512
+ })
513
+ const result = results[0].result
514
+ if (!result || Object.hasOwn(result, 'error')) throw new Error(result?.error ?? results[0].error ?? 'XPath callback did not execute')
515
+ return {
516
+ result: {
517
+ type: typeof result.value,
518
+ value: result.value,
519
+ },
520
+ }
521
+ }
522
+ return record.socket.req({
479
523
  method: 'Runtime.evaluate',
480
524
  params: {
481
- browser: params.browser,
482
- target: params.target,
483
525
  expression: '(' + fn + ')(...' + JSON.stringify([xpath, options]) + ')',
484
526
  returnByValue: true,
485
527
  awaitPromise: true,
486
528
  silent: true,
487
529
  userGesture: true,
488
530
  },
531
+ sessionId: entry.sessionId,
489
532
  })
490
533
  }
491
534
 
@@ -513,7 +556,7 @@ export class cdp extends EventTarget {
513
556
  }
514
557
  const info = browser.target_info[targetId]
515
558
  if (message.method === 'Target.attachedToTarget') {
516
- this._detach(browser, info?.sessionId)
559
+ if (info?.sessionId !== sessionId) this._detach(browser, info?.sessionId)
517
560
  info.sessionId = sessionId
518
561
  browser.session_targets[sessionId] = targetId
519
562
  }
@@ -616,6 +659,14 @@ export class cdp extends EventTarget {
616
659
  }
617
660
 
618
661
  async _connect(record) {
662
+ const args = browser.build_args(record)
663
+ if (record.cdp_ext) {
664
+ record.cdp_ext_install ??= !!args['user-data-dir']
665
+ record.cdp_ext_user_scripts ??= record.cdp_ext_install && !!args['user-data-dir']
666
+ if (record.cdp_ext_user_scripts && (!record.cdp_ext_install || !args['user-data-dir'])) {
667
+ throw new Error('Automatic user-script access requires cdp_ext_install and a custom user-data-dir')
668
+ }
669
+ }
619
670
  if (record.reverse || record.cdp_ext) {
620
671
  await this.server_closing
621
672
  if (!this.server) this.listen_reverse()
@@ -631,6 +682,7 @@ export class cdp extends EventTarget {
631
682
  bootstrap.searchParams.set('browser', record.name)
632
683
  bootstrap.searchParams.set('websocket_url', record.reverse_url)
633
684
  bootstrap.searchParams.set('token', record.reverse_token)
685
+ if (record.cdp_ext_user_scripts) bootstrap.searchParams.set('user_scripts', '1')
634
686
  record.bootstrap_url = bootstrap.href
635
687
  }
636
688
  delete record.reverse_error
@@ -644,8 +696,6 @@ export class cdp extends EventTarget {
644
696
  url = (await response.json()).webSocketDebuggerUrl
645
697
  }
646
698
  if (record.cdp_ext || (!record.reverse && !url)) {
647
- const args = browser.build_args(record)
648
- if (record.cdp_ext) record.cdp_ext_install ??= !!args['user-data-dir']
649
699
  if ((record.proc?.exitCode === null && record.proc.signalCode === null) ||
650
700
  (record.opener?.exitCode === null && record.opener.signalCode === null)) url = record.socket?.url
651
701
  else {
@@ -661,6 +711,12 @@ export class cdp extends EventTarget {
661
711
  for (const filename of ['manifest.json', 'cdp_ext.js', 'sidebar.html', 'sidebar.js']) {
662
712
  await fs.copyFile(new URL('./cdp_ext/' + filename, import.meta.url), path.join(record.extension_path, filename))
663
713
  }
714
+ if (record.cdp_ext_user_scripts) {
715
+ const filename = path.join(record.extension_path, 'manifest.json')
716
+ const manifest = JSON.parse(await fs.readFile(filename, 'utf8'))
717
+ manifest.host_permissions.push('chrome://extensions/*')
718
+ await fs.writeFile(filename, JSON.stringify(manifest, null, 2) + '\n')
719
+ }
664
720
  }
665
721
  if (args['user-data-dir'] && record.update_preferences !== false) {
666
722
  await browser.update_profile_preferences(path.join(args['user-data-dir'], args['profile-directory'] || 'Default', 'Preferences'), {
@@ -777,6 +833,8 @@ export class cdp extends EventTarget {
777
833
  async _target(browser, target) {
778
834
  const socket = browser.socket
779
835
  const reverse = browser.reverse || browser.cdp_ext
836
+ const execution_mode = target.execution_mode ?? 'api'
837
+ if (reverse && !['api', 'debugger'].includes(execution_mode)) throw new Error('execution_mode must be api or debugger')
780
838
  if (target.group != null && target.group !== false) {
781
839
  const spec = typeof target.group === 'string' ? { name: target.group } : target.group
782
840
  browser.groups[spec.name] ??= {
@@ -819,17 +877,23 @@ export class cdp extends EventTarget {
819
877
  if (!info.incognito) throw new Error('Existing target is not incognito')
820
878
  }
821
879
  if (target.group != null && target.group !== false) info.groupId = await this._group(browser, target)
822
- if (!info.sessionId) {
880
+ if (!info.sessionId || (reverse && execution_mode === 'debugger')) {
823
881
  info.sessionId = (await socket.req({
824
882
  method: 'Target.attachToTarget',
825
883
  params: {
826
884
  targetId: target.targetId,
827
885
  flatten: true,
886
+ ...(reverse ? { execution_mode } : {}),
828
887
  },
829
888
  })).sessionId
830
889
  browser.session_targets[info.sessionId] = target.targetId
831
890
  }
832
891
  const sessionId = info.sessionId
892
+ target.setup_errors = []
893
+ if (reverse && execution_mode === 'api') {
894
+ target.sessionId = sessionId
895
+ return
896
+ }
833
897
  const steps = []
834
898
  if (target.initialize !== false) {
835
899
  for (const [option, domain] of Object.entries({
@@ -853,7 +917,6 @@ export class cdp extends EventTarget {
853
917
  if ((target.runtime ?? 'bootstrap') === 'bootstrap') steps.push(['Runtime.disable'])
854
918
  }
855
919
  steps.push(['Runtime.runIfWaitingForDebugger'])
856
- target.setup_errors = []
857
920
  for (const [method, params] of steps) {
858
921
  try {
859
922
  await socket.req({
@@ -869,13 +932,7 @@ export class cdp extends EventTarget {
869
932
  target.sessionId = sessionId
870
933
  }
871
934
 
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
935
+ async _resolve({ browser = 'main', target }) {
879
936
  const spec = typeof browser === 'string' ? { name: browser } : browser
880
937
  const record = this.browsers[spec.name] ??= {
881
938
  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 +990,20 @@ export class cdp extends EventTarget {
933
990
  await entry.connecting
934
991
  }
935
992
  }
993
+ return {
994
+ record,
995
+ entry,
996
+ }
997
+ }
998
+
999
+ async call({ method, params = {} }) {
1000
+ if (method.startsWith('_.')) {
1001
+ const handler = this.custom_methods[method]
1002
+ if (!handler) throw new Error('Unknown CDP extension: ' + method)
1003
+ return handler.call(this, params)
1004
+ }
1005
+ const { browser, target, execution_mode, ...native } = params
1006
+ const { record, entry } = await this._resolve(params)
936
1007
  if (method === 'Target.disposeBrowserContext' && target == null) {
937
1008
  const context = Object.values(record.contexts).find(value => value.browserContextId === native.browserContextId)
938
1009
  if (context) return this._dispose_context(record, context, native)
@@ -12,9 +12,22 @@ let auto_attach = {}
12
12
  let bootstrapping
13
13
  let checking = false
14
14
  let connection_message
15
+ let setup_error
15
16
  const debug_commands = []
16
17
  const debug_ports = []
17
18
 
19
+ function debug_record(fields) {
20
+ const record = {
21
+ time: Date.now(),
22
+ ...fields,
23
+ status: 'running',
24
+ }
25
+ debug_commands.unshift(record)
26
+ debug_commands.length = Math.min(debug_commands.length, 50)
27
+ debug_update()
28
+ return record
29
+ }
30
+
18
31
  function debug_update() {
19
32
  const state = {
20
33
  status: connection_message ?? (config.browser ? 'Connecting to server' : 'Waiting for trigger'),
@@ -23,6 +36,7 @@ function debug_update() {
23
36
  endpoint: config.websocket_url ?? '',
24
37
  extension_id: chrome.runtime.id,
25
38
  sessions: Object.keys(sessions).length,
39
+ debugger_tabs: Object.keys(attached).length,
26
40
  commands: debug_commands,
27
41
  }
28
42
  for (const port of debug_ports) {
@@ -52,6 +66,36 @@ function report_connection(message) {
52
66
  debug_update()
53
67
  }
54
68
 
69
+ async function enable_user_scripts() {
70
+ try {
71
+ await chrome.userScripts.getScripts()
72
+ return
73
+ } catch {}
74
+ const tab = await chrome.tabs.create({
75
+ url: 'chrome://extensions/?id=' + chrome.runtime.id,
76
+ active: false,
77
+ })
78
+ try {
79
+ const results = await chrome.scripting.executeScript({
80
+ target: { tabId: tab.id },
81
+ world: 'MAIN',
82
+ args: [chrome.runtime.id],
83
+ func: async id => {
84
+ const version = Number(navigator.userAgent.match(/(?:Chrome|Chromium)\/(\d+)/)?.[1])
85
+ if (version < 138) await chrome.developerPrivate.updateProfileConfiguration({ inDeveloperMode: true })
86
+ else await chrome.developerPrivate.updateExtensionConfiguration({
87
+ extensionId: id,
88
+ userScriptsAccess: true,
89
+ })
90
+ return true
91
+ },
92
+ })
93
+ if (results[0]?.result !== true) throw new Error('Chrome did not enable user-script access')
94
+ } finally {
95
+ await chrome.tabs.remove(tab.id)
96
+ }
97
+ }
98
+
55
99
  async function bootstrap(tab) {
56
100
  const text = tab.pendingUrl ?? tab.url ?? ''
57
101
  if (!/^http:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?\/cdp_ext\?/.test(text)) return
@@ -67,33 +111,66 @@ async function bootstrap(tab) {
67
111
  await configured
68
112
  while (bootstrapping) await bootstrapping
69
113
  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
- })
114
+ const tabs = await chrome.tabs.query({})
115
+ if (!tabs.some(current => current.id === tab.id && (current.pendingUrl ?? current.url) === text)) return
116
+ const trigger = debug_commands.find(record => record.method === 'cdp_ext.trigger' &&
117
+ record.tabId === tab.id && record.browser === settings.browser && record.endpoint === settings.websocket_url) ?? debug_record({
118
+ method: 'cdp_ext.trigger',
119
+ tabId: tab.id,
120
+ browser: settings.browser,
121
+ endpoint: settings.websocket_url,
122
+ })
123
+ try {
124
+ const changed = ['browser', 'websocket_url', 'token'].some(key => settings[key] !== config[key])
125
+ if (changed && config.browser && socket?.readyState < WebSocket.CLOSING) {
126
+ const probe = new WebSocket(settings.websocket_url)
127
+ trigger.error = 'This Chrome profile is already connected as ' + config.browser
128
+ probe.onopen = () => probe.send(JSON.stringify({
129
+ browser: settings.browser,
130
+ extension_id: chrome.runtime.id,
131
+ token: settings.token,
132
+ error: trigger.error,
133
+ }))
134
+ } else {
135
+ setup_error = undefined
136
+ if (changed) {
137
+ Object.assign(config, settings)
138
+ connection_message = undefined
139
+ debug_update()
140
+ await chrome.storage.session.set({ cdp_bootstrap: settings })
141
+ if (socket && socket.readyState !== WebSocket.CLOSED) {
142
+ await new Promise(resolve => {
143
+ socket.addEventListener('close', resolve, { once: true })
144
+ socket.close()
145
+ })
146
+ }
147
+ await disconnecting
148
+ }
149
+ if (url.searchParams.get('user_scripts') === '1') await enable_user_scripts()
91
150
  }
92
- await disconnecting
151
+ const current_tabs = await chrome.tabs.query({})
152
+ const current = current_tabs.find(current => current.id === tab.id)
153
+ if ((current?.pendingUrl ?? current?.url) === text) {
154
+ if (current_tabs.length === 1) {
155
+ await chrome.tabs.update(tab.id, { url: chrome.runtime.getURL('sidebar.html?connection') })
156
+ trigger.action = 'opened connection page'
157
+ } else {
158
+ await chrome.tabs.remove(tab.id)
159
+ trigger.action = 'closed tab'
160
+ }
161
+ }
162
+ trigger.status = trigger.error ? 'error' : 'done'
163
+ } catch (error) {
164
+ trigger.status = 'error'
165
+ trigger.error = error.message
166
+ setup_error = 'Extension setup failed: ' + error.message
167
+ } finally {
168
+ trigger.duration_ms = Date.now() - trigger.time
169
+ debug_update()
93
170
  }
94
- await connect()
95
171
  })().finally(() => {
96
172
  bootstrapping = null
173
+ return connect()
97
174
  })
98
175
  }
99
176
 
@@ -114,7 +191,7 @@ function target_info(tab) {
114
191
  }
115
192
 
116
193
  function detached(source) {
117
- if (!attached[source.tabId]) return
194
+ if (!sessions[String(source.tabId)]) return
118
195
  delete attached[source.tabId]
119
196
  for (const [id, session] of Object.entries(sessions)) {
120
197
  if (session.tabId === source.tabId) delete sessions[id]
@@ -210,6 +287,12 @@ const methods = {
210
287
  },
211
288
  async 'Target.attachToTarget'(params) {
212
289
  const tabId = Number(params.targetId)
290
+ if (params.execution_mode === 'api') {
291
+ await chrome.tabs.get(tabId)
292
+ sessions[String(tabId)] = { tabId }
293
+ debug_update()
294
+ return { sessionId: String(tabId) }
295
+ }
213
296
  if (attaching[tabId]) return attaching[tabId]
214
297
  if (attached[tabId]) return { sessionId: String(tabId) }
215
298
  return attaching[tabId] = attach_tab(tabId).finally(() => delete attaching[tabId])
@@ -220,7 +303,7 @@ const methods = {
220
303
  if (source.sessionId) {
221
304
  await chrome.debugger.sendCommand({ tabId: source.tabId }, 'Target.detachFromTarget', { sessionId: source.sessionId })
222
305
  } else {
223
- await chrome.debugger.detach(source)
306
+ if (attached[source.tabId]) await chrome.debugger.detach(source)
224
307
  detached(source)
225
308
  }
226
309
  },
@@ -266,6 +349,7 @@ chrome.tabs.onUpdated.addListener((id, changes, tab) => {
266
349
  })
267
350
  })
268
351
  chrome.tabs.onRemoved.addListener(id => {
352
+ detached({ tabId: id })
269
353
  if (discovering) send({
270
354
  method: 'Target.targetDestroyed',
271
355
  params: { targetId: String(id) },
@@ -293,7 +377,7 @@ for (const [name, options] of Object.entries({
293
377
  async function connect() {
294
378
  await configured
295
379
  if (!config.browser || !config.websocket_url || !config.token) return
296
- if (disconnecting || checking) return
380
+ if (bootstrapping || disconnecting || checking) return
297
381
  if (socket?.readyState === WebSocket.CONNECTING) return
298
382
  if (socket?.readyState === WebSocket.OPEN) return socket.send('')
299
383
  const endpoint = config.websocket_url
@@ -327,22 +411,19 @@ async function connect() {
327
411
  browser: config.browser,
328
412
  extension_id: chrome.runtime.id,
329
413
  token: config.token,
414
+ error: setup_error,
330
415
  }))
331
416
  }
332
417
  connection.onmessage = async event => {
333
418
  if (!event.data) return
334
419
  const request = JSON.parse(event.data)
335
420
  const { method, params = {}, sessionId, id } = request
336
- const command = {
337
- time: Date.now(),
421
+ const command = debug_record({
338
422
  id,
339
423
  method,
340
424
  sessionId,
341
- status: 'running',
342
- }
343
- debug_commands.unshift(command)
344
- debug_commands.length = Math.min(debug_commands.length, 50)
345
- debug_update()
425
+ execution_mode: params.execution_mode,
426
+ })
346
427
  let response
347
428
  try {
348
429
  let result
@@ -363,11 +444,62 @@ async function connect() {
363
444
  else resolve(value)
364
445
  }))
365
446
  : await object[name](...args)
447
+ } else if (method === '_.click' || method === '_.find') {
448
+ const source = sessions[sessionId]
449
+ if (!source || source.sessionId) throw new Error('XPath browser API commands require a registered root tab')
450
+ const results = await chrome.scripting.executeScript({
451
+ target: { tabId: source.tabId },
452
+ args: [method, params.xpath, params.options],
453
+ func: async (method, xpath, options) => {
454
+ if (method === '_.click') {
455
+ for (let i = 0; i < options.attempts; i++) {
456
+ const node = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue
457
+ if (node) {
458
+ if (typeof node.click !== 'function') return false
459
+ node.click()
460
+ return true
461
+ }
462
+ if (i + 1 < options.attempts) await new Promise(resolve => setTimeout(resolve, options.interval_ms))
463
+ }
464
+ return false
465
+ }
466
+ const nodes = document.evaluate(xpath, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
467
+ const items = []
468
+ for (let i = 0; i < Math.min(nodes.snapshotLength, options); i++) {
469
+ const node = nodes.snapshotItem(i)
470
+ items.push({
471
+ node_type: node.nodeType,
472
+ node_name: node.nodeName,
473
+ text: String(node.innerText ?? node.textContent ?? '').trim().slice(0, 2000),
474
+ tag: node.tagName?.toLowerCase(),
475
+ id: node.id,
476
+ class: node.className == null ? undefined : String(node.className),
477
+ href: node.href,
478
+ value: node.value,
479
+ rect: node.getBoundingClientRect?.().toJSON(),
480
+ })
481
+ }
482
+ return {
483
+ count: nodes.snapshotLength,
484
+ items,
485
+ }
486
+ },
487
+ })
488
+ if (!Object.hasOwn(results[0] ?? {}, 'result')) throw new Error('XPath browser API execution failed')
489
+ result = {
490
+ result: {
491
+ type: typeof results[0].result,
492
+ value: results[0].result,
493
+ },
494
+ }
366
495
  } else {
367
496
  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)
497
+ if (sessionId != null) {
498
+ const source = sessions[sessionId]
499
+ if (!source) throw new Error('Unknown debugger session: ' + sessionId)
500
+ await methods['Target.attachToTarget']({ targetId: String(source.tabId) })
501
+ result = await chrome.debugger.sendCommand(source, method, params)
502
+ } else result = await methods[method](params)
371
503
  }
372
504
  response = {
373
505
  id,
@@ -391,10 +523,11 @@ async function connect() {
391
523
  connection.onclose = () => {
392
524
  report_connection('Waiting for CDP.js server at ' + endpoint + '; retrying automatically.')
393
525
  discovering = false
394
- disconnecting = Promise.all(Object.keys(attached).map(id => {
526
+ disconnecting = Promise.all(Object.keys(sessions).filter(id => !sessions[id].sessionId).map(id => {
395
527
  const source = { tabId: Number(id) }
528
+ const was_attached = attached[id]
396
529
  detached(source)
397
- return chrome.debugger.detach(source).catch(() => {})
530
+ if (was_attached) return chrome.debugger.detach(source).catch(() => {})
398
531
  })).finally(() => disconnecting = null)
399
532
  }
400
533
  }
@@ -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.8.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.8.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",