@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 +285 -35
- package/cdp.js +90 -19
- package/cdp_ext/cdp_ext.js +171 -38
- package/cdp_ext/manifest.json +1 -1
- package/cdp_ext/sidebar.html +6 -4
- package/cdp_ext/sidebar.js +12 -1
- package/package.json +1 -1
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
|
|
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
|
|
652
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
740
|
-
|
|
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
|
-
|
|
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
|
|
760
|
-
those setup flags if desired.
|
|
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`
|
|
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
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
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
|
-
| `
|
|
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.
|
|
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
|
-
`
|
|
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
|
|
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.
|
|
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
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
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, '
|
|
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
|
-
|
|
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
|
|
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)
|
package/cdp_ext/cdp_ext.js
CHANGED
|
@@ -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
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
:
|
|
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(
|
|
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
|
}
|
package/cdp_ext/manifest.json
CHANGED
package/cdp_ext/sidebar.html
CHANGED
|
@@ -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
|
|
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>
|
|
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
|
|
32
|
-
<p id="empty">No
|
|
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>
|
package/cdp_ext/sidebar.js
CHANGED
|
@@ -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
|
-
|
|
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