@mefistofelix/cdp.js 0.4.0 → 0.5.1

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,98 @@ 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) and adds
546
+ startup loading flags. The server address comes only from the trigger URL. 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
+ Both launch paths keep `--no-first-run` and
572
+ `--disable-search-engine-choice-screen`, including when opening a new custom
573
+ profile with installation disabled. This suppresses onboarding and the search
574
+ engine selection prompt without changing the chosen search provider or writing
575
+ search preferences. Raw `args` can override either flag. An existing Chrome only
576
+ receives the URL; new startup flags require restarting that instance.
577
+
578
+ The manager opens an actual local HTTP URL on the shared listener, for example
579
+ `http://127.0.0.1:9223/cdp_ext?browser=work&websocket_url=...&token=...`.
580
+ The port is the effective listening port, including an automatically allocated
581
+ port for `reverse_server.port: 0`. Name, WebSocket URL and generated binding token
582
+ are internal bootstrap parameters, not additional user options. The extension
583
+ observes startup tabs, tab creation and URL changes. Bootstrap accepts only
584
+ loopback HTTP URLs with a WebSocket endpoint on the same host/port. The bootstrap
585
+ tab remains open.
586
+
587
+ The first announcement contains `{ browser, extension_id, token }`. The browser
588
+ field is the logical label; `extension_id` is `chrome.runtime.id` and must match
589
+ the upgrade origin. The token must match the pending manager record. One extension
590
+ directory and native ID can therefore serve multiple profiles with distinct
591
+ logical labels. No logical name is written into a manifest. Bootstrap settings
592
+ use `chrome.storage.session` to survive worker restarts; they disappear on browser
593
+ restart or extension reload. Manager browser/target/session mappings remain in memory.
594
+
595
+ A profile has one active logical connection. A link requesting another name or
596
+ manager while connected returns an explicit error, preserving the existing
597
+ connection. After disconnection, a new bootstrap can rebind it.
598
+
599
+ Passive external attachment remains available through `reverse: true`: it opens
600
+ no URL and waits for `extension_id` (or the browser label). This compatibility
601
+ mode is optional; `cdp_ext: true` needs neither `reverse` nor an ID.
602
+ The current extension still requires a trigger URL in passive mode; it never
603
+ connects automatically to a manifest endpoint or a default port.
604
+
605
+ The extension stays idle until a valid trigger URL supplies the logical name,
606
+ WebSocket endpoint and token. Before that it makes no server requests and logs
607
+ no connection messages. A worker restart may resume the same browser session's
608
+ previous trigger from `chrome.storage.session`.
609
+
610
+ The shipped manifest needs no custom configuration keys. An optional custom
611
+ `cdp.reconnect_ms` override remains supported (Chrome displays a harmless
612
+ unrecognized-key warning if it is added):
613
+
614
+ | Manifest `cdp` key | Default | Meaning |
615
+ | --- | --- | --- |
616
+ | `reconnect_ms` | `5000` | Awake retry and connected heartbeat interval; use a positive value below 30000. |
617
+
583
618
  Construction and direct CDP calls do not start a listener. The first browser call
584
619
  using `reverse: true` or `cdp_ext: true` starts the shared server lazily, with the
585
620
  constructor's `reverse_server` settings. To mix direct and reverse browsers, leave the
@@ -616,7 +651,8 @@ Native Bun WebSocket limits still apply.
616
651
  Reverse calls reuse the normal target setup, custom handlers, mappings, routed
617
652
  events, request correlation, timeout and error behavior. `reverse: true` alone
618
653
  attaches externally: the manager neither launches nor updates that browser.
619
- `cdp_ext: true` owns the launched process and applies normal launch preferences.
654
+ `cdp_ext_install: true` owns the launched process and applies normal launch preferences.
655
+ With installation off, Chrome is external and remains open on manager close.
620
656
  Closing the manager closes owned processes and the server, while external
621
657
  browsers and tabs remain open. The extension retries when the server returns;
622
658
  the next call rebuilds discovery and sessions, preserving surviving target IDs.
@@ -628,20 +664,11 @@ for that extension to reconnect; they do not spawn another process. Only the
628
664
  child process's confirmed exit permits a new launch. Explicit manager `close()`
629
665
  still terminates owned browsers.
630
666
 
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.
667
+ Chrome selects an existing instance by user-data directory. The minimal link
668
+ opener preserves this normal behavior. Automation/headless flags can prevent
669
+ forwarding; `no-default-browser-check` only suppresses a prompt. The short-lived
670
+ launcher is `record.opener`; its exit is not proof of browser exit. Installed
671
+ launches use `record.proc` instead.
645
672
 
646
673
  The [service-worker lifecycle](https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle)
647
674
  allows termination while offline. A 30-second Chrome alarm wakes the worker for
@@ -649,10 +676,20 @@ another connection attempt; the configured interval applies while it is awake.
649
676
  Connected empty-text heartbeats keep an otherwise idle WebSocket worker active.
650
677
  These are transport messages, not event buffering or polling of browser state.
651
678
 
679
+ After a trigger, the server may be offline normally. Before each disconnected
680
+ WebSocket attempt the extension checks the same server's HTTP `/cdp_ext` endpoint
681
+ with a two-second timeout and no cache. It opens the socket only when that
682
+ endpoint identifies CDP.js. Concurrent retries share the check; an unavailable or
683
+ incompatible server causes one informational waiting message, not a failing
684
+ WebSocket attempt on every timer tick. Successful connections and later
685
+ disconnects log their status once per transition. Chrome can still emit a native
686
+ network diagnostic if the server disappears between the HTTP check and upgrade.
687
+
652
688
  ### Reverse protocol and supported commands
653
689
 
654
- The first extension-to-server message is `{ "browser": "<extension ID>" }`. Subsequent
655
- messages use existing CDP envelopes:
690
+ The current extension's first announcement contains `{ browser, extension_id, token }`
691
+ after a trigger. The server also accepts legacy native-ID announcements.
692
+ Subsequent messages use CDP envelopes:
656
693
 
657
694
  - Request: `{ id, method, params, sessionId? }`.
658
695
  - Response: `{ id, result }` or `{ id, error: { code, message } }`.
@@ -1618,8 +1655,8 @@ dictionary containers have a null prototype.
1618
1655
  | `client.reverse_server` | Shared listener configuration; the effective hostname/port are stored after startup. |
1619
1656
  | `client.custom_methods[name]` | Handler function. |
1620
1657
  | `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. |
1658
+ | `client.reverse_sockets[browser_name]` | Announced socket (native extension ID for passive attachment); native on Deno, an EventTarget adapter on Bun. |
1659
+ | `record.extension_id`, `record.extension_path` | Observed native extension identity and, when installed by the manager, the copy's canonical directory. |
1623
1660
  | `client.server` | Current native Deno/Bun reverse server with shared lifecycle fields, or unset/null when not listening. |
1624
1661
  | `client.server_closing` | Shutdown promise while the reverse listener is stopping, otherwise unset/null. |
1625
1662
  | `record.socket` | Current `jsrpc` connection once created. |
@@ -1677,7 +1714,10 @@ try {
1677
1714
  `browser.launch` builds arguments, starts the process, reads the
1678
1715
  DevTools WebSocket URL from stderr and returns `{ proc, url }`. With `cdp_ext: true`,
1679
1716
  it waits for process spawn instead and returns `url: undefined`; the caller must
1680
- prepare the extension and supply its directory as `extension_path`.
1717
+ prepare the extension and supply its directory as `extension_path` when loading
1718
+ it. With `cdp_ext_install: false`, the helper skips the automation preset, uses
1719
+ the same option/argument/wildcard handling, and returns an unreferenced launcher.
1720
+ `bootstrap_url` is the optional startup URL, defaulting to `about:blank`.
1681
1721
  It does not open a CDP connection or write preference files; `update_preferences`, `preferences`
1682
1722
  and `local_state` are manager options, ignored by this helper.
1683
1723
  To prepare files independently, call
@@ -1784,5 +1824,16 @@ Managed-launch coverage checks concurrent first use, profile-specific identity,
1784
1824
  connection timeout without process replacement, lazy listener startup/shutdown
1785
1825
  independent of direct browsers, and relaunch after confirmed exit. External reverse
1786
1826
  timeouts release the listening port; other live reverse connections retain it.
1827
+ URL bootstrap also uses disposable normal Chrome profiles to check reuse of an
1828
+ already running instance, startup when absent, one shared extension directory,
1829
+ logical names, binding tokens, conflicting names and preservation on manager close.
1830
+ Installation defaults, raw directory overrides, profile writes independent of
1831
+ installation and shared wildcard expansion are covered without opening personal profiles.
1832
+ Argument coverage crosses direct/extension launch modes with headless, proxy,
1833
+ image/login/translation options, extension selectors and raw overrides. Profile
1834
+ combinations include default/custom directories, null/false raw directory
1835
+ overrides, forced installation and independent preference-write controls.
1836
+ Offline worker checks verify silence before a trigger, deduplicated waiting logs,
1837
+ no WebSocket attempts against absent/incompatible servers and concurrent retry checks.
1787
1838
  The npm tarball contains `package.json`, `cdp.js`, `README.md` and the two extension
1788
1839
  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',
@@ -150,12 +153,10 @@ export class browser {
150
153
  'disable-prompt-on-repost': true,
151
154
  'force-color-profile': 'srgb',
152
155
  'metrics-recording-only': true,
153
- 'no-first-run': true,
154
156
  'password-store': 'basic',
155
157
  'use-mock-keychain': true,
156
158
  'no-service-autorun': true,
157
159
  'export-tagged-pdf': true,
158
- 'disable-search-engine-choice-screen': true,
159
160
  'disable-features': [
160
161
  'InfiniteSessionRestore',
161
162
  'ImprovedCookieControls',
@@ -174,6 +175,8 @@ export class browser {
174
175
  'user-data-dir': options.user_data_dir,
175
176
  'remote-debugging-port': 0,
176
177
  }
178
+ args['no-first-run'] = true
179
+ args['disable-search-engine-choice-screen'] = true
177
180
  if (options.headless) {
178
181
  args.headless = 'new'
179
182
  args['window-size'] = '1440,900'
@@ -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',
@@ -616,12 +658,7 @@ export class cdp extends EventTarget {
616
658
  await fs.mkdir(record.extension_path, { recursive: true })
617
659
  record.extension_path = await fs.realpath(record.extension_path)
618
660
  record.extension_id = browser.extension_id(record.extension_path)
619
- 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
624
- await fs.writeFile(path.join(record.extension_path, 'manifest.json'), JSON.stringify(manifest))
661
+ await fs.copyFile(new URL('./cdp_ext/manifest.json', import.meta.url), path.join(record.extension_path, 'manifest.json'))
625
662
  await fs.copyFile(new URL('./cdp_ext/cdp_ext.js', import.meta.url), path.join(record.extension_path, 'cdp_ext.js'))
626
663
  }
627
664
  if (args['user-data-dir'] && record.update_preferences !== false) {
@@ -635,17 +672,28 @@ export class cdp extends EventTarget {
635
672
  await browser.update_profile_preferences(path.join(args['user-data-dir'], 'Local State'), record.local_state)
636
673
  }
637
674
  }
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
675
+ if (!record.cdp_ext || record.cdp_ext_install) {
676
+ const launched = await browser.launch(record)
677
+ record.proc = launched.proc
678
+ if (record.cdp_ext) record.proc.once('exit', () => this._close_reverse_if_idle())
679
+ url = launched.url
680
+ }
642
681
  }
643
682
  }
644
683
  if (record.reverse || record.cdp_ext) {
645
- const id = record.extension_id ?? record.name
684
+ const id = record.cdp_ext ? record.name : record.extension_id ?? record.name
646
685
  url = this.reverse_sockets[id]
647
686
  if (url?.readyState !== WebSocket.OPEN) {
648
- url = (await util.on_first(this, 'reverse_' + encodeURIComponent(id), record.connect_timeout_ms ?? 15000)).args[0].detail
687
+ if (record.cdp_ext && !record.cdp_ext_install &&
688
+ !(record.opener?.exitCode === null && record.opener.signalCode === null)) {
689
+ record.opener = (await browser.launch(record)).proc
690
+ }
691
+ if (record.reverse_error) throw record.reverse_error
692
+ url = this.reverse_sockets[id]
693
+ if (url?.readyState !== WebSocket.OPEN) {
694
+ url = (await util.on_first(this, 'reverse_' + encodeURIComponent(id), record.connect_timeout_ms ?? 15000)).args[0].detail
695
+ }
696
+ if (url instanceof Error) throw url
649
697
  }
650
698
  }
651
699
  const socket = record.socket = new jsrpc(url, record)
@@ -829,7 +877,7 @@ export class cdp extends EventTarget {
829
877
  const { browser = 'main', target, ...native } = params
830
878
  const spec = typeof browser === 'string' ? { name: browser } : browser
831
879
  const record = this.browsers[spec.name] ??= {
832
- user_data_dir: path.join(this.base_path, spec.name),
880
+ user_data_dir: ((spec.reverse ?? this.options.reverse) || (spec.cdp_ext ?? this.options.cdp_ext)) ? null : path.join(this.base_path, spec.name),
833
881
  ...this.options,
834
882
  ...spec,
835
883
  args: {
@@ -1,4 +1,7 @@
1
- const config = chrome.runtime.getManifest().cdp ?? {}
1
+ const config = {
2
+ reconnect_ms: chrome.runtime.getManifest().cdp?.reconnect_ms ?? 5000,
3
+ }
4
+ const configured = chrome.storage.session.get('cdp_bootstrap').then(value => Object.assign(config, value.cdp_bootstrap))
2
5
  const sessions = {}
3
6
  const attached = {}
4
7
  const attaching = {}
@@ -6,6 +9,58 @@ let socket
6
9
  let disconnecting
7
10
  let discovering = false
8
11
  let auto_attach = {}
12
+ let bootstrapping
13
+ let checking = false
14
+ let connection_message
15
+
16
+ function report_connection(message) {
17
+ if (connection_message === message) return
18
+ connection_message = message
19
+ console.info('[cdp_ext] ' + message)
20
+ }
21
+
22
+ async function bootstrap(tab) {
23
+ const text = tab.pendingUrl ?? tab.url ?? ''
24
+ if (!/^http:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?\/cdp_ext\?/.test(text)) return
25
+ const url = new URL(text)
26
+ const settings = {
27
+ browser: url.searchParams.get('browser'),
28
+ websocket_url: url.searchParams.get('websocket_url'),
29
+ token: url.searchParams.get('token'),
30
+ }
31
+ if (!settings.browser || !settings.websocket_url || !settings.token) return
32
+ const endpoint = new URL(settings.websocket_url)
33
+ if (endpoint.protocol !== 'ws:' || endpoint.host !== url.host) return
34
+ await configured
35
+ while (bootstrapping) await bootstrapping
36
+ 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
+ })
56
+ }
57
+ await disconnecting
58
+ }
59
+ await connect()
60
+ })().finally(() => {
61
+ bootstrapping = null
62
+ })
63
+ }
9
64
 
10
65
  function send(message) {
11
66
  if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message))
@@ -160,12 +215,14 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
160
215
  })
161
216
  chrome.debugger.onDetach.addListener(detached)
162
217
  chrome.tabs.onCreated.addListener(tab => {
218
+ bootstrap(tab)
163
219
  if (discovering) send({
164
220
  method: 'Target.targetCreated',
165
221
  params: { targetInfo: target_info(tab) },
166
222
  })
167
223
  })
168
224
  chrome.tabs.onUpdated.addListener((id, changes, tab) => {
225
+ bootstrap(tab)
169
226
  if (discovering) send({
170
227
  method: 'Target.targetInfoChanged',
171
228
  params: { targetInfo: target_info(tab) },
@@ -196,12 +253,45 @@ for (const [name, options] of Object.entries({
196
253
  }), { urls: ['<all_urls>'] }, options)
197
254
  }
198
255
 
199
- function connect() {
200
- if (disconnecting) return
256
+ async function connect() {
257
+ await configured
258
+ if (!config.browser || !config.websocket_url || !config.token) return
259
+ if (disconnecting || checking) return
201
260
  if (socket?.readyState === WebSocket.CONNECTING) return
202
261
  if (socket?.readyState === WebSocket.OPEN) return socket.send('')
203
- 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 }))
262
+ const endpoint = config.websocket_url
263
+ const url = new URL(endpoint)
264
+ url.protocol = url.protocol === 'wss:' ? 'https:' : 'http:'
265
+ url.pathname = '/cdp_ext'
266
+ url.search = ''
267
+ url.hash = ''
268
+ let available = false
269
+ checking = true
270
+ try {
271
+ const response = await fetch(url, {
272
+ cache: 'no-store',
273
+ signal: AbortSignal.timeout(2000),
274
+ })
275
+ available = response.ok && await response.text() === 'CDP.js browser connection'
276
+ } catch (error) {
277
+ if (!(error instanceof TypeError) && error.name !== 'TimeoutError') throw error
278
+ } finally {
279
+ checking = false
280
+ }
281
+ if (endpoint !== config.websocket_url) return connect()
282
+ if (!available) {
283
+ report_connection('Waiting for CDP.js server at ' + endpoint + '; retrying automatically.')
284
+ return
285
+ }
286
+ const connection = socket = new WebSocket(endpoint)
287
+ connection.onopen = () => {
288
+ report_connection('Connected to CDP.js server at ' + endpoint + '.')
289
+ connection.send(JSON.stringify({
290
+ browser: config.browser,
291
+ extension_id: chrome.runtime.id,
292
+ token: config.token,
293
+ }))
294
+ }
205
295
  connection.onmessage = async event => {
206
296
  if (!event.data) return
207
297
  const request = JSON.parse(event.data)
@@ -248,6 +338,7 @@ function connect() {
248
338
  if (id != null && connection.readyState === WebSocket.OPEN) connection.send(JSON.stringify(response))
249
339
  }
250
340
  connection.onclose = () => {
341
+ report_connection('Waiting for CDP.js server at ' + endpoint + '; retrying automatically.')
251
342
  discovering = false
252
343
  disconnecting = Promise.all(Object.keys(attached).map(id => {
253
344
  const source = { tabId: Number(id) }
@@ -257,8 +348,19 @@ function connect() {
257
348
  }
258
349
  }
259
350
 
260
- chrome.runtime.onStartup.addListener(connect)
351
+ async function start() {
352
+ await configured
353
+ if (!config.browser) {
354
+ for (const tab of (await chrome.tabs.query({})).reverse()) {
355
+ if (config.browser) break
356
+ await bootstrap(tab)
357
+ }
358
+ }
359
+ await connect()
360
+ }
361
+
362
+ chrome.runtime.onStartup.addListener(start)
261
363
  chrome.alarms.onAlarm.addListener(connect)
262
364
  chrome.alarms.create('cdp-reconnect', { periodInMinutes: 0.5 })
263
- setInterval(connect, config.reconnect_ms ?? 5000)
264
- connect()
365
+ setInterval(connect, config.reconnect_ms)
366
+ 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.1",
5
5
  "minimum_chrome_version": "125",
6
6
  "permissions": [
7
7
  "accessibilityFeatures.read",
@@ -68,9 +68,5 @@
68
68
  "incognito": "spanning",
69
69
  "background": {
70
70
  "service_worker": "cdp_ext.js"
71
- },
72
- "cdp": {
73
- "websocket_url": "ws://127.0.0.1:9223",
74
- "reconnect_ms": 5000
75
71
  }
76
72
  }
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.1",
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",