@mefistofelix/cdp.js 0.3.0 → 0.5.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
@@ -25,8 +25,8 @@ import api, { util, jsrpc, browser, cdp } from '@mefistofelix/cdp.js'
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
27
  The package includes both `cdp_ext/` files. With `cdp_ext: true`, the manager
28
- writes a configured extension copy into the browser's profile directory and loads
29
- it automatically; no manual extraction or build is needed. Reverse mode still
28
+ prepares and loads the extension automatically with a custom user-data directory.
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`.
31
31
  In Bun, use `bun add @mefistofelix/cdp.js` and the ordinary package import above.
32
32
 
@@ -37,6 +37,7 @@ In Bun, use `bun add @mefistofelix/cdp.js` and the ordinary package import above
37
37
  - Browser proxies and named direct-mode contexts with independent proxies/storage
38
38
  and automatic cleanup after their last target closes.
39
39
  - Incognito targets in direct and reverse modes.
40
+ - Named, colored tab groups through the reverse Chrome extension.
40
41
  - Attachment through WebSocket, HTTP discovery or a debugging port.
41
42
  - Optional Chrome extension reverse connections through one native Deno/Bun server,
42
43
  without browser remote-debugging arguments.
@@ -188,7 +189,7 @@ belong in `params.target` and do not inherit from the constructor.
188
189
 
189
190
  For a new browser record, configuration is assembled in this order:
190
191
 
191
- 1. Default `user_data_dir` derived from `base_path` and the browser name.
192
+ 1. Default `user_data_dir`: unset in extension/passive reverse mode, otherwise derived from `base_path` and the browser name.
192
193
  2. Constructor browser options.
193
194
  3. The `params.browser` configuration object.
194
195
 
@@ -224,7 +225,7 @@ local configuration.
224
225
  | `base_path` | string, `'.cdp'` | Constructor-only profile root, resolved against the working directory. |
225
226
  | `reverse_server` | object, `{}` | Constructor-only settings for the shared lazy listener: `hostname` defaults to `'127.0.0.1'`, `port` to `9223`. Independent of browser attachment `host`/`port`. |
226
227
  | `name` | string, `'main'` when routing is omitted | Required in a browser configuration object; dictionary key and default profile directory label. |
227
- | `user_data_dir` | absolute path string or `null`, `<base_path>/<name>` | Chromium user-data directory containing profiles such as `Default`. Explicit paths should be absolute. `null` omits the switch and skips manager preference-file writes; Chromium selects its system directory. |
228
+ | `user_data_dir` | absolute path string or `null`, `null` in extension/passive reverse mode, otherwise `<base_path>/<name>` | Chromium user-data directory containing profiles such as `Default`. Explicit paths should be absolute. `null` omits the switch and skips manager preference-file writes; Chromium selects its system directory. |
228
229
  | `executable_path` | string, automatic discovery | Executable to spawn; takes precedence over `CDP_BROWSER`. |
229
230
  | `headless` | boolean, `false` | When true, add `headless: 'new'` and default `window-size: '1440,900'`. |
230
231
  | `windowsHide` | boolean, effective headless mode | Hide the spawned process's console on Windows. Defaults to true when the final arguments include `--headless`, false otherwise; an explicit value wins. Does not hide the parent terminal. |
@@ -241,9 +242,10 @@ local configuration.
241
242
  | `request_timeout_ms` | number, `30000` | Default timeout for each protocol request on this browser's socket. |
242
243
  | `waitForDebuggerOnStart` | boolean, `false` | Ask Chromium to pause newly auto-attached targets. Applied to `Target.setAutoAttach` when connecting; not a CLI argument or target option. |
243
244
  | `websocket_url` | string, unset | Attach directly to a browser WebSocket endpoint. |
244
- | `cdp_ext` | boolean, `false` | Launch with the reverse extension under Deno or Bun. Start one shared server automatically if needed, prepare a profile-specific extension copy and wait for its connection. |
245
+ | `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
+ | `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`. |
245
247
  | `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. |
246
- | `extension_id` | string, browser name for external reverse attachment | Incoming extension ID to associate with this logical browser. Set automatically for managed `cdp_ext` launches. |
248
+ | `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. |
247
249
  | `http_url` | string, unset | Discover the endpoint from an HTTP(S) base URL or exact `/json/version` URL. |
248
250
  | `port` | number, unset | Discover an existing browser at `http://<host>:<port>/json/version`. |
249
251
  | `host` | string, `'127.0.0.1'` | Host used with `port`. |
@@ -491,8 +493,8 @@ can configure its native incognito scope. See
491
493
  [cdp_ext/manifest.json](cdp_ext/manifest.json) and
492
494
  [cdp_ext/cdp_ext.js](cdp_ext/cdp_ext.js) form a Manifest V3 extension for Chrome
493
495
  125+. The extension connects out to one WebSocket server. The server serves all
494
- browser instances on the same port; each extension announces its native ID
495
- in its first message. No remote-debugging argument is needed.
496
+ browser instances on the same port; bootstrapped extensions announce the logical
497
+ browser name and native extension ID. No remote-debugging argument is needed.
496
498
 
497
499
  Run the server under Deno using its native
498
500
  [WebSocket upgrade](https://docs.deno.com/api/deno/websockets/) or under Bun using
@@ -512,6 +514,7 @@ const result = await client.call({
512
514
  browser: {
513
515
  name: 'work',
514
516
  cdp_ext: true,
517
+ user_data_dir: '/absolute/path/to/work-profile',
515
518
  headless: true,
516
519
  },
517
520
  target: 'demo',
@@ -520,65 +523,84 @@ const result = await client.call({
520
523
  })
521
524
  ```
522
525
 
523
- The manager copies the two extension files into `cdp_ext/` inside the effective
524
- profile directory, sets the shared server URL in that copy's manifest and loads
525
- it before browser startup. Without an effective user-data directory, the copy
526
- lives under `base_path/<name>/<profile-directory>/` instead (`Default` when unset);
527
- Chromium still uses its system profile. Profile preferences follow the existing
528
- `update_preferences` rules.
529
- The generated launch arguments omit remote debugging and enable this extension.
530
- An `extensions` array also selects those directories; `extensions: true` keeps
531
- normally enabled profile extensions through `'*'`. Raw `args` still win and can
532
- prevent the bridge from loading or connecting, causing a connection timeout.
533
-
534
- For external attachment, load unpacked `cdp_ext/` through `chrome://extensions`
535
- or the [local-extension argument options](#local-extensions-at-startup).
536
- Use its ID shown in Chrome, or `browser.extension_id(directory)`, as the
537
- browser option `extension_id`. The logical `name` stays in the manager.
538
-
539
- The extension configuration is read from `chrome.runtime.getManifest()`:
540
-
541
- | Manifest `cdp` key | Default | Meaning |
542
- | --- | --- | --- |
543
- | `websocket_url` | `ws://127.0.0.1:9223` | The shared server endpoint. Port 9223 avoids the usual direct CDP port 9222. |
544
- | `reconnect_ms` | `5000` | Connection retry interval while the extension worker is awake; also the empty-message heartbeat interval while connected. Use a positive value below 30000. |
545
-
546
- The first message uses `chrome.runtime.id`, not a configured logical browser name.
547
- For an unpacked extension without a manifest `key`, Chromium derives this ID from
548
- its directory. Managed launches use a separate directory for each profile, so
549
- the ID is stable across reconnects and restarts, and the manager knows which
550
- browser it belongs to. External instances must likewise use distinct extension
551
- directories. Moving the directory changes the ID. The server rejects duplicate
552
- live IDs and announcements that differ from the WebSocket's `chrome-extension://`
553
- origin. This is routing information, not an authentication credential.
526
+ With just `cdp_ext: true`, Chrome uses its system user-data directory. No
527
+ `user-data-dir` switch is added. An already installed and enabled bridge reads
528
+ the local URL and connects to the waiting manager. The user enters no extension
529
+ ID or WebSocket address. Load the package's `cdp_ext/` once through
530
+ `chrome://extensions` (developer mode, Load unpacked); reload it after upgrading.
554
531
 
555
532
  ```js
556
- import { browser, cdp } from './cdp.js'
557
-
558
533
  const client = new cdp({
559
- reverse: true,
560
- connect_timeout_ms: 60000,
561
- reverse_server: {
562
- hostname: '127.0.0.1',
563
- port: 9223,
564
- },
534
+ cdp_ext: true,
565
535
  })
566
- const result = await client.call({
567
- method: 'Runtime.evaluate',
568
- params: {
569
- browser: {
570
- name: 'other',
571
- reverse: true,
572
- extension_id: browser.extension_id('/absolute/path/to/cdp_ext'),
573
- },
574
- target: 'demo',
575
- expression: 'document.title',
576
- },
536
+ await client.call({
537
+ method: 'Browser.getVersion',
538
+ params: { browser: 'work' },
577
539
  })
578
- console.log(result.result.value)
579
- await client.close()
580
540
  ```
581
541
 
542
+ Provide `user_data_dir` or raw `args['user-data-dir']` to choose a custom
543
+ directory. An effective custom directory makes `cdp_ext_install` default to true:
544
+ the manager copies the two extension files into
545
+ `<user-data-dir>/<profile-directory>/cdp_ext/` (`Default` when unset), sets the
546
+ shared server URL in that manifest and adds startup loading flags. Normal launch
547
+ options and profile preferences then apply. Set `cdp_ext_install: false` to use
548
+ an extension already installed in that profile, or true to force preparation and
549
+ loading. A null/false raw directory override makes automatic installation default off.
550
+
551
+ Forced installation without a directory places only the extension copy under
552
+ `<base_path>/<name>/<profile-directory>/cdp_ext/`; Chrome still selects its system
553
+ profile. The manager never infers system profile paths for preference writes.
554
+ Loading switches apply only at Chrome startup. An already running Chrome cannot
555
+ load a new extension from a forwarded command line: install it once or restart
556
+ that Chrome before forcing installation.
557
+
558
+ With installation disabled, opening the link skips the automation launch preset
559
+ so normal Chrome can forward the URL into its existing instance. High-level
560
+ headless, proxy, image, translation, login and extension options still generate
561
+ their flags, followed by raw overrides and the same enabled-only wildcard
562
+ expansion. The manager does not claim ownership of Chrome. Preference writes are
563
+ independent of extension installation: `update_preferences` controls them and
564
+ they require an explicit directory. A running instance does not apply new flags.
565
+ With installation
566
+ enabled, the normal launch preset applies, generated flags omit remote debugging
567
+ and enable the bridge. An `extensions` array adds its directories;
568
+ `extensions: true` retains enabled profile extensions through `'*'`. Raw `args`
569
+ still win and can prevent loading or connection.
570
+
571
+ The manager opens an actual local HTTP URL on the shared listener, for example
572
+ `http://127.0.0.1:9223/cdp_ext?browser=work&websocket_url=...&token=...`.
573
+ The port is the effective listening port, including an automatically allocated
574
+ port for `reverse_server.port: 0`. Name, WebSocket URL and generated binding token
575
+ are internal bootstrap parameters, not additional user options. The extension
576
+ observes startup tabs, tab creation and URL changes. Bootstrap accepts only
577
+ loopback HTTP URLs with a WebSocket endpoint on the same host/port. The bootstrap
578
+ tab remains open.
579
+
580
+ The first announcement contains `{ browser, extension_id, token }`. The browser
581
+ field is the logical label; `extension_id` is `chrome.runtime.id` and must match
582
+ the upgrade origin. The token must match the pending manager record. One extension
583
+ directory and native ID can therefore serve multiple profiles with distinct
584
+ logical labels. No logical name is written into a manifest. Bootstrap settings
585
+ use `chrome.storage.session` to survive worker restarts; they disappear on browser
586
+ restart or extension reload. Manager browser/target/session mappings remain in memory.
587
+
588
+ A profile has one active logical connection. A link requesting another name or
589
+ manager while connected returns an explicit error, preserving the existing
590
+ connection. After disconnection, a new bootstrap can rebind it.
591
+
592
+ Passive external attachment remains available through `reverse: true`: it opens
593
+ no URL and waits for `extension_id` (or the browser label). Without bootstrap,
594
+ the extension announces its native ID and uses its manifest endpoint. This
595
+ compatibility mode is optional; `cdp_ext: true` needs neither `reverse` nor an ID.
596
+
597
+ The manifest's `cdp` configuration supplies initial connection defaults:
598
+
599
+ | Manifest `cdp` key | Default | Meaning |
600
+ | --- | --- | --- |
601
+ | `websocket_url` | `ws://127.0.0.1:9223` | Initial shared endpoint; URL bootstrap overrides it. |
602
+ | `reconnect_ms` | `5000` | Awake retry and connected heartbeat interval; use a positive value below 30000. |
603
+
582
604
  Construction and direct CDP calls do not start a listener. The first browser call
583
605
  using `reverse: true` or `cdp_ext: true` starts the shared server lazily, with the
584
606
  constructor's `reverse_server` settings. To mix direct and reverse browsers, leave the
@@ -615,7 +637,8 @@ Native Bun WebSocket limits still apply.
615
637
  Reverse calls reuse the normal target setup, custom handlers, mappings, routed
616
638
  events, request correlation, timeout and error behavior. `reverse: true` alone
617
639
  attaches externally: the manager neither launches nor updates that browser.
618
- `cdp_ext: true` owns the launched process and applies normal launch preferences.
640
+ `cdp_ext_install: true` owns the launched process and applies normal launch preferences.
641
+ With installation off, Chrome is external and remains open on manager close.
619
642
  Closing the manager closes owned processes and the server, while external
620
643
  browsers and tabs remain open. The extension retries when the server returns;
621
644
  the next call rebuilds discovery and sessions, preserving surviving target IDs.
@@ -627,20 +650,11 @@ for that extension to reconnect; they do not spawn another process. Only the
627
650
  child process's confirmed exit permits a new launch. Explicit manager `close()`
628
651
  still terminates owned browsers.
629
652
 
630
- The current reverse registration uses a profile-specific extension copy. It does
631
- not assign a new logical name to an already running Chrome using a shared extension
632
- directory. Unpacked `chrome.runtime.id` identifies the extension path, not the
633
- user-data directory or browser executable. Shared-path registration and URL-based
634
- bootstrap are tracked in [TODO.md](TODO.md), not implemented behavior.
635
-
636
- Chrome's [process singleton](https://github.com/chromium/chromium/blob/main/chrome/browser/process_singleton.h)
637
- uses the user-data directory to decide whether to forward a new launch to an
638
- existing instance. `no-default-browser-check` only suppresses the default-browser
639
- prompt. The current Chromium [notification callback](https://github.com/chromium/chromium/blob/main/chrome/browser/chrome_browser_main.cc)
640
- rejects forwarding when either command line enables automation or uses headless
641
- mode. This library generates `enable-automation` by default; a normal browser
642
- started with `args['enable-automation']: false` can behave differently. Do not
643
- infer ownership of the real browser from a short-lived forwarding process.
653
+ Chrome selects an existing instance by user-data directory. The minimal link
654
+ opener preserves this normal behavior. Automation/headless flags can prevent
655
+ forwarding; `no-default-browser-check` only suppresses a prompt. The short-lived
656
+ launcher is `record.opener`; its exit is not proof of browser exit. Installed
657
+ launches use `record.proc` instead.
644
658
 
645
659
  The [service-worker lifecycle](https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle)
646
660
  allows termination while offline. A 30-second Chrome alarm wakes the worker for
@@ -650,8 +664,8 @@ These are transport messages, not event buffering or polling of browser state.
650
664
 
651
665
  ### Reverse protocol and supported commands
652
666
 
653
- The first extension-to-server message is `{ "browser": "<extension ID>" }`. Subsequent
654
- messages use existing CDP envelopes:
667
+ The first announcement contains `{ browser, extension_id, token }` for bootstrap,
668
+ or the native extension ID for passive attachment. Subsequent messages use CDP envelopes:
655
669
 
656
670
  - Request: `{ id, method, params, sessionId? }`.
657
671
  - Response: `{ id, result }` or `{ id, error: { code, message } }`.
@@ -1273,6 +1287,7 @@ await client.call({
1273
1287
  | `targetId` | `null` | Optional existing physical target ID. Reused if discovered; otherwise create a new target. |
1274
1288
  | `context` | unset | Direct-mode context name or first-use configuration object; see named contexts above. Reverse mode rejects this option. |
1275
1289
  | `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. |
1290
+ | `group` | unset | Reverse-only tab group name or first-use `{ name, color }` configuration. False skips grouping. |
1276
1291
  | `create_params` | `{}` | Native creation overrides, merged over `url: 'about:blank'` and `background: true`; start from a blank target without requesting foreground activation. |
1277
1292
  | `initialize` | `true` | Run the convenience setup sequence; false skips it. |
1278
1293
  | `runtime` | `'bootstrap'` | Enable Runtime for setup, then disable reporting. `true` leaves it enabled; `false` skips both operations. |
@@ -1283,6 +1298,78 @@ await client.call({
1283
1298
  | `binding` | `'_send_to_cdp'` | Default page-to-host JavaScript binding. String changes its name; false skips installation; true selects the default name. |
1284
1299
  | `background_service` | `true` | Clear, observe and record `pushMessaging` background-service events in Chromium. |
1285
1300
 
1301
+ ### Named tab groups
1302
+
1303
+ With `cdp_ext: true` or external `reverse: true`, set `target.group` when first
1304
+ configuring a target:
1305
+
1306
+ ```js
1307
+ await client.call({
1308
+ method: 'Page.navigate',
1309
+ params: {
1310
+ browser: {
1311
+ name: 'work',
1312
+ cdp_ext: true,
1313
+ },
1314
+ target: {
1315
+ name: 'search',
1316
+ group: {
1317
+ name: 'Research',
1318
+ color: 'blue',
1319
+ },
1320
+ },
1321
+ url: 'https://example.com/',
1322
+ },
1323
+ })
1324
+ await client.call({
1325
+ method: 'Page.navigate',
1326
+ params: {
1327
+ browser: 'work',
1328
+ target: {
1329
+ name: 'notes',
1330
+ group: 'Research',
1331
+ },
1332
+ url: 'https://example.com/notes',
1333
+ },
1334
+ })
1335
+ ```
1336
+
1337
+ | Group option | Default | Meaning |
1338
+ | --- | --- | --- |
1339
+ | `name` | required in an object | Logical name in `record.groups`; also the visible group title. A string reference creates a default group if this name is new. |
1340
+ | `color` | unset | Chrome's native color selection for new groups; otherwise retain the current color. Explicit values: `grey`, `blue`, `red`, `yellow`, `green`, `pink`, `purple`, `cyan`, `orange`. |
1341
+
1342
+ The first use of a group name records its configuration. Concurrent target
1343
+ creation shares that definition and serializes group operations. Names are scoped
1344
+ to the logical browser. Each window has its own native group for that name:
1345
+ tabs stay in their current window, including separate regular/incognito windows.
1346
+ Groups are created by the library, not adopted from unrelated groups that happen
1347
+ to have the same visible title. Incognito still requires the bridge's user grant.
1348
+
1349
+ Grouping happens before optional CDP setup, including with `initialize: false`.
1350
+ It also works with `targetId` selecting an existing tab. As with other target
1351
+ options, passing a new group on a target label that was already configured does
1352
+ not reconfigure that label. Use another label with its physical `targetId`, or
1353
+ edit the public record and reinitialize the target.
1354
+
1355
+ Reconnection reapplies the name/color and rejoins a surviving group. If its last
1356
+ tab was closed or ungrouped, Chrome removes the native group; later target setup
1357
+ creates a new one. `record.groups[name].window_groups[windowId]` stores the last
1358
+ known native group ID. These IDs are checked against Chrome during setup and can
1359
+ remain stale between operations; no extra event subscriptions or persistence are
1360
+ added. In reverse target info, `groupId` is the current native ID, or `-1` when
1361
+ ungrouped. Generic `chrome.tabs` / `chrome.tabGroups` calls remain available for
1362
+ immediate native updates. Native errors, including invalid colors, reject setup
1363
+ with the usual RPC error context and can be retried after correcting the record.
1364
+
1365
+ Direct CDP does not expose browser tab-group commands. A non-false `group` option
1366
+ therefore rejects before connecting or launching in direct mode. The bridge's
1367
+ existing `tabGroups` permission is sufficient; no extra permission is added.
1368
+ See [Chrome tab groups](https://developer.chrome.com/docs/extensions/reference/api/tabGroups)
1369
+ and [grouping tabs](https://developer.chrome.com/docs/extensions/reference/api/tabs#method-group).
1370
+
1371
+ ### Target initialization
1372
+
1286
1373
  Domain options set to false skip setup commands; they do not actively disable a
1287
1374
  domain already enabled by another caller. Likewise, `binding: false` does not
1288
1375
  remove a previously installed binding. Use native CDP commands for immediate
@@ -1544,8 +1631,8 @@ dictionary containers have a null prototype.
1544
1631
  | `client.reverse_server` | Shared listener configuration; the effective hostname/port are stored after startup. |
1545
1632
  | `client.custom_methods[name]` | Handler function. |
1546
1633
  | `client.browsers[name]` | Browser configuration fields plus live state. |
1547
- | `client.reverse_sockets[extension_id]` | Announced incoming socket, available even before the first call; native on Deno, an EventTarget adapter on Bun. |
1548
- | `record.extension_id`, `record.extension_path` | Expected extension identity and the managed copy's canonical directory. |
1634
+ | `client.reverse_sockets[browser_name]` | Announced socket (native extension ID for passive attachment); native on Deno, an EventTarget adapter on Bun. |
1635
+ | `record.extension_id`, `record.extension_path` | Observed native extension identity and, when installed by the manager, the copy's canonical directory. |
1549
1636
  | `client.server` | Current native Deno/Bun reverse server with shared lifecycle fields, or unset/null when not listening. |
1550
1637
  | `client.server_closing` | Shutdown promise while the reverse listener is stopping, otherwise unset/null. |
1551
1638
  | `record.socket` | Current `jsrpc` connection once created. |
@@ -1554,6 +1641,7 @@ dictionary containers have a null prototype.
1554
1641
  | `record.targets[label]` | Target options, physical `targetId`, `sessionId`, `connecting` and `setup_errors` after setup. |
1555
1642
  | `record.contexts[name]` | Recorded context options, native `browserContextId` or null, and pending `connecting`/`closing` promises. Direct mode only. |
1556
1643
  | `record.context_checking` | Pending automatic empty-context cleanup, otherwise unset/null. |
1644
+ | `record.groups[name]` | Recorded tab-group options, per-window native IDs in `window_groups`, and a pending `connecting` operation. Reverse only. |
1557
1645
  | `record.target_info[targetId]` | Discovered native target info plus cached `sessionId` when attached. |
1558
1646
  | `record.session_targets[sessionId]` | Physical `targetId` for a session. |
1559
1647
  | `socket.pending[id]` | Response-wait promise, removed on completion. |
@@ -1602,7 +1690,10 @@ try {
1602
1690
  `browser.launch` builds arguments, starts the process, reads the
1603
1691
  DevTools WebSocket URL from stderr and returns `{ proc, url }`. With `cdp_ext: true`,
1604
1692
  it waits for process spawn instead and returns `url: undefined`; the caller must
1605
- prepare the extension and supply its directory as `extension_path`.
1693
+ prepare the extension and supply its directory as `extension_path` when loading
1694
+ it. With `cdp_ext_install: false`, the helper skips the automation preset, uses
1695
+ the same option/argument/wildcard handling, and returns an unreferenced launcher.
1696
+ `bootstrap_url` is the optional startup URL, defaulting to `about:blank`.
1606
1697
  It does not open a CDP connection or write preference files; `update_preferences`, `preferences`
1607
1698
  and `local_state` are manager options, ignored by this helper.
1608
1699
  To prepare files independently, call
@@ -1641,6 +1732,7 @@ labels, target labels, custom methods or reconnection policy in this layer.
1641
1732
  | Failed discovery or local launch | Reject the call establishing the connection. |
1642
1733
  | Named context in reverse mode | Reject explicitly; no separate process emulation. |
1643
1734
  | Reverse incognito without the extension's user grant | Reject with an instruction to enable Allow in incognito. |
1735
+ | Tab group in direct mode | Reject before connecting; tab groups require the reverse extension APIs. |
1644
1736
  | Automatic context cleanup fails | Emit the manager's `error` event with browser label and original error. |
1645
1737
 
1646
1738
  ```js
@@ -1688,6 +1780,8 @@ The shared Deno/Bun reverse suite loads the real extension into two headless Chr
1688
1780
  server port without remote-debugging arguments. It checks native/custom calls,
1689
1781
  Network events, webRequest notifications without debugger attachment, bindings,
1690
1782
  child sessions, reconnect, detach/recreate, incognito and isolation.
1783
+ Tab-group checks cover concurrent creation, existing tabs, title/color, per-window
1784
+ incognito separation, reconnection, recreation and native validation/retry.
1691
1785
  Node coverage also checks actual browser/context proxy routing, shared private
1692
1786
  storage, concurrent context creation, automatic disposal, unselected targets and
1693
1787
  context ownership in externally attached browsers.
@@ -1706,5 +1800,10 @@ Managed-launch coverage checks concurrent first use, profile-specific identity,
1706
1800
  connection timeout without process replacement, lazy listener startup/shutdown
1707
1801
  independent of direct browsers, and relaunch after confirmed exit. External reverse
1708
1802
  timeouts release the listening port; other live reverse connections retain it.
1803
+ URL bootstrap also uses disposable normal Chrome profiles to check reuse of an
1804
+ already running instance, startup when absent, one shared extension directory,
1805
+ logical names, binding tokens, conflicting names and preservation on manager close.
1806
+ Installation defaults, raw directory overrides, profile writes independent of
1807
+ installation and shared wildcard expansion are covered without opening personal profiles.
1709
1808
  The npm tarball contains `package.json`, `cdp.js`, `README.md` and the two extension
1710
1809
  files required by managed reverse launches.
package/cdp.js CHANGED
@@ -125,7 +125,10 @@ export class browser {
125
125
  }
126
126
 
127
127
  static build_args(options = {}) {
128
- const args = {
128
+ const args = options.cdp_ext && options.cdp_ext_install === false ? {
129
+ 'user-data-dir': options.user_data_dir,
130
+ 'disable-features': [],
131
+ } : {
129
132
  'enable-automation': true,
130
133
  'mute-audio': true,
131
134
  'disable-blink-features': 'AutomationControlled',
@@ -264,10 +267,17 @@ export class browser {
264
267
  directory === '*' ? installed : directory,
265
268
  ))]
266
269
  }
267
- const proc = child_process.spawn(options.executable_path || browser.find_executable_path(), [...util.args_to_strings(args), 'about:blank'], {
270
+ const external = options.cdp_ext && options.cdp_ext_install === false
271
+ const proc = child_process.spawn(options.executable_path || browser.find_executable_path(), [...util.args_to_strings(args), options.bootstrap_url ?? 'about:blank'], {
268
272
  windowsHide: options.windowsHide ?? (args.headless != null && args.headless !== false),
269
- stdio: ['ignore', 'ignore', 'pipe'],
273
+ detached: external,
274
+ stdio: external ? 'ignore' : ['ignore', 'ignore', 'pipe'],
270
275
  })
276
+ if (external) {
277
+ await once(proc, 'spawn')
278
+ proc.unref()
279
+ return { proc }
280
+ }
271
281
  let diagnostic = ''
272
282
  const ready = util.on_first(proc, options.cdp_ext ? 'spawn exit' : 'ready exit', options.connect_timeout_ms ?? 15000)
273
283
  const data = chunk => {
@@ -359,11 +369,23 @@ export class cdp extends EventTarget {
359
369
  if (this.server) throw new Error('Reverse server is already listening')
360
370
  const accept = (socket, origin) => {
361
371
  socket.addEventListener('message', event => {
362
- const id = JSON.parse(event.data).browser
363
- if (id !== origin.slice('chrome-extension://'.length) ||
372
+ const message = JSON.parse(event.data)
373
+ const id = message.browser
374
+ const extension_id = message.extension_id ?? id
375
+ const record = this.browsers[id]
376
+ if (typeof id !== 'string' || extension_id !== origin.slice('chrome-extension://'.length) ||
377
+ (record?.reverse_token && message.token !== record.reverse_token) ||
378
+ (id !== extension_id && !record?.reverse_token) ||
364
379
  this.reverse_sockets[id]?.readyState < WebSocket.CLOSING) {
365
- return socket.close(1008, 'Invalid or already connected extension ID')
380
+ return socket.close(1008, 'Invalid or already connected reverse browser')
381
+ }
382
+ if (message.error) {
383
+ const error = new Error(message.error)
384
+ if (record) record.reverse_error = error
385
+ util.emit(this, 'reverse_' + encodeURIComponent(id), error)
386
+ return socket.close(1008, 'Chrome profile already connected')
366
387
  }
388
+ if (record) record.extension_id = extension_id
367
389
  this.reverse_sockets[id] = socket
368
390
  socket.addEventListener('close', () => {
369
391
  if (this.reverse_sockets[id] === socket) delete this.reverse_sockets[id]
@@ -373,6 +395,9 @@ export class cdp extends EventTarget {
373
395
  }, { once: true })
374
396
  }
375
397
  const fetch = (request, server) => {
398
+ if (request.method === 'GET' && new URL(request.url).pathname === '/cdp_ext') {
399
+ return new Response('CDP.js browser connection')
400
+ }
376
401
  const origin = request.headers.get('origin')
377
402
  if (request.headers.get('upgrade') !== 'websocket' ||
378
403
  !origin?.startsWith('chrome-extension://')) {
@@ -594,6 +619,21 @@ export class cdp extends EventTarget {
594
619
  if (record.reverse || record.cdp_ext) {
595
620
  await this.server_closing
596
621
  if (!this.server) this.listen_reverse()
622
+ let host = this.server.addr.hostname
623
+ if (host === '0.0.0.0') host = '127.0.0.1'
624
+ if (host === '::') host = '::1'
625
+ const address = (host.includes(':') ? '[' + host + ']' : host) + ':' + this.server.addr.port
626
+ record.reverse_url = 'ws://' + address
627
+ if (record.cdp_ext) {
628
+ if (!['127.0.0.1', 'localhost', '::1'].includes(host)) throw new Error('URL bootstrap requires a loopback reverse listener')
629
+ record.reverse_token ??= crypto.randomUUID()
630
+ const bootstrap = new URL('http://' + address + '/cdp_ext')
631
+ bootstrap.searchParams.set('browser', record.name)
632
+ bootstrap.searchParams.set('websocket_url', record.reverse_url)
633
+ bootstrap.searchParams.set('token', record.reverse_token)
634
+ record.bootstrap_url = bootstrap.href
635
+ }
636
+ delete record.reverse_error
597
637
  }
598
638
  let url = record.websocket_url
599
639
  if (!record.reverse && !record.cdp_ext && !url && (record.http_url || record.port)) {
@@ -604,10 +644,12 @@ export class cdp extends EventTarget {
604
644
  url = (await response.json()).webSocketDebuggerUrl
605
645
  }
606
646
  if (record.cdp_ext || (!record.reverse && !url)) {
607
- if (record.proc?.exitCode === null && record.proc.signalCode === null) url = record.socket?.url
647
+ const args = browser.build_args(record)
648
+ if (record.cdp_ext) record.cdp_ext_install ??= !!args['user-data-dir']
649
+ if ((record.proc?.exitCode === null && record.proc.signalCode === null) ||
650
+ (record.opener?.exitCode === null && record.opener.signalCode === null)) url = record.socket?.url
608
651
  else {
609
- const args = browser.build_args(record)
610
- if (record.cdp_ext) {
652
+ if (record.cdp_ext && record.cdp_ext_install) {
611
653
  record.extension_path = path.resolve(
612
654
  args['user-data-dir'] || path.join(this.base_path, record.name),
613
655
  args['profile-directory'] || 'Default',
@@ -617,10 +659,7 @@ export class cdp extends EventTarget {
617
659
  record.extension_path = await fs.realpath(record.extension_path)
618
660
  record.extension_id = browser.extension_id(record.extension_path)
619
661
  const manifest = JSON.parse(await fs.readFile(new URL('./cdp_ext/manifest.json', import.meta.url), 'utf8'))
620
- let host = this.server.addr.hostname
621
- if (host === '0.0.0.0') host = '127.0.0.1'
622
- if (host === '::') host = '::1'
623
- manifest.cdp.websocket_url = 'ws://' + (host.includes(':') ? '[' + host + ']' : host) + ':' + this.server.addr.port
662
+ manifest.cdp.websocket_url = record.reverse_url
624
663
  await fs.writeFile(path.join(record.extension_path, 'manifest.json'), JSON.stringify(manifest))
625
664
  await fs.copyFile(new URL('./cdp_ext/cdp_ext.js', import.meta.url), path.join(record.extension_path, 'cdp_ext.js'))
626
665
  }
@@ -635,17 +674,28 @@ export class cdp extends EventTarget {
635
674
  await browser.update_profile_preferences(path.join(args['user-data-dir'], 'Local State'), record.local_state)
636
675
  }
637
676
  }
638
- const launched = await browser.launch(record)
639
- record.proc = launched.proc
640
- if (record.cdp_ext) record.proc.once('exit', () => this._close_reverse_if_idle())
641
- url = launched.url
677
+ if (!record.cdp_ext || record.cdp_ext_install) {
678
+ const launched = await browser.launch(record)
679
+ record.proc = launched.proc
680
+ if (record.cdp_ext) record.proc.once('exit', () => this._close_reverse_if_idle())
681
+ url = launched.url
682
+ }
642
683
  }
643
684
  }
644
685
  if (record.reverse || record.cdp_ext) {
645
- const id = record.extension_id ?? record.name
686
+ const id = record.cdp_ext ? record.name : record.extension_id ?? record.name
646
687
  url = this.reverse_sockets[id]
647
688
  if (url?.readyState !== WebSocket.OPEN) {
648
- url = (await util.on_first(this, 'reverse_' + encodeURIComponent(id), record.connect_timeout_ms ?? 15000)).args[0].detail
689
+ if (record.cdp_ext && !record.cdp_ext_install &&
690
+ !(record.opener?.exitCode === null && record.opener.signalCode === null)) {
691
+ record.opener = (await browser.launch(record)).proc
692
+ }
693
+ if (record.reverse_error) throw record.reverse_error
694
+ url = this.reverse_sockets[id]
695
+ if (url?.readyState !== WebSocket.OPEN) {
696
+ url = (await util.on_first(this, 'reverse_' + encodeURIComponent(id), record.connect_timeout_ms ?? 15000)).args[0].detail
697
+ }
698
+ if (url instanceof Error) throw url
649
699
  }
650
700
  }
651
701
  const socket = record.socket = new jsrpc(url, record)
@@ -684,9 +734,58 @@ export class cdp extends EventTarget {
684
734
  }
685
735
  }
686
736
 
737
+ async _group(browser, target) {
738
+ const socket = browser.socket
739
+ const group = browser.groups[target.group]
740
+ while (group.connecting) await group.connecting
741
+ return group.connecting = (async () => {
742
+ const tab = await socket.req({
743
+ method: 'chrome.tabs.get',
744
+ params: { args: [Number(target.targetId)] },
745
+ })
746
+ const groups = await socket.req({
747
+ method: 'chrome.tabGroups.query',
748
+ params: { args: [{ windowId: tab.windowId }] },
749
+ })
750
+ const groupId = group.window_groups[tab.windowId]
751
+ const existing = groups.some(value => value.id === groupId)
752
+ const id = await socket.req({
753
+ method: 'chrome.tabs.group',
754
+ params: {
755
+ args: [{
756
+ tabIds: tab.id,
757
+ groupId: existing ? groupId : undefined,
758
+ createProperties: existing ? undefined : { windowId: tab.windowId },
759
+ }],
760
+ },
761
+ })
762
+ group.window_groups[tab.windowId] = id
763
+ await socket.req({
764
+ method: 'chrome.tabGroups.update',
765
+ params: {
766
+ args: [id, {
767
+ title: group.name,
768
+ color: group.color,
769
+ }],
770
+ },
771
+ })
772
+ return id
773
+ })().finally(() => {
774
+ group.connecting = null
775
+ })
776
+ }
777
+
687
778
  async _target(browser, target) {
688
779
  const socket = browser.socket
689
780
  const reverse = browser.reverse || browser.cdp_ext
781
+ if (target.group != null && target.group !== false) {
782
+ const spec = typeof target.group === 'string' ? { name: target.group } : target.group
783
+ browser.groups[spec.name] ??= {
784
+ ...spec,
785
+ window_groups: Object.create(null),
786
+ }
787
+ target.group = spec.name
788
+ }
690
789
  if (target.incognito && !reverse && target.context == null && target.create_params?.browserContextId == null) {
691
790
  target.context = 'incognito'
692
791
  }
@@ -720,6 +819,7 @@ export class cdp extends EventTarget {
720
819
  Object.assign(info, targetInfo)
721
820
  if (!info.incognito) throw new Error('Existing target is not incognito')
722
821
  }
822
+ if (target.group != null && target.group !== false) info.groupId = await this._group(browser, target)
723
823
  if (!info.sessionId) {
724
824
  info.sessionId = (await socket.req({
725
825
  method: 'Target.attachToTarget',
@@ -779,7 +879,7 @@ export class cdp extends EventTarget {
779
879
  const { browser = 'main', target, ...native } = params
780
880
  const spec = typeof browser === 'string' ? { name: browser } : browser
781
881
  const record = this.browsers[spec.name] ??= {
782
- user_data_dir: path.join(this.base_path, spec.name),
882
+ user_data_dir: ((spec.reverse ?? this.options.reverse) || (spec.cdp_ext ?? this.options.cdp_ext)) ? null : path.join(this.base_path, spec.name),
783
883
  ...this.options,
784
884
  ...spec,
785
885
  args: {
@@ -796,10 +896,14 @@ export class cdp extends EventTarget {
796
896
  },
797
897
  targets: Object.create(null),
798
898
  contexts: Object.create(null),
899
+ groups: Object.create(null),
799
900
  }
800
901
  if (target?.context != null && (record.reverse || record.cdp_ext)) {
801
902
  throw new Error('Named browser contexts are unavailable through the reverse Chrome extension')
802
903
  }
904
+ if (target?.group != null && target.group !== false && !record.reverse && !record.cdp_ext) {
905
+ throw new Error('Tab groups require the reverse Chrome extension (cdp_ext or reverse)')
906
+ }
803
907
  if (!record.connecting && record.socket?.readyState !== WebSocket.OPEN) {
804
908
  record.connecting = this._connect(record).catch(error => {
805
909
  record.socket?.close()
@@ -1,4 +1,5 @@
1
1
  const config = chrome.runtime.getManifest().cdp ?? {}
2
+ const configured = chrome.storage.session.get('cdp_bootstrap').then(value => Object.assign(config, value.cdp_bootstrap))
2
3
  const sessions = {}
3
4
  const attached = {}
4
5
  const attaching = {}
@@ -6,6 +7,50 @@ let socket
6
7
  let disconnecting
7
8
  let discovering = false
8
9
  let auto_attach = {}
10
+ let bootstrapping
11
+
12
+ async function bootstrap(tab) {
13
+ const text = tab.pendingUrl ?? tab.url ?? ''
14
+ if (!/^http:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?\/cdp_ext\?/.test(text)) return
15
+ const url = new URL(text)
16
+ const settings = {
17
+ browser: url.searchParams.get('browser'),
18
+ websocket_url: url.searchParams.get('websocket_url'),
19
+ token: url.searchParams.get('token'),
20
+ }
21
+ if (!settings.browser || !settings.websocket_url || !settings.token) return
22
+ const endpoint = new URL(settings.websocket_url)
23
+ if (endpoint.protocol !== 'ws:' || endpoint.host !== url.host) return
24
+ await configured
25
+ while (bootstrapping) await bootstrapping
26
+ return bootstrapping = (async () => {
27
+ const changed = ['browser', 'websocket_url', 'token'].some(key => settings[key] !== config[key])
28
+ if (changed && config.browser && socket?.readyState < WebSocket.CLOSING) {
29
+ const probe = new WebSocket(settings.websocket_url)
30
+ probe.onopen = () => probe.send(JSON.stringify({
31
+ browser: settings.browser,
32
+ extension_id: chrome.runtime.id,
33
+ token: settings.token,
34
+ error: 'This Chrome profile is already connected as ' + config.browser,
35
+ }))
36
+ return
37
+ }
38
+ if (changed) {
39
+ Object.assign(config, settings)
40
+ await chrome.storage.session.set({ cdp_bootstrap: settings })
41
+ if (socket && socket.readyState !== WebSocket.CLOSED) {
42
+ await new Promise(resolve => {
43
+ socket.addEventListener('close', resolve, { once: true })
44
+ socket.close()
45
+ })
46
+ }
47
+ await disconnecting
48
+ }
49
+ await connect()
50
+ })().finally(() => {
51
+ bootstrapping = null
52
+ })
53
+ }
9
54
 
10
55
  function send(message) {
11
56
  if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message))
@@ -19,6 +64,7 @@ function target_info(tab) {
19
64
  url: tab.url ?? tab.pendingUrl ?? '',
20
65
  attached: !!attached[tab.id],
21
66
  incognito: tab.incognito,
67
+ groupId: tab.groupId,
22
68
  }
23
69
  }
24
70
 
@@ -159,12 +205,14 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
159
205
  })
160
206
  chrome.debugger.onDetach.addListener(detached)
161
207
  chrome.tabs.onCreated.addListener(tab => {
208
+ bootstrap(tab)
162
209
  if (discovering) send({
163
210
  method: 'Target.targetCreated',
164
211
  params: { targetInfo: target_info(tab) },
165
212
  })
166
213
  })
167
214
  chrome.tabs.onUpdated.addListener((id, changes, tab) => {
215
+ bootstrap(tab)
168
216
  if (discovering) send({
169
217
  method: 'Target.targetInfoChanged',
170
218
  params: { targetInfo: target_info(tab) },
@@ -195,12 +243,17 @@ for (const [name, options] of Object.entries({
195
243
  }), { urls: ['<all_urls>'] }, options)
196
244
  }
197
245
 
198
- function connect() {
246
+ async function connect() {
247
+ await configured
199
248
  if (disconnecting) return
200
249
  if (socket?.readyState === WebSocket.CONNECTING) return
201
250
  if (socket?.readyState === WebSocket.OPEN) return socket.send('')
202
251
  const connection = socket = new WebSocket(config.websocket_url ?? 'ws://127.0.0.1:9223')
203
- connection.onopen = () => connection.send(JSON.stringify({ browser: chrome.runtime.id }))
252
+ connection.onopen = () => connection.send(JSON.stringify({
253
+ browser: config.browser ?? chrome.runtime.id,
254
+ extension_id: chrome.runtime.id,
255
+ token: config.token,
256
+ }))
204
257
  connection.onmessage = async event => {
205
258
  if (!event.data) return
206
259
  const request = JSON.parse(event.data)
@@ -256,8 +309,19 @@ function connect() {
256
309
  }
257
310
  }
258
311
 
259
- chrome.runtime.onStartup.addListener(connect)
312
+ async function start() {
313
+ await configured
314
+ if (!config.browser) {
315
+ for (const tab of (await chrome.tabs.query({})).reverse()) {
316
+ if (config.browser) break
317
+ await bootstrap(tab)
318
+ }
319
+ }
320
+ await connect()
321
+ }
322
+
323
+ chrome.runtime.onStartup.addListener(start)
260
324
  chrome.alarms.onAlarm.addListener(connect)
261
325
  chrome.alarms.create('cdp-reconnect', { periodInMinutes: 0.5 })
262
326
  setInterval(connect, config.reconnect_ms ?? 5000)
263
- connect()
327
+ start()
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "CDP reverse connection",
4
- "version": "0.2.0",
4
+ "version": "0.5.0",
5
5
  "minimum_chrome_version": "125",
6
6
  "permissions": [
7
7
  "accessibilityFeatures.read",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mefistofelix/cdp.js",
3
- "version": "0.3.0",
3
+ "version": "0.5.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",