@mefistofelix/cdp.js 0.4.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
 
@@ -189,7 +189,7 @@ belong in `params.target` and do not inherit from the constructor.
189
189
 
190
190
  For a new browser record, configuration is assembled in this order:
191
191
 
192
- 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.
193
193
  2. Constructor browser options.
194
194
  3. The `params.browser` configuration object.
195
195
 
@@ -225,7 +225,7 @@ local configuration.
225
225
  | `base_path` | string, `'.cdp'` | Constructor-only profile root, resolved against the working directory. |
226
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`. |
227
227
  | `name` | string, `'main'` when routing is omitted | Required in a browser configuration object; dictionary key and default profile directory label. |
228
- | `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. |
229
229
  | `executable_path` | string, automatic discovery | Executable to spawn; takes precedence over `CDP_BROWSER`. |
230
230
  | `headless` | boolean, `false` | When true, add `headless: 'new'` and default `window-size: '1440,900'`. |
231
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. |
@@ -242,9 +242,10 @@ local configuration.
242
242
  | `request_timeout_ms` | number, `30000` | Default timeout for each protocol request on this browser's socket. |
243
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. |
244
244
  | `websocket_url` | string, unset | Attach directly to a browser WebSocket endpoint. |
245
- | `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`. |
246
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. |
247
- | `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. |
248
249
  | `http_url` | string, unset | Discover the endpoint from an HTTP(S) base URL or exact `/json/version` URL. |
249
250
  | `port` | number, unset | Discover an existing browser at `http://<host>:<port>/json/version`. |
250
251
  | `host` | string, `'127.0.0.1'` | Host used with `port`. |
@@ -492,8 +493,8 @@ can configure its native incognito scope. See
492
493
  [cdp_ext/manifest.json](cdp_ext/manifest.json) and
493
494
  [cdp_ext/cdp_ext.js](cdp_ext/cdp_ext.js) form a Manifest V3 extension for Chrome
494
495
  125+. The extension connects out to one WebSocket server. The server serves all
495
- browser instances on the same port; each extension announces its native ID
496
- 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.
497
498
 
498
499
  Run the server under Deno using its native
499
500
  [WebSocket upgrade](https://docs.deno.com/api/deno/websockets/) or under Bun using
@@ -513,6 +514,7 @@ const result = await client.call({
513
514
  browser: {
514
515
  name: 'work',
515
516
  cdp_ext: true,
517
+ user_data_dir: '/absolute/path/to/work-profile',
516
518
  headless: true,
517
519
  },
518
520
  target: 'demo',
@@ -521,65 +523,84 @@ const result = await client.call({
521
523
  })
522
524
  ```
523
525
 
524
- The manager copies the two extension files into `cdp_ext/` inside the effective
525
- profile directory, sets the shared server URL in that copy's manifest and loads
526
- it before browser startup. Without an effective user-data directory, the copy
527
- lives under `base_path/<name>/<profile-directory>/` instead (`Default` when unset);
528
- Chromium still uses its system profile. Profile preferences follow the existing
529
- `update_preferences` rules.
530
- The generated launch arguments omit remote debugging and enable this extension.
531
- An `extensions` array also selects those directories; `extensions: true` keeps
532
- normally enabled profile extensions through `'*'`. Raw `args` still win and can
533
- prevent the bridge from loading or connecting, causing a connection timeout.
534
-
535
- For external attachment, load unpacked `cdp_ext/` through `chrome://extensions`
536
- or the [local-extension argument options](#local-extensions-at-startup).
537
- Use its ID shown in Chrome, or `browser.extension_id(directory)`, as the
538
- browser option `extension_id`. The logical `name` stays in the manager.
539
-
540
- The extension configuration is read from `chrome.runtime.getManifest()`:
541
-
542
- | Manifest `cdp` key | Default | Meaning |
543
- | --- | --- | --- |
544
- | `websocket_url` | `ws://127.0.0.1:9223` | The shared server endpoint. Port 9223 avoids the usual direct CDP port 9222. |
545
- | `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. |
546
-
547
- The first message uses `chrome.runtime.id`, not a configured logical browser name.
548
- For an unpacked extension without a manifest `key`, Chromium derives this ID from
549
- its directory. Managed launches use a separate directory for each profile, so
550
- the ID is stable across reconnects and restarts, and the manager knows which
551
- browser it belongs to. External instances must likewise use distinct extension
552
- directories. Moving the directory changes the ID. The server rejects duplicate
553
- live IDs and announcements that differ from the WebSocket's `chrome-extension://`
554
- 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.
555
531
 
556
532
  ```js
557
- import { browser, cdp } from './cdp.js'
558
-
559
533
  const client = new cdp({
560
- reverse: true,
561
- connect_timeout_ms: 60000,
562
- reverse_server: {
563
- hostname: '127.0.0.1',
564
- port: 9223,
565
- },
534
+ cdp_ext: true,
566
535
  })
567
- const result = await client.call({
568
- method: 'Runtime.evaluate',
569
- params: {
570
- browser: {
571
- name: 'other',
572
- reverse: true,
573
- extension_id: browser.extension_id('/absolute/path/to/cdp_ext'),
574
- },
575
- target: 'demo',
576
- expression: 'document.title',
577
- },
536
+ await client.call({
537
+ method: 'Browser.getVersion',
538
+ params: { browser: 'work' },
578
539
  })
579
- console.log(result.result.value)
580
- await client.close()
581
540
  ```
582
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
+
583
604
  Construction and direct CDP calls do not start a listener. The first browser call
584
605
  using `reverse: true` or `cdp_ext: true` starts the shared server lazily, with the
585
606
  constructor's `reverse_server` settings. To mix direct and reverse browsers, leave the
@@ -616,7 +637,8 @@ Native Bun WebSocket limits still apply.
616
637
  Reverse calls reuse the normal target setup, custom handlers, mappings, routed
617
638
  events, request correlation, timeout and error behavior. `reverse: true` alone
618
639
  attaches externally: the manager neither launches nor updates that browser.
619
- `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.
620
642
  Closing the manager closes owned processes and the server, while external
621
643
  browsers and tabs remain open. The extension retries when the server returns;
622
644
  the next call rebuilds discovery and sessions, preserving surviving target IDs.
@@ -628,20 +650,11 @@ for that extension to reconnect; they do not spawn another process. Only the
628
650
  child process's confirmed exit permits a new launch. Explicit manager `close()`
629
651
  still terminates owned browsers.
630
652
 
631
- The current reverse registration uses a profile-specific extension copy. It does
632
- not assign a new logical name to an already running Chrome using a shared extension
633
- directory. Unpacked `chrome.runtime.id` identifies the extension path, not the
634
- user-data directory or browser executable. Shared-path registration and URL-based
635
- bootstrap are tracked in [TODO.md](TODO.md), not implemented behavior.
636
-
637
- Chrome's [process singleton](https://github.com/chromium/chromium/blob/main/chrome/browser/process_singleton.h)
638
- uses the user-data directory to decide whether to forward a new launch to an
639
- existing instance. `no-default-browser-check` only suppresses the default-browser
640
- prompt. The current Chromium [notification callback](https://github.com/chromium/chromium/blob/main/chrome/browser/chrome_browser_main.cc)
641
- rejects forwarding when either command line enables automation or uses headless
642
- mode. This library generates `enable-automation` by default; a normal browser
643
- started with `args['enable-automation']: false` can behave differently. Do not
644
- 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.
645
658
 
646
659
  The [service-worker lifecycle](https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle)
647
660
  allows termination while offline. A 30-second Chrome alarm wakes the worker for
@@ -651,8 +664,8 @@ These are transport messages, not event buffering or polling of browser state.
651
664
 
652
665
  ### Reverse protocol and supported commands
653
666
 
654
- The first extension-to-server message is `{ "browser": "<extension ID>" }`. Subsequent
655
- 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:
656
669
 
657
670
  - Request: `{ id, method, params, sessionId? }`.
658
671
  - Response: `{ id, result }` or `{ id, error: { code, message } }`.
@@ -1618,8 +1631,8 @@ dictionary containers have a null prototype.
1618
1631
  | `client.reverse_server` | Shared listener configuration; the effective hostname/port are stored after startup. |
1619
1632
  | `client.custom_methods[name]` | Handler function. |
1620
1633
  | `client.browsers[name]` | Browser configuration fields plus live state. |
1621
- | `client.reverse_sockets[extension_id]` | Announced incoming socket, available even before the first call; native on Deno, an EventTarget adapter on Bun. |
1622
- | `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. |
1623
1636
  | `client.server` | Current native Deno/Bun reverse server with shared lifecycle fields, or unset/null when not listening. |
1624
1637
  | `client.server_closing` | Shutdown promise while the reverse listener is stopping, otherwise unset/null. |
1625
1638
  | `record.socket` | Current `jsrpc` connection once created. |
@@ -1677,7 +1690,10 @@ try {
1677
1690
  `browser.launch` builds arguments, starts the process, reads the
1678
1691
  DevTools WebSocket URL from stderr and returns `{ proc, url }`. With `cdp_ext: true`,
1679
1692
  it waits for process spawn instead and returns `url: undefined`; the caller must
1680
- 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`.
1681
1697
  It does not open a CDP connection or write preference files; `update_preferences`, `preferences`
1682
1698
  and `local_state` are manager options, ignored by this helper.
1683
1699
  To prepare files independently, call
@@ -1784,5 +1800,10 @@ Managed-launch coverage checks concurrent first use, profile-specific identity,
1784
1800
  connection timeout without process replacement, lazy listener startup/shutdown
1785
1801
  independent of direct browsers, and relaunch after confirmed exit. External reverse
1786
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.
1787
1808
  The npm tarball contains `package.json`, `cdp.js`, `README.md` and the two extension
1788
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')
366
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')
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)
@@ -829,7 +879,7 @@ export class cdp extends EventTarget {
829
879
  const { browser = 'main', target, ...native } = params
830
880
  const spec = typeof browser === 'string' ? { name: browser } : browser
831
881
  const record = this.browsers[spec.name] ??= {
832
- 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),
833
883
  ...this.options,
834
884
  ...spec,
835
885
  args: {
@@ -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))
@@ -160,12 +205,14 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
160
205
  })
161
206
  chrome.debugger.onDetach.addListener(detached)
162
207
  chrome.tabs.onCreated.addListener(tab => {
208
+ bootstrap(tab)
163
209
  if (discovering) send({
164
210
  method: 'Target.targetCreated',
165
211
  params: { targetInfo: target_info(tab) },
166
212
  })
167
213
  })
168
214
  chrome.tabs.onUpdated.addListener((id, changes, tab) => {
215
+ bootstrap(tab)
169
216
  if (discovering) send({
170
217
  method: 'Target.targetInfoChanged',
171
218
  params: { targetInfo: target_info(tab) },
@@ -196,12 +243,17 @@ for (const [name, options] of Object.entries({
196
243
  }), { urls: ['<all_urls>'] }, options)
197
244
  }
198
245
 
199
- function connect() {
246
+ async function connect() {
247
+ await configured
200
248
  if (disconnecting) return
201
249
  if (socket?.readyState === WebSocket.CONNECTING) return
202
250
  if (socket?.readyState === WebSocket.OPEN) return socket.send('')
203
251
  const connection = socket = new WebSocket(config.websocket_url ?? 'ws://127.0.0.1:9223')
204
- 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
+ }))
205
257
  connection.onmessage = async event => {
206
258
  if (!event.data) return
207
259
  const request = JSON.parse(event.data)
@@ -257,8 +309,19 @@ function connect() {
257
309
  }
258
310
  }
259
311
 
260
- 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)
261
324
  chrome.alarms.onAlarm.addListener(connect)
262
325
  chrome.alarms.create('cdp-reconnect', { periodInMinutes: 0.5 })
263
326
  setInterval(connect, config.reconnect_ms ?? 5000)
264
- 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.4.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",