@mefistofelix/cdp.js 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,7 +24,7 @@ import api, { util, jsrpc, browser, cdp } from '@mefistofelix/cdp.js'
24
24
 
25
25
  The npm package exports the same file and API. Direct file imports in the examples
26
26
  below remain supported. Publication metadata is isolated in `npm/`.
27
- The package includes both `cdp_ext/` files. With `cdp_ext: true`, the manager
27
+ The package includes the worker, manifest and debug sidebar files. With `cdp_ext: true`, the manager
28
28
  prepares and loads the extension automatically with a custom user-data directory.
29
29
  The system profile uses an already installed extension by default. Reverse mode still
30
30
  requires Deno or Bun. In Deno, import from `npm:@mefistofelix/cdp.js`.
@@ -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. |
@@ -491,7 +493,7 @@ can configure its native incognito scope. See
491
493
  ## Reverse Chrome extension transport
492
494
 
493
495
  [cdp_ext/manifest.json](cdp_ext/manifest.json) and
494
- [cdp_ext/cdp_ext.js](cdp_ext/cdp_ext.js) form a Manifest V3 extension for Chrome
496
+ [cdp_ext/cdp_ext.js](cdp_ext/cdp_ext.js), with the local debug sidebar, form a Manifest V3 extension for Chrome
495
497
  125+. The extension connects out to one WebSocket server. The server serves all
496
498
  browser instances on the same port; bootstrapped extensions announce the logical
497
499
  browser name and native extension ID. No remote-debugging argument is needed.
@@ -529,6 +531,19 @@ the local URL and connects to the waiting manager. The user enters no extension
529
531
  ID or WebSocket address. Load the package's `cdp_ext/` once through
530
532
  `chrome://extensions` (developer mode, Load unpacked); reload it after upgrading.
531
533
 
534
+ After accepting a trigger, the extension records `cdp_ext.trigger` in its debug
535
+ sidebar and closes that trigger tab automatically, including when the server is
536
+ currently offline. If it is Chrome's only tab, it becomes the extension's
537
+ **CDP.js connection** page instead, keeping Chrome available for subsequent calls.
538
+ This reuses the debug view and displays the logical browser and live connected/
539
+ waiting status; it never claims a connection before one exists. No extra
540
+ tab is created. Other loopback pages are preserved: only the exact `/cdp_ext`
541
+ route with non-empty `browser`, `websocket_url` and `token` parameters and a
542
+ same-host/port `ws:` endpoint is accepted. The tab URL is checked again before
543
+ cleanup, so a tab already navigated elsewhere is left alone. Duplicate tab updates
544
+ reuse the trigger's diagnostic entry. A conflicting live browser binding is
545
+ reported and its trigger page is cleaned up without replacing the connection.
546
+
532
547
  ```js
533
548
  const client = new cdp({
534
549
  cdp_ext: true,
@@ -541,7 +556,7 @@ await client.call({
541
556
 
542
557
  Provide `user_data_dir` or raw `args['user-data-dir']` to choose a custom
543
558
  directory. An effective custom directory makes `cdp_ext_install` default to true:
544
- the manager copies the two extension files into
559
+ the manager copies the four extension files into
545
560
  `<user-data-dir>/<profile-directory>/cdp_ext/` (`Default` when unset) and adds
546
561
  startup loading flags. The server address comes only from the trigger URL. Normal launch
547
562
  options and profile preferences then apply. Set `cdp_ext_install: false` to use
@@ -648,8 +663,9 @@ native send/close and live ready state. The same `jsrpc` handles both runtimes;
648
663
  registration, request IDs, pending waits, events and session routing are shared.
649
664
  Native Bun WebSocket limits still apply.
650
665
 
651
- Reverse calls reuse the normal target setup, custom handlers, mappings, routed
652
- events, request correlation, timeout and error behavior. `reverse: true` alone
666
+ Reverse calls share target selection, custom handlers, mappings, routed events,
667
+ request correlation, timeout and error behavior. Implicit CDP setup is opt-in
668
+ through `target.execution_mode: 'debugger'`. `reverse: true` alone
653
669
  attaches externally: the manager neither launches nor updates that browser.
654
670
  `cdp_ext_install: true` owns the launched process and applies normal launch preferences.
655
671
  With installation off, Chrome is external and remains open on manager close.
@@ -685,6 +701,40 @@ WebSocket attempt on every timer tick. Successful connections and later
685
701
  disconnects log their status once per transition. Chrome can still emit a native
686
702
  network diagnostic if the server disappears between the HTTP check and upgrade.
687
703
 
704
+ ### Debug sidebar
705
+
706
+ Click the extension's toolbar icon to open its native Chrome side panel.
707
+ If the icon is hidden, open Chrome's Extensions menu (the puzzle piece), find
708
+ **CDP reverse connection**, and pin it or click its entry. The panel opens on a
709
+ user action; loading the extension or connecting a browser does not open it.
710
+ After updating the package, an already running Chrome still has its previously
711
+ loaded extension: reload it in `chrome://extensions` after updating its directory,
712
+ or restart that custom-profile browser through a fresh manager. For a manually
713
+ installed extension, update its installed files too. Reopening an old side panel
714
+ alone does not upgrade the worker.
715
+
716
+ It shows the current connection status, logical browser name, WebSocket endpoint,
717
+ native extension ID, number of routing sessions and number of debugger-attached
718
+ root tabs. A routing session does not imply debugger attachment. It works before
719
+ the first trigger too, displaying **Waiting for trigger** without starting a
720
+ server connection.
721
+
722
+ The most recent 50 commands and trigger entries appear newest first, with request ID,
723
+ method, optional session ID/execution mode, running/done/error status, elapsed milliseconds and
724
+ error text. This is command metadata only: parameters, results and the trigger
725
+ token are not included. Trigger entries show the tab ID, browser label, endpoint
726
+ and cleanup action; the full trigger URL is never recorded. Empty heartbeat
727
+ messages and CDP events are not logged.
728
+ **Clear** empties this history without cancelling commands or changing the
729
+ connection. Reopening the panel retrieves the current snapshot; a worker restart
730
+ or extension reload clears the history. No debug history is written to storage.
731
+
732
+ The panel receives updates through a local extension port. While open, a
733
+ 20-second port heartbeat keeps the worker available, including before a trigger.
734
+ Closing the panel does not disconnect CDP. After reloading the extension, reopen
735
+ the panel if its old document reports that it has disconnected. The HTML and
736
+ external script follow the normal extension CSP and require no build or dependency.
737
+
688
738
  ### Reverse protocol and supported commands
689
739
 
690
740
  The current extension's first announcement contains `{ browser, extension_id, token }`
@@ -714,8 +764,10 @@ Worker and out-of-process iframe commands still use real flattened CDP sessions.
714
764
  Their native session IDs pass through unchanged, without prefixes. The extension
715
765
  uses one `sessions` object for both roots and children: a tab ID maps to `{ tabId }`,
716
766
  and a native child session ID maps to `{ tabId, sessionId }`. Every target command
717
- looks up that object and passes the debuggee directly to `chrome.debugger.sendCommand`;
718
- there is no fallback that interprets unknown session IDs as tab IDs.
767
+ looks up that object. API-mode root selection only registers the routing reference;
768
+ an explicit session CDP command attaches its root lazily, sharing concurrent
769
+ attachment, then calls `chrome.debugger.sendCommand`. There is no fallback that
770
+ interprets unknown session IDs as tab IDs.
719
771
  Explicit detach takes `params.sessionId` for either a tab or child
720
772
  session. Logical target labels remain inside the manager. Reconnecting invalidates
721
773
  attachment/setup state even though a surviving root tab's routing ID stays the same.
@@ -730,12 +782,13 @@ tab and focuses its window. `Browser.getVersion` returns product/user-agent data
730
782
  its protocol version is `1.3`, with empty `revision` and `jsVersion` fields.
731
783
  Other browser-level commands return explicit unsupported-command errors.
732
784
 
733
- Target/session commands go through
785
+ Explicit native target/session CDP commands go through
734
786
  [chrome.debugger](https://developer.chrome.com/docs/extensions/reference/api/debugger),
735
787
  which supports a restricted set of CDP domains. Runtime, Page, Network, DOM,
736
788
  Input and Emulation are available; ServiceWorker and BackgroundService are not.
737
- Their default setup attempts therefore appear in `target.setup_errors`; disable
738
- those setup flags if desired. Chrome restrictions, policies and debugger detach
789
+ Their setup attempts in debugger target mode therefore appear in
790
+ `target.setup_errors`; disable those setup flags if desired. API targets skip
791
+ that setup. Chrome restrictions, policies and debugger detach
739
792
  events remain visible. Opening DevTools on an attached tab can detach this debugger.
740
793
  Firefox/Camoufox support remains planned.
741
794
 
@@ -1313,7 +1366,8 @@ await client.call({
1313
1366
  | `incognito` | `false` | Create/select a private target; direct mode uses the shared `'incognito'` context by default, reverse uses a native incognito window with the user's extension grant. |
1314
1367
  | `group` | unset | Reverse-only tab group name or first-use `{ name, color }` configuration. False skips grouping. |
1315
1368
  | `create_params` | `{}` | Native creation overrides, merged over `url: 'about:blank'` and `background: true`; start from a blank target without requesting foreground activation. |
1316
- | `initialize` | `true` | Run the convenience setup sequence; false skips it. |
1369
+ | `execution_mode` | `'api'` in reverse | Reverse-only choice for implicit target setup and built-in XPath helpers: `'api'` uses extension APIs; `'debugger'` attaches and runs CDP setup. Direct CDP ignores it. |
1370
+ | `initialize` | `true` | Run the convenience CDP setup sequence in direct/debugger mode; false skips it. API mode skips that sequence regardless. |
1317
1371
  | `runtime` | `'bootstrap'` | Enable Runtime for setup, then disable reporting. `true` leaves it enabled; `false` skips both operations. |
1318
1372
  | `page` | `true` | Enable Page domain reporting for navigation/page operations and events. |
1319
1373
  | `network` | `true` | Enable Network reporting so native network events are available. |
@@ -1322,6 +1376,102 @@ await client.call({
1322
1376
  | `binding` | `'_send_to_cdp'` | Default page-to-host JavaScript binding. String changes its name; false skips installation; true selects the default name. |
1323
1377
  | `background_service` | `true` | Clear, observe and record `pushMessaging` background-service events in Chromium. |
1324
1378
 
1379
+ ### Reverse execution mode
1380
+
1381
+ In extension mode (`cdp_ext: true` or passive `reverse: true`), a target defaults
1382
+ to `execution_mode: 'api'`. Creating/selecting it, grouping it and running
1383
+ `_.find`/`_.click` do not attach the debugger. Root tabs are created through
1384
+ `chrome.tabs`/`chrome.windows`; choosing debugger mode then attaches the selected
1385
+ tab before setup. Chrome's debugger API needs an existing debuggee.
1386
+
1387
+ | Operation | Reverse API target (default) | Reverse debugger target |
1388
+ | --- | --- | --- |
1389
+ | Target selection and root routing | Register the stable tab ID without debugger attachment. | Attach the selected tab and register the same routing ID. |
1390
+ | Implicit initialization | No Runtime/Page/Network enabling, binding, focus emulation, background-service setup or resume command. | Apply the documented initialization options and resume after setup. |
1391
+ | `_.find`, `_.click` | Fixed native `chrome.scripting.executeScript` functions in the isolated world; shared DOM, no string evaluation. | `Runtime.evaluate` in the page world. |
1392
+ | Explicit session CDP, such as `Runtime.evaluate` or `Page.navigate` | Attach lazily and execute the exact CDP request. | Execute the CDP request. |
1393
+ | Explicit `chrome.*` method | Invoke the named native extension API with the supplied positional arguments. | Same native extension API dispatch. |
1394
+
1395
+ The target option is recorded on first use of its logical name, like the other
1396
+ target options. It belongs in `params.target`, not constructor/browser defaults.
1397
+ `runtime: true`, `network: true` or a binding name alone do not opt an API target
1398
+ into debugger setup. To retain the previous reverse convenience initialization,
1399
+ set `target.execution_mode: 'debugger'` explicitly. Direct CDP defaults are unchanged.
1400
+ As with the existing XPath helper, `_.find` does not wait for navigation or retry;
1401
+ wait for your page's readiness before querying it.
1402
+
1403
+ ```js
1404
+ const found = await client.call({
1405
+ method: '_.find',
1406
+ params: {
1407
+ browser: {
1408
+ name: 'work',
1409
+ cdp_ext: true,
1410
+ },
1411
+ target: {
1412
+ name: 'search',
1413
+ create_params: {
1414
+ url: 'https://example.com/',
1415
+ },
1416
+ },
1417
+ xpath: '//h1',
1418
+ },
1419
+ })
1420
+ console.log(found.result.value)
1421
+
1422
+ // Select debugger execution for this helper call only.
1423
+ await client.call({
1424
+ method: '_.click',
1425
+ params: {
1426
+ browser: 'work',
1427
+ target: 'search',
1428
+ execution_mode: 'debugger',
1429
+ xpath: '//button',
1430
+ },
1431
+ })
1432
+
1433
+ // Native CDP executes even though the target's default remains API mode.
1434
+ await client.call({
1435
+ method: 'Runtime.evaluate',
1436
+ params: {
1437
+ browser: 'work',
1438
+ target: 'search',
1439
+ expression: 'document.title',
1440
+ },
1441
+ })
1442
+
1443
+ // A separate target with the full convenience debugger setup.
1444
+ await client.call({
1445
+ method: 'Page.navigate',
1446
+ params: {
1447
+ browser: 'work',
1448
+ target: {
1449
+ name: 'instrumented',
1450
+ execution_mode: 'debugger',
1451
+ runtime: true,
1452
+ },
1453
+ url: 'https://example.com/',
1454
+ },
1455
+ })
1456
+ ```
1457
+
1458
+ For the built-in helpers and `evaluate_xpath`, `params.execution_mode` overrides
1459
+ the target's choice for that call only; it does not enable optional target setup
1460
+ or change the recorded configuration. Valid reverse values are `'api'` and
1461
+ `'debugger'`. Native CDP and `chrome.*` methods keep their explicit dispatch and
1462
+ ignore this helper override; the router removes it from native parameters.
1463
+ Custom handlers still receive the original parameters and choose their own behavior.
1464
+
1465
+ API selection does not detach a debugger previously attached by an explicit
1466
+ command. Use `Target.detachFromTarget` explicitly if needed. After detachment,
1467
+ an API target can be selected and used through API helpers again without
1468
+ reattachment. Native CDP attachment by itself does not enable domains or install
1469
+ bindings; send the desired commands explicitly or choose debugger target setup.
1470
+ Only debugger-attached targets expose flattened worker/iframe sessions. Browser
1471
+ API helpers operate on the registered root tab's main frame; extension permissions
1472
+ and Chrome's restricted-page rules still apply. There is no automatic fallback
1473
+ from API execution to debugger execution.
1474
+
1325
1475
  ### Named tab groups
1326
1476
 
1327
1477
  With `cdp_ext: true` or external `reverse: true`, set `target.group` when first
@@ -1394,6 +1544,10 @@ and [grouping tabs](https://developer.chrome.com/docs/extensions/reference/api/t
1394
1544
 
1395
1545
  ### Target initialization
1396
1546
 
1547
+ The following CDP setup applies to direct mode and reverse targets configured
1548
+ with `execution_mode: 'debugger'`. Reverse API targets skip it entirely, including
1549
+ `Runtime.runIfWaitingForDebugger`; `initialize: false` does not opt into debugging.
1550
+
1397
1551
  Domain options set to false skip setup commands; they do not actively disable a
1398
1552
  domain already enabled by another caller. Likewise, `binding: false` does not
1399
1553
  remove a previously installed binding. Use native CDP commands for immediate
@@ -1466,10 +1620,16 @@ await loaded
1466
1620
  ## XPath custom methods
1467
1621
 
1468
1622
  Both built-ins require `params.target`; `params.browser` defaults to `main`
1469
- through the normal router. They use `Runtime.evaluate` with `returnByValue`,
1623
+ through the normal router. In direct/debugger execution they use `Runtime.evaluate` with `returnByValue`,
1470
1624
  `awaitPromise`, `silent` and `userGesture` set to true: serialize the result,
1471
1625
  await async page code, suppress evaluation exception reporting, and evaluate
1472
- with a user gesture respectively.
1626
+ with a user gesture respectively. In reverse API execution, fixed native scripting
1627
+ functions perform the same DOM operations in the isolated world, without eval,
1628
+ user-script permission or CSP changes. They keep `response.result.value` and the
1629
+ same click/find value shapes. API results contain `result.type` and `result.value`;
1630
+ they do not manufacture CDP remote-object metadata or `exceptionDetails`.
1631
+ Native injection errors reject; failed script execution also rejects. Debugger
1632
+ evaluation retains native `exceptionDetails` for page-side exceptions.
1473
1633
 
1474
1634
  | Method | Parameters | Remote result in `response.result.value` |
1475
1635
  | --- | --- | --- |
@@ -1514,10 +1674,19 @@ const text = await client.evaluate_xpath({
1514
1674
  console.log(text.result.value)
1515
1675
  ```
1516
1676
 
1517
- `params` supplies `browser`, `target` and `xpath`. `fn` is serialized and called in
1677
+ `params` supplies `browser`, `target`, `xpath` and optional per-call
1678
+ `execution_mode`. `fn` is serialized and called in
1518
1679
  the target as `fn(normalizedXPath, options)`; it cannot capture Node.js variables
1519
1680
  or imported helpers. `options` must be JSON-serializable; omission becomes null
1520
- in the argument array. Results retain native `exceptionDetails` if page code throws.
1681
+ in the argument array. Direct/debugger results retain native `exceptionDetails`
1682
+ if page code throws. In reverse API mode this arbitrary-function helper uses the
1683
+ existing string-function scripting adapter in the **MAIN** world and returns
1684
+ `{ result: { type, value } }`. Unlike the two fixed built-ins, arbitrary source
1685
+ evaluation requires the page CSP to allow eval. Injection errors or missing
1686
+ execution results reject; no debugger, CSP bypass or user-script toggle is enabled
1687
+ automatically. Choose `execution_mode: 'debugger'` explicitly when native CDP
1688
+ evaluation is required. Native `chrome.scripting.executeScript` calls retain their
1689
+ existing native world default and native result array.
1521
1690
 
1522
1691
  ### Register, replace or remove handlers
1523
1692
 
@@ -1623,6 +1792,7 @@ await client.call({
1623
1792
  params: {
1624
1793
  target: {
1625
1794
  name: 'bridge',
1795
+ execution_mode: 'debugger',
1626
1796
  binding: 'send_to_host',
1627
1797
  runtime: true,
1628
1798
  },
@@ -1787,11 +1957,12 @@ from tool-call history. It is a caller-side recipe, not additional library API.
1787
1957
 
1788
1958
  For npm packaging, account setup, and GitHub publishing, see
1789
1959
  [Publishing](npm/PUBLISHING.md). Publishing copies `cdp.js`, this README and the
1790
- two `cdp_ext/` files into `npm/` alongside its manifest; no build script is needed.
1960
+ four `cdp_ext/` files into `npm/` alongside its manifest; no build script is needed.
1791
1961
 
1792
1962
  ```sh
1793
1963
  node --check cdp.js
1794
1964
  node --check cdp_ext/cdp_ext.js
1965
+ node --check cdp_ext/sidebar.js
1795
1966
  node --test tests/cdp.test.js
1796
1967
  deno test -A tests/reverse.test.js
1797
1968
  bun test tests/cdp.test.js tests/reverse.test.js --timeout 60000
@@ -1835,5 +2006,7 @@ combinations include default/custom directories, null/false raw directory
1835
2006
  overrides, forced installation and independent preference-write controls.
1836
2007
  Offline worker checks verify silence before a trigger, deduplicated waiting logs,
1837
2008
  no WebSocket attempts against absent/incompatible servers and concurrent retry checks.
1838
- The npm tarball contains `package.json`, `cdp.js`, `README.md` and the two extension
1839
- files required by managed reverse launches.
2009
+ The npm tarball contains `package.json`, `cdp.js`, `README.md` and the four extension
2010
+ files required by managed reverse launches and their debug sidebar. Tests cover
2011
+ live command metadata, its 50-entry limit, error/session details, sidebar rendering,
2012
+ safe text display, reopening and clearing through native extension messaging.
package/cdp.js CHANGED
@@ -320,7 +320,7 @@ const custom_methods = {
320
320
  if (i + 1 < options.attempts) await new Promise(resolve => setTimeout(resolve, options.interval_ms))
321
321
  }
322
322
  return false
323
- }, options)
323
+ }, options, '_.click')
324
324
  },
325
325
  '_.find'(params) {
326
326
  const limit = params.limit ?? 20
@@ -346,7 +346,7 @@ const custom_methods = {
346
346
  count: nodes.snapshotLength,
347
347
  items,
348
348
  }
349
- }, limit)
349
+ }, limit, '_.find')
350
350
  },
351
351
  }
352
352
 
@@ -472,20 +472,52 @@ export class cdp extends EventTarget {
472
472
  })
473
473
  }
474
474
 
475
- evaluate_xpath(params, fn, options) {
475
+ async evaluate_xpath(params, fn, options, method) {
476
476
  if (params.target == null) throw new Error('Custom method requires target')
477
477
  const xpath = util.normalize_xpath(params.xpath)
478
- return this.call({
478
+ const { record, entry } = await this._resolve(params)
479
+ const reverse = record.reverse || record.cdp_ext
480
+ const execution_mode = params.execution_mode ?? entry.execution_mode ?? 'api'
481
+ if (reverse && !['api', 'debugger'].includes(execution_mode)) throw new Error('execution_mode must be api or debugger')
482
+ if (reverse && execution_mode === 'api') {
483
+ if (method) return record.socket.req({
484
+ method,
485
+ params: {
486
+ xpath,
487
+ options,
488
+ },
489
+ sessionId: entry.sessionId,
490
+ })
491
+ const results = await record.socket.req({
492
+ method: 'chrome.scripting.executeScript',
493
+ params: {
494
+ args: [{
495
+ target: { tabId: Number(entry.targetId) },
496
+ world: 'MAIN',
497
+ func: 'async (...args) => ({ value: await (' + fn + ')(...args) })',
498
+ args: [xpath, options ?? null],
499
+ }],
500
+ },
501
+ })
502
+ const result = results[0]?.result
503
+ if (!result) throw new Error('Browser API evaluation failed; check page CSP and extension access. No debugger fallback was used.')
504
+ return {
505
+ result: {
506
+ type: typeof result.value,
507
+ value: result.value,
508
+ },
509
+ }
510
+ }
511
+ return record.socket.req({
479
512
  method: 'Runtime.evaluate',
480
513
  params: {
481
- browser: params.browser,
482
- target: params.target,
483
514
  expression: '(' + fn + ')(...' + JSON.stringify([xpath, options]) + ')',
484
515
  returnByValue: true,
485
516
  awaitPromise: true,
486
517
  silent: true,
487
518
  userGesture: true,
488
519
  },
520
+ sessionId: entry.sessionId,
489
521
  })
490
522
  }
491
523
 
@@ -513,7 +545,7 @@ export class cdp extends EventTarget {
513
545
  }
514
546
  const info = browser.target_info[targetId]
515
547
  if (message.method === 'Target.attachedToTarget') {
516
- this._detach(browser, info?.sessionId)
548
+ if (info?.sessionId !== sessionId) this._detach(browser, info?.sessionId)
517
549
  info.sessionId = sessionId
518
550
  browser.session_targets[sessionId] = targetId
519
551
  }
@@ -658,8 +690,9 @@ export class cdp extends EventTarget {
658
690
  await fs.mkdir(record.extension_path, { recursive: true })
659
691
  record.extension_path = await fs.realpath(record.extension_path)
660
692
  record.extension_id = browser.extension_id(record.extension_path)
661
- await fs.copyFile(new URL('./cdp_ext/manifest.json', import.meta.url), path.join(record.extension_path, 'manifest.json'))
662
- await fs.copyFile(new URL('./cdp_ext/cdp_ext.js', import.meta.url), path.join(record.extension_path, 'cdp_ext.js'))
693
+ for (const filename of ['manifest.json', 'cdp_ext.js', 'sidebar.html', 'sidebar.js']) {
694
+ await fs.copyFile(new URL('./cdp_ext/' + filename, import.meta.url), path.join(record.extension_path, filename))
695
+ }
663
696
  }
664
697
  if (args['user-data-dir'] && record.update_preferences !== false) {
665
698
  await browser.update_profile_preferences(path.join(args['user-data-dir'], args['profile-directory'] || 'Default', 'Preferences'), {
@@ -776,6 +809,8 @@ export class cdp extends EventTarget {
776
809
  async _target(browser, target) {
777
810
  const socket = browser.socket
778
811
  const reverse = browser.reverse || browser.cdp_ext
812
+ const execution_mode = target.execution_mode ?? 'api'
813
+ if (reverse && !['api', 'debugger'].includes(execution_mode)) throw new Error('execution_mode must be api or debugger')
779
814
  if (target.group != null && target.group !== false) {
780
815
  const spec = typeof target.group === 'string' ? { name: target.group } : target.group
781
816
  browser.groups[spec.name] ??= {
@@ -818,17 +853,23 @@ export class cdp extends EventTarget {
818
853
  if (!info.incognito) throw new Error('Existing target is not incognito')
819
854
  }
820
855
  if (target.group != null && target.group !== false) info.groupId = await this._group(browser, target)
821
- if (!info.sessionId) {
856
+ if (!info.sessionId || (reverse && execution_mode === 'debugger')) {
822
857
  info.sessionId = (await socket.req({
823
858
  method: 'Target.attachToTarget',
824
859
  params: {
825
860
  targetId: target.targetId,
826
861
  flatten: true,
862
+ ...(reverse ? { execution_mode } : {}),
827
863
  },
828
864
  })).sessionId
829
865
  browser.session_targets[info.sessionId] = target.targetId
830
866
  }
831
867
  const sessionId = info.sessionId
868
+ target.setup_errors = []
869
+ if (reverse && execution_mode === 'api') {
870
+ target.sessionId = sessionId
871
+ return
872
+ }
832
873
  const steps = []
833
874
  if (target.initialize !== false) {
834
875
  for (const [option, domain] of Object.entries({
@@ -852,7 +893,6 @@ export class cdp extends EventTarget {
852
893
  if ((target.runtime ?? 'bootstrap') === 'bootstrap') steps.push(['Runtime.disable'])
853
894
  }
854
895
  steps.push(['Runtime.runIfWaitingForDebugger'])
855
- target.setup_errors = []
856
896
  for (const [method, params] of steps) {
857
897
  try {
858
898
  await socket.req({
@@ -868,13 +908,7 @@ export class cdp extends EventTarget {
868
908
  target.sessionId = sessionId
869
909
  }
870
910
 
871
- async call({ method, params = {} }) {
872
- if (method.startsWith('_.')) {
873
- const handler = this.custom_methods[method]
874
- if (!handler) throw new Error('Unknown CDP extension: ' + method)
875
- return handler.call(this, params)
876
- }
877
- const { browser = 'main', target, ...native } = params
911
+ async _resolve({ browser = 'main', target }) {
878
912
  const spec = typeof browser === 'string' ? { name: browser } : browser
879
913
  const record = this.browsers[spec.name] ??= {
880
914
  user_data_dir: ((spec.reverse ?? this.options.reverse) || (spec.cdp_ext ?? this.options.cdp_ext)) ? null : path.join(this.base_path, spec.name),
@@ -932,6 +966,20 @@ export class cdp extends EventTarget {
932
966
  await entry.connecting
933
967
  }
934
968
  }
969
+ return {
970
+ record,
971
+ entry,
972
+ }
973
+ }
974
+
975
+ async call({ method, params = {} }) {
976
+ if (method.startsWith('_.')) {
977
+ const handler = this.custom_methods[method]
978
+ if (!handler) throw new Error('Unknown CDP extension: ' + method)
979
+ return handler.call(this, params)
980
+ }
981
+ const { browser, target, execution_mode, ...native } = params
982
+ const { record, entry } = await this._resolve(params)
935
983
  if (method === 'Target.disposeBrowserContext' && target == null) {
936
984
  const context = Object.values(record.contexts).find(value => value.browserContextId === native.browserContextId)
937
985
  if (context) return this._dispose_context(record, context, native)
@@ -12,11 +12,57 @@ let auto_attach = {}
12
12
  let bootstrapping
13
13
  let checking = false
14
14
  let connection_message
15
+ const debug_commands = []
16
+ const debug_ports = []
17
+
18
+ function debug_record(fields) {
19
+ const record = {
20
+ time: Date.now(),
21
+ ...fields,
22
+ status: 'running',
23
+ }
24
+ debug_commands.unshift(record)
25
+ debug_commands.length = Math.min(debug_commands.length, 50)
26
+ debug_update()
27
+ return record
28
+ }
29
+
30
+ function debug_update() {
31
+ const state = {
32
+ status: connection_message ?? (config.browser ? 'Connecting to server' : 'Waiting for trigger'),
33
+ connected: socket?.readyState === WebSocket.OPEN,
34
+ browser: config.browser ?? '',
35
+ endpoint: config.websocket_url ?? '',
36
+ extension_id: chrome.runtime.id,
37
+ sessions: Object.keys(sessions).length,
38
+ debugger_tabs: Object.keys(attached).length,
39
+ commands: debug_commands,
40
+ }
41
+ for (const port of debug_ports) {
42
+ try {
43
+ port.postMessage(state)
44
+ } catch {}
45
+ }
46
+ }
47
+
48
+ chrome.runtime.onConnect.addListener(port => {
49
+ if (port.name !== 'cdp_debug') return
50
+ debug_ports.push(port)
51
+ port.onDisconnect.addListener(() => debug_ports.splice(debug_ports.indexOf(port), 1))
52
+ port.onMessage.addListener(message => {
53
+ if (message !== 'clear') return
54
+ debug_commands.length = 0
55
+ debug_update()
56
+ })
57
+ configured.then(debug_update)
58
+ })
59
+ chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true })
15
60
 
16
61
  function report_connection(message) {
17
62
  if (connection_message === message) return
18
63
  connection_message = message
19
64
  console.info('[cdp_ext] ' + message)
65
+ debug_update()
20
66
  }
21
67
 
22
68
  async function bootstrap(tab) {
@@ -34,29 +80,62 @@ async function bootstrap(tab) {
34
80
  await configured
35
81
  while (bootstrapping) await bootstrapping
36
82
  return bootstrapping = (async () => {
37
- const changed = ['browser', 'websocket_url', 'token'].some(key => settings[key] !== config[key])
38
- if (changed && config.browser && socket?.readyState < WebSocket.CLOSING) {
39
- const probe = new WebSocket(settings.websocket_url)
40
- probe.onopen = () => probe.send(JSON.stringify({
41
- browser: settings.browser,
42
- extension_id: chrome.runtime.id,
43
- token: settings.token,
44
- error: 'This Chrome profile is already connected as ' + config.browser,
45
- }))
46
- return
47
- }
48
- if (changed) {
49
- Object.assign(config, settings)
50
- await chrome.storage.session.set({ cdp_bootstrap: settings })
51
- if (socket && socket.readyState !== WebSocket.CLOSED) {
52
- await new Promise(resolve => {
53
- socket.addEventListener('close', resolve, { once: true })
54
- socket.close()
55
- })
83
+ const tabs = await chrome.tabs.query({})
84
+ if (!tabs.some(current => current.id === tab.id && (current.pendingUrl ?? current.url) === text)) return
85
+ const trigger = debug_commands.find(record => record.method === 'cdp_ext.trigger' &&
86
+ record.tabId === tab.id && record.browser === settings.browser && record.endpoint === settings.websocket_url) ?? debug_record({
87
+ method: 'cdp_ext.trigger',
88
+ tabId: tab.id,
89
+ browser: settings.browser,
90
+ endpoint: settings.websocket_url,
91
+ })
92
+ try {
93
+ const changed = ['browser', 'websocket_url', 'token'].some(key => settings[key] !== config[key])
94
+ if (changed && config.browser && socket?.readyState < WebSocket.CLOSING) {
95
+ const probe = new WebSocket(settings.websocket_url)
96
+ trigger.error = 'This Chrome profile is already connected as ' + config.browser
97
+ probe.onopen = () => probe.send(JSON.stringify({
98
+ browser: settings.browser,
99
+ extension_id: chrome.runtime.id,
100
+ token: settings.token,
101
+ error: trigger.error,
102
+ }))
103
+ } else {
104
+ if (changed) {
105
+ Object.assign(config, settings)
106
+ connection_message = undefined
107
+ debug_update()
108
+ await chrome.storage.session.set({ cdp_bootstrap: settings })
109
+ if (socket && socket.readyState !== WebSocket.CLOSED) {
110
+ await new Promise(resolve => {
111
+ socket.addEventListener('close', resolve, { once: true })
112
+ socket.close()
113
+ })
114
+ }
115
+ await disconnecting
116
+ }
117
+ await connect()
56
118
  }
57
- await disconnecting
119
+ const current_tabs = await chrome.tabs.query({})
120
+ const current = current_tabs.find(current => current.id === tab.id)
121
+ if ((current?.pendingUrl ?? current?.url) === text) {
122
+ if (current_tabs.length === 1) {
123
+ await chrome.tabs.update(tab.id, { url: chrome.runtime.getURL('sidebar.html?connection') })
124
+ trigger.action = 'opened connection page'
125
+ } else {
126
+ await chrome.tabs.remove(tab.id)
127
+ trigger.action = 'closed tab'
128
+ }
129
+ }
130
+ trigger.status = trigger.error ? 'error' : 'done'
131
+ } catch (error) {
132
+ trigger.status = 'error'
133
+ trigger.error = error.message
134
+ throw error
135
+ } finally {
136
+ trigger.duration_ms = Date.now() - trigger.time
137
+ debug_update()
58
138
  }
59
- await connect()
60
139
  })().finally(() => {
61
140
  bootstrapping = null
62
141
  })
@@ -79,7 +158,7 @@ function target_info(tab) {
79
158
  }
80
159
 
81
160
  function detached(source) {
82
- if (!attached[source.tabId]) return
161
+ if (!sessions[String(source.tabId)]) return
83
162
  delete attached[source.tabId]
84
163
  for (const [id, session] of Object.entries(sessions)) {
85
164
  if (session.tabId === source.tabId) delete sessions[id]
@@ -91,6 +170,7 @@ function detached(source) {
91
170
  targetId: String(source.tabId),
92
171
  },
93
172
  })
173
+ debug_update()
94
174
  }
95
175
 
96
176
  async function attach_tab(tabId) {
@@ -174,6 +254,12 @@ const methods = {
174
254
  },
175
255
  async 'Target.attachToTarget'(params) {
176
256
  const tabId = Number(params.targetId)
257
+ if (params.execution_mode === 'api') {
258
+ await chrome.tabs.get(tabId)
259
+ sessions[String(tabId)] = { tabId }
260
+ debug_update()
261
+ return { sessionId: String(tabId) }
262
+ }
177
263
  if (attaching[tabId]) return attaching[tabId]
178
264
  if (attached[tabId]) return { sessionId: String(tabId) }
179
265
  return attaching[tabId] = attach_tab(tabId).finally(() => delete attaching[tabId])
@@ -184,7 +270,7 @@ const methods = {
184
270
  if (source.sessionId) {
185
271
  await chrome.debugger.sendCommand({ tabId: source.tabId }, 'Target.detachFromTarget', { sessionId: source.sessionId })
186
272
  } else {
187
- await chrome.debugger.detach(source)
273
+ if (attached[source.tabId]) await chrome.debugger.detach(source)
188
274
  detached(source)
189
275
  }
190
276
  },
@@ -206,6 +292,7 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
206
292
  sessionId: params.sessionId,
207
293
  }
208
294
  else delete sessions[params.sessionId]
295
+ debug_update()
209
296
  }
210
297
  send({
211
298
  method,
@@ -229,6 +316,7 @@ chrome.tabs.onUpdated.addListener((id, changes, tab) => {
229
316
  })
230
317
  })
231
318
  chrome.tabs.onRemoved.addListener(id => {
319
+ detached({ tabId: id })
232
320
  if (discovering) send({
233
321
  method: 'Target.targetDestroyed',
234
322
  params: { targetId: String(id) },
@@ -296,6 +384,12 @@ async function connect() {
296
384
  if (!event.data) return
297
385
  const request = JSON.parse(event.data)
298
386
  const { method, params = {}, sessionId, id } = request
387
+ const command = debug_record({
388
+ id,
389
+ method,
390
+ sessionId,
391
+ execution_mode: params.execution_mode,
392
+ })
299
393
  let response
300
394
  try {
301
395
  let result
@@ -316,11 +410,62 @@ async function connect() {
316
410
  else resolve(value)
317
411
  }))
318
412
  : await object[name](...args)
413
+ } else if (method === '_.click' || method === '_.find') {
414
+ const source = sessions[sessionId]
415
+ if (!source || source.sessionId) throw new Error('XPath browser API commands require a registered root tab')
416
+ const results = await chrome.scripting.executeScript({
417
+ target: { tabId: source.tabId },
418
+ args: [method, params.xpath, params.options],
419
+ func: async (method, xpath, options) => {
420
+ if (method === '_.click') {
421
+ for (let i = 0; i < options.attempts; i++) {
422
+ const node = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue
423
+ if (node) {
424
+ if (typeof node.click !== 'function') return false
425
+ node.click()
426
+ return true
427
+ }
428
+ if (i + 1 < options.attempts) await new Promise(resolve => setTimeout(resolve, options.interval_ms))
429
+ }
430
+ return false
431
+ }
432
+ const nodes = document.evaluate(xpath, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
433
+ const items = []
434
+ for (let i = 0; i < Math.min(nodes.snapshotLength, options); i++) {
435
+ const node = nodes.snapshotItem(i)
436
+ items.push({
437
+ node_type: node.nodeType,
438
+ node_name: node.nodeName,
439
+ text: String(node.innerText ?? node.textContent ?? '').trim().slice(0, 2000),
440
+ tag: node.tagName?.toLowerCase(),
441
+ id: node.id,
442
+ class: node.className == null ? undefined : String(node.className),
443
+ href: node.href,
444
+ value: node.value,
445
+ rect: node.getBoundingClientRect?.().toJSON(),
446
+ })
447
+ }
448
+ return {
449
+ count: nodes.snapshotLength,
450
+ items,
451
+ }
452
+ },
453
+ })
454
+ if (!Object.hasOwn(results[0] ?? {}, 'result')) throw new Error('XPath browser API execution failed')
455
+ result = {
456
+ result: {
457
+ type: typeof results[0].result,
458
+ value: results[0].result,
459
+ },
460
+ }
319
461
  } else {
320
462
  if (sessionId == null && !methods[method]) throw new Error('Unsupported reverse browser command: ' + method)
321
- result = sessionId != null
322
- ? await chrome.debugger.sendCommand(sessions[sessionId], method, params)
323
- : await methods[method](params)
463
+ if (sessionId != null) {
464
+ const source = sessions[sessionId]
465
+ if (!source) throw new Error('Unknown debugger session: ' + sessionId)
466
+ await methods['Target.attachToTarget']({ targetId: String(source.tabId) })
467
+ result = await chrome.debugger.sendCommand(source, method, params)
468
+ } else result = await methods[method](params)
324
469
  }
325
470
  response = {
326
471
  id,
@@ -335,15 +480,20 @@ async function connect() {
335
480
  },
336
481
  }
337
482
  }
483
+ command.status = response.error ? 'error' : 'done'
484
+ command.error = response.error?.message
485
+ command.duration_ms = Date.now() - command.time
486
+ debug_update()
338
487
  if (id != null && connection.readyState === WebSocket.OPEN) connection.send(JSON.stringify(response))
339
488
  }
340
489
  connection.onclose = () => {
341
490
  report_connection('Waiting for CDP.js server at ' + endpoint + '; retrying automatically.')
342
491
  discovering = false
343
- disconnecting = Promise.all(Object.keys(attached).map(id => {
492
+ disconnecting = Promise.all(Object.keys(sessions).filter(id => !sessions[id].sessionId).map(id => {
344
493
  const source = { tabId: Number(id) }
494
+ const was_attached = attached[id]
345
495
  detached(source)
346
- return chrome.debugger.detach(source).catch(() => {})
496
+ if (was_attached) return chrome.debugger.detach(source).catch(() => {})
347
497
  })).finally(() => disconnecting = null)
348
498
  }
349
499
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "CDP reverse connection",
4
- "version": "0.5.1",
4
+ "version": "0.7.0",
5
5
  "minimum_chrome_version": "125",
6
6
  "permissions": [
7
7
  "accessibilityFeatures.read",
@@ -64,7 +64,12 @@
64
64
  "host_permissions": [
65
65
  "<all_urls>"
66
66
  ],
67
- "action": {},
67
+ "action": {
68
+ "default_title": "CDP.js debug"
69
+ },
70
+ "side_panel": {
71
+ "default_path": "sidebar.html"
72
+ },
68
73
  "incognito": "spanning",
69
74
  "background": {
70
75
  "service_worker": "cdp_ext.js"
@@ -0,0 +1,37 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <meta charset="utf-8">
4
+ <meta name="viewport" content="width=device-width, initial-scale=1">
5
+ <title>CDP.js debug</title>
6
+ <style>
7
+ :root { color-scheme: light dark; font: 14px system-ui, sans-serif; }
8
+ body { margin: 16px; }
9
+ header { display: flex; align-items: center; justify-content: space-between; }
10
+ h1 { font-size: 18px; margin: 0; }
11
+ h2 { font-size: 14px; margin: 24px 0 12px; }
12
+ button { font: inherit; cursor: pointer; padding: 5px 10px; }
13
+ #status { border-left: 3px solid #d99b24; padding-left: 10px; overflow-wrap: anywhere; }
14
+ #status.connected { border-color: #35a76a; }
15
+ dt { opacity: .65; margin-top: 12px; }
16
+ dd { margin: 3px 0; overflow-wrap: anywhere; font-family: monospace; }
17
+ #commands { padding: 0; list-style: none; }
18
+ li { border-top: 1px solid #8885; padding: 10px 0; }
19
+ strong { display: block; overflow-wrap: anywhere; font: 13px monospace; }
20
+ small { display: block; opacity: .7; margin-top: 5px; font-size: 12px; }
21
+ .error { color: light-dark(#b42318, #ff938a); white-space: pre-wrap; overflow-wrap: anywhere; }
22
+ </style>
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>
25
+ <p id="status" role="status">Connecting to extension…</p>
26
+ <dl>
27
+ <dt>Browser</dt><dd id="browser">—</dd>
28
+ <dt>Server</dt><dd id="endpoint">—</dd>
29
+ <dt>Extension</dt><dd id="extension">—</dd>
30
+ <dt>Routing sessions</dt><dd id="sessions">0</dd>
31
+ <dt>Debugger tabs</dt><dd id="debugger_tabs">0</dd>
32
+ </dl>
33
+ <h2>Recent activity <small>Latest 50 · memory only</small></h2>
34
+ <p id="empty">No activity received.</p>
35
+ <ol id="commands"></ol>
36
+ <script src="sidebar.js"></script>
37
+ </html>
@@ -0,0 +1,54 @@
1
+ const port = chrome.runtime.connect({ name: 'cdp_debug' })
2
+ const status = document.querySelector('#status')
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
+ }
9
+
10
+ port.onMessage.addListener(state => {
11
+ status.textContent = state.status
12
+ status.classList.toggle('connected', state.connected)
13
+ for (const [id, value] of Object.entries({
14
+ browser: state.browser,
15
+ endpoint: state.endpoint,
16
+ extension: state.extension_id,
17
+ sessions: state.sessions,
18
+ debugger_tabs: state.debugger_tabs ?? 0,
19
+ })) document.getElementById(id).textContent = value === '' ? '—' : value
20
+ document.querySelector('#empty').hidden = state.commands.length > 0
21
+ document.querySelector('#commands').replaceChildren(...state.commands.map(command => {
22
+ const row = document.createElement('li')
23
+ const method = document.createElement('strong')
24
+ method.textContent = command.method
25
+ const details = document.createElement('small')
26
+ details.textContent = [
27
+ new Date(command.time).toLocaleTimeString(),
28
+ command.id == null ? (command.method === 'cdp_ext.trigger' ? 'trigger' : 'notification') : '#' + command.id,
29
+ command.sessionId ? 'session ' + command.sessionId : 'browser',
30
+ command.status,
31
+ command.execution_mode,
32
+ command.tabId == null ? '' : 'tab ' + command.tabId,
33
+ command.browser,
34
+ command.endpoint,
35
+ command.action,
36
+ command.duration_ms == null ? '' : command.duration_ms + ' ms',
37
+ ].filter(Boolean).join(' · ')
38
+ row.append(method, details)
39
+ if (command.error) {
40
+ const error = document.createElement('p')
41
+ error.className = 'error'
42
+ error.textContent = command.error
43
+ row.append(error)
44
+ }
45
+ return row
46
+ }))
47
+ })
48
+ port.onDisconnect.addListener(() => {
49
+ clearInterval(heartbeat)
50
+ status.textContent = 'Extension disconnected. Reopen this panel after reloading the extension.'
51
+ status.classList.remove('connected')
52
+ document.querySelector('#clear').disabled = true
53
+ })
54
+ document.querySelector('#clear').addEventListener('click', () => port.postMessage('clear'))
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@mefistofelix/cdp.js",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
4
4
  "description": "Low-level Chromium DevTools Protocol client for Node.js, Deno and Bun, with no runtime dependencies.",
5
5
  "type": "module",
6
6
  "exports": "./cdp.js",
7
7
  "files": [
8
8
  "cdp.js",
9
9
  "cdp_ext/manifest.json",
10
- "cdp_ext/cdp_ext.js"
10
+ "cdp_ext/cdp_ext.js",
11
+ "cdp_ext/sidebar.html",
12
+ "cdp_ext/sidebar.js"
11
13
  ],
12
14
  "engines": {
13
15
  "node": ">=22.19.0"