@mefistofelix/cdp.js 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,7 +24,7 @@ import api, { util, jsrpc, browser, cdp } from '@mefistofelix/cdp.js'
24
24
 
25
25
  The npm package exports the same file and API. Direct file imports in the examples
26
26
  below remain supported. Publication metadata is isolated in `npm/`.
27
- The package includes both `cdp_ext/` files. With `cdp_ext: true`, the manager
27
+ The package includes the worker, manifest and debug sidebar files. With `cdp_ext: true`, the manager
28
28
  prepares and loads the extension automatically with a custom user-data directory.
29
29
  The system profile uses an already installed extension by default. Reverse mode still
30
30
  requires Deno or Bun. In Deno, import from `npm:@mefistofelix/cdp.js`.
@@ -491,7 +491,7 @@ can configure its native incognito scope. See
491
491
  ## Reverse Chrome extension transport
492
492
 
493
493
  [cdp_ext/manifest.json](cdp_ext/manifest.json) and
494
- [cdp_ext/cdp_ext.js](cdp_ext/cdp_ext.js) form a Manifest V3 extension for Chrome
494
+ [cdp_ext/cdp_ext.js](cdp_ext/cdp_ext.js), with the local debug sidebar, form a Manifest V3 extension for Chrome
495
495
  125+. The extension connects out to one WebSocket server. The server serves all
496
496
  browser instances on the same port; bootstrapped extensions announce the logical
497
497
  browser name and native extension ID. No remote-debugging argument is needed.
@@ -541,9 +541,9 @@ await client.call({
541
541
 
542
542
  Provide `user_data_dir` or raw `args['user-data-dir']` to choose a custom
543
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
544
+ the manager copies the four 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
547
  options and profile preferences then apply. Set `cdp_ext_install: false` to use
548
548
  an extension already installed in that profile, or true to force preparation and
549
549
  loading. A null/false raw directory override makes automatic installation default off.
@@ -568,6 +568,13 @@ and enable the bridge. An `extensions` array adds its directories;
568
568
  `extensions: true` retains enabled profile extensions through `'*'`. Raw `args`
569
569
  still win and can prevent loading or connection.
570
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
+
571
578
  The manager opens an actual local HTTP URL on the shared listener, for example
572
579
  `http://127.0.0.1:9223/cdp_ext?browser=work&websocket_url=...&token=...`.
573
580
  The port is the effective listening port, including an automatically allocated
@@ -590,15 +597,22 @@ manager while connected returns an explicit error, preserving the existing
590
597
  connection. After disconnection, a new bootstrap can rebind it.
591
598
 
592
599
  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.
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`.
596
609
 
597
- The manifest's `cdp` configuration supplies initial connection defaults:
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):
598
613
 
599
614
  | Manifest `cdp` key | Default | Meaning |
600
615
  | --- | --- | --- |
601
- | `websocket_url` | `ws://127.0.0.1:9223` | Initial shared endpoint; URL bootstrap overrides it. |
602
616
  | `reconnect_ms` | `5000` | Awake retry and connected heartbeat interval; use a positive value below 30000. |
603
617
 
604
618
  Construction and direct CDP calls do not start a listener. The first browser call
@@ -662,10 +676,42 @@ another connection attempt; the configured interval applies while it is awake.
662
676
  Connected empty-text heartbeats keep an otherwise idle WebSocket worker active.
663
677
  These are transport messages, not event buffering or polling of browser state.
664
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
+
688
+ ### Debug sidebar
689
+
690
+ Click the extension's toolbar icon to open its native Chrome side panel.
691
+ It shows the current connection status, logical browser name, WebSocket endpoint,
692
+ native extension ID and number of attached root/child sessions. It works before
693
+ the first trigger too, displaying **Waiting for trigger** without starting a
694
+ server connection.
695
+
696
+ The most recent 50 incoming commands appear newest first, with request ID,
697
+ method, optional session ID, running/done/error status, elapsed milliseconds and
698
+ error text. This is command metadata only: parameters, results and the trigger
699
+ token are not included. Empty heartbeat messages and CDP events are not logged.
700
+ **Clear** empties this history without cancelling commands or changing the
701
+ connection. Reopening the panel retrieves the current snapshot; a worker restart
702
+ or extension reload clears the history. No debug history is written to storage.
703
+
704
+ The panel receives updates through a local extension port. While open, a
705
+ 20-second port heartbeat keeps the worker available, including before a trigger.
706
+ Closing the panel does not disconnect CDP. After reloading the extension, reopen
707
+ the panel if its old document reports that it has disconnected. The HTML and
708
+ external script follow the normal extension CSP and require no build or dependency.
709
+
665
710
  ### Reverse protocol and supported commands
666
711
 
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:
712
+ The current extension's first announcement contains `{ browser, extension_id, token }`
713
+ after a trigger. The server also accepts legacy native-ID announcements.
714
+ Subsequent messages use CDP envelopes:
669
715
 
670
716
  - Request: `{ id, method, params, sessionId? }`.
671
717
  - Response: `{ id, result }` or `{ id, error: { code, message } }`.
@@ -1763,11 +1809,12 @@ from tool-call history. It is a caller-side recipe, not additional library API.
1763
1809
 
1764
1810
  For npm packaging, account setup, and GitHub publishing, see
1765
1811
  [Publishing](npm/PUBLISHING.md). Publishing copies `cdp.js`, this README and the
1766
- two `cdp_ext/` files into `npm/` alongside its manifest; no build script is needed.
1812
+ four `cdp_ext/` files into `npm/` alongside its manifest; no build script is needed.
1767
1813
 
1768
1814
  ```sh
1769
1815
  node --check cdp.js
1770
1816
  node --check cdp_ext/cdp_ext.js
1817
+ node --check cdp_ext/sidebar.js
1771
1818
  node --test tests/cdp.test.js
1772
1819
  deno test -A tests/reverse.test.js
1773
1820
  bun test tests/cdp.test.js tests/reverse.test.js --timeout 60000
@@ -1805,5 +1852,13 @@ already running instance, startup when absent, one shared extension directory,
1805
1852
  logical names, binding tokens, conflicting names and preservation on manager close.
1806
1853
  Installation defaults, raw directory overrides, profile writes independent of
1807
1854
  installation and shared wildcard expansion are covered without opening personal profiles.
1808
- The npm tarball contains `package.json`, `cdp.js`, `README.md` and the two extension
1809
- files required by managed reverse launches.
1855
+ Argument coverage crosses direct/extension launch modes with headless, proxy,
1856
+ image/login/translation options, extension selectors and raw overrides. Profile
1857
+ combinations include default/custom directories, null/false raw directory
1858
+ overrides, forced installation and independent preference-write controls.
1859
+ Offline worker checks verify silence before a trigger, deduplicated waiting logs,
1860
+ no WebSocket attempts against absent/incompatible servers and concurrent retry checks.
1861
+ The npm tarball contains `package.json`, `cdp.js`, `README.md` and the four extension
1862
+ files required by managed reverse launches and their debug sidebar. Tests cover
1863
+ live command metadata, its 50-entry limit, error/session details, sidebar rendering,
1864
+ safe text display, reopening and clearing through native extension messaging.
package/cdp.js CHANGED
@@ -153,12 +153,10 @@ export class browser {
153
153
  'disable-prompt-on-repost': true,
154
154
  'force-color-profile': 'srgb',
155
155
  'metrics-recording-only': true,
156
- 'no-first-run': true,
157
156
  'password-store': 'basic',
158
157
  'use-mock-keychain': true,
159
158
  'no-service-autorun': true,
160
159
  'export-tagged-pdf': true,
161
- 'disable-search-engine-choice-screen': true,
162
160
  'disable-features': [
163
161
  'InfiniteSessionRestore',
164
162
  'ImprovedCookieControls',
@@ -177,6 +175,8 @@ export class browser {
177
175
  'user-data-dir': options.user_data_dir,
178
176
  'remote-debugging-port': 0,
179
177
  }
178
+ args['no-first-run'] = true
179
+ args['disable-search-engine-choice-screen'] = true
180
180
  if (options.headless) {
181
181
  args.headless = 'new'
182
182
  args['window-size'] = '1440,900'
@@ -658,10 +658,9 @@ export class cdp extends EventTarget {
658
658
  await fs.mkdir(record.extension_path, { recursive: true })
659
659
  record.extension_path = await fs.realpath(record.extension_path)
660
660
  record.extension_id = browser.extension_id(record.extension_path)
661
- const manifest = JSON.parse(await fs.readFile(new URL('./cdp_ext/manifest.json', import.meta.url), 'utf8'))
662
- manifest.cdp.websocket_url = record.reverse_url
663
- await fs.writeFile(path.join(record.extension_path, 'manifest.json'), JSON.stringify(manifest))
664
- await fs.copyFile(new URL('./cdp_ext/cdp_ext.js', import.meta.url), path.join(record.extension_path, 'cdp_ext.js'))
661
+ for (const filename of ['manifest.json', 'cdp_ext.js', 'sidebar.html', 'sidebar.js']) {
662
+ await fs.copyFile(new URL('./cdp_ext/' + filename, import.meta.url), path.join(record.extension_path, filename))
663
+ }
665
664
  }
666
665
  if (args['user-data-dir'] && record.update_preferences !== false) {
667
666
  await browser.update_profile_preferences(path.join(args['user-data-dir'], args['profile-directory'] || 'Default', 'Preferences'), {
@@ -1,4 +1,6 @@
1
- const config = chrome.runtime.getManifest().cdp ?? {}
1
+ const config = {
2
+ reconnect_ms: chrome.runtime.getManifest().cdp?.reconnect_ms ?? 5000,
3
+ }
2
4
  const configured = chrome.storage.session.get('cdp_bootstrap').then(value => Object.assign(config, value.cdp_bootstrap))
3
5
  const sessions = {}
4
6
  const attached = {}
@@ -8,6 +10,47 @@ let disconnecting
8
10
  let discovering = false
9
11
  let auto_attach = {}
10
12
  let bootstrapping
13
+ let checking = false
14
+ let connection_message
15
+ const debug_commands = []
16
+ const debug_ports = []
17
+
18
+ function debug_update() {
19
+ const state = {
20
+ status: connection_message ?? (config.browser ? 'Connecting to server' : 'Waiting for trigger'),
21
+ connected: socket?.readyState === WebSocket.OPEN,
22
+ browser: config.browser ?? '',
23
+ endpoint: config.websocket_url ?? '',
24
+ extension_id: chrome.runtime.id,
25
+ sessions: Object.keys(sessions).length,
26
+ commands: debug_commands,
27
+ }
28
+ for (const port of debug_ports) {
29
+ try {
30
+ port.postMessage(state)
31
+ } catch {}
32
+ }
33
+ }
34
+
35
+ chrome.runtime.onConnect.addListener(port => {
36
+ if (port.name !== 'cdp_debug') return
37
+ debug_ports.push(port)
38
+ port.onDisconnect.addListener(() => debug_ports.splice(debug_ports.indexOf(port), 1))
39
+ port.onMessage.addListener(message => {
40
+ if (message !== 'clear') return
41
+ debug_commands.length = 0
42
+ debug_update()
43
+ })
44
+ configured.then(debug_update)
45
+ })
46
+ chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true })
47
+
48
+ function report_connection(message) {
49
+ if (connection_message === message) return
50
+ connection_message = message
51
+ console.info('[cdp_ext] ' + message)
52
+ debug_update()
53
+ }
11
54
 
12
55
  async function bootstrap(tab) {
13
56
  const text = tab.pendingUrl ?? tab.url ?? ''
@@ -37,6 +80,8 @@ async function bootstrap(tab) {
37
80
  }
38
81
  if (changed) {
39
82
  Object.assign(config, settings)
83
+ connection_message = undefined
84
+ debug_update()
40
85
  await chrome.storage.session.set({ cdp_bootstrap: settings })
41
86
  if (socket && socket.readyState !== WebSocket.CLOSED) {
42
87
  await new Promise(resolve => {
@@ -81,6 +126,7 @@ function detached(source) {
81
126
  targetId: String(source.tabId),
82
127
  },
83
128
  })
129
+ debug_update()
84
130
  }
85
131
 
86
132
  async function attach_tab(tabId) {
@@ -196,6 +242,7 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
196
242
  sessionId: params.sessionId,
197
243
  }
198
244
  else delete sessions[params.sessionId]
245
+ debug_update()
199
246
  }
200
247
  send({
201
248
  method,
@@ -245,19 +292,57 @@ for (const [name, options] of Object.entries({
245
292
 
246
293
  async function connect() {
247
294
  await configured
248
- if (disconnecting) return
295
+ if (!config.browser || !config.websocket_url || !config.token) return
296
+ if (disconnecting || checking) return
249
297
  if (socket?.readyState === WebSocket.CONNECTING) return
250
298
  if (socket?.readyState === WebSocket.OPEN) return socket.send('')
251
- const connection = socket = new WebSocket(config.websocket_url ?? 'ws://127.0.0.1:9223')
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
- }))
299
+ const endpoint = config.websocket_url
300
+ const url = new URL(endpoint)
301
+ url.protocol = url.protocol === 'wss:' ? 'https:' : 'http:'
302
+ url.pathname = '/cdp_ext'
303
+ url.search = ''
304
+ url.hash = ''
305
+ let available = false
306
+ checking = true
307
+ try {
308
+ const response = await fetch(url, {
309
+ cache: 'no-store',
310
+ signal: AbortSignal.timeout(2000),
311
+ })
312
+ available = response.ok && await response.text() === 'CDP.js browser connection'
313
+ } catch (error) {
314
+ if (!(error instanceof TypeError) && error.name !== 'TimeoutError') throw error
315
+ } finally {
316
+ checking = false
317
+ }
318
+ if (endpoint !== config.websocket_url) return connect()
319
+ if (!available) {
320
+ report_connection('Waiting for CDP.js server at ' + endpoint + '; retrying automatically.')
321
+ return
322
+ }
323
+ const connection = socket = new WebSocket(endpoint)
324
+ connection.onopen = () => {
325
+ report_connection('Connected to CDP.js server at ' + endpoint + '.')
326
+ connection.send(JSON.stringify({
327
+ browser: config.browser,
328
+ extension_id: chrome.runtime.id,
329
+ token: config.token,
330
+ }))
331
+ }
257
332
  connection.onmessage = async event => {
258
333
  if (!event.data) return
259
334
  const request = JSON.parse(event.data)
260
335
  const { method, params = {}, sessionId, id } = request
336
+ const command = {
337
+ time: Date.now(),
338
+ id,
339
+ method,
340
+ sessionId,
341
+ status: 'running',
342
+ }
343
+ debug_commands.unshift(command)
344
+ debug_commands.length = Math.min(debug_commands.length, 50)
345
+ debug_update()
261
346
  let response
262
347
  try {
263
348
  let result
@@ -297,9 +382,14 @@ async function connect() {
297
382
  },
298
383
  }
299
384
  }
385
+ command.status = response.error ? 'error' : 'done'
386
+ command.error = response.error?.message
387
+ command.duration_ms = Date.now() - command.time
388
+ debug_update()
300
389
  if (id != null && connection.readyState === WebSocket.OPEN) connection.send(JSON.stringify(response))
301
390
  }
302
391
  connection.onclose = () => {
392
+ report_connection('Waiting for CDP.js server at ' + endpoint + '; retrying automatically.')
303
393
  discovering = false
304
394
  disconnecting = Promise.all(Object.keys(attached).map(id => {
305
395
  const source = { tabId: Number(id) }
@@ -323,5 +413,5 @@ async function start() {
323
413
  chrome.runtime.onStartup.addListener(start)
324
414
  chrome.alarms.onAlarm.addListener(connect)
325
415
  chrome.alarms.create('cdp-reconnect', { periodInMinutes: 0.5 })
326
- setInterval(connect, config.reconnect_ms ?? 5000)
416
+ setInterval(connect, config.reconnect_ms)
327
417
  start()
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "CDP reverse connection",
4
- "version": "0.5.0",
4
+ "version": "0.6.0",
5
5
  "minimum_chrome_version": "125",
6
6
  "permissions": [
7
7
  "accessibilityFeatures.read",
@@ -64,13 +64,14 @@
64
64
  "host_permissions": [
65
65
  "<all_urls>"
66
66
  ],
67
- "action": {},
67
+ "action": {
68
+ "default_title": "CDP.js debug"
69
+ },
70
+ "side_panel": {
71
+ "default_path": "sidebar.html"
72
+ },
68
73
  "incognito": "spanning",
69
74
  "background": {
70
75
  "service_worker": "cdp_ext.js"
71
- },
72
- "cdp": {
73
- "websocket_url": "ws://127.0.0.1:9223",
74
- "reconnect_ms": 5000
75
76
  }
76
77
  }
@@ -0,0 +1,35 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <meta charset="utf-8">
4
+ <meta name="viewport" content="width=device-width, initial-scale=1">
5
+ <title>CDP.js debug</title>
6
+ <style>
7
+ :root { color-scheme: light dark; font: 14px system-ui, sans-serif; }
8
+ body { margin: 16px; }
9
+ header { display: flex; align-items: center; justify-content: space-between; }
10
+ h1 { font-size: 18px; margin: 0; }
11
+ h2 { font-size: 14px; margin: 24px 0 12px; }
12
+ button { font: inherit; cursor: pointer; padding: 5px 10px; }
13
+ #status { border-left: 3px solid #d99b24; padding-left: 10px; overflow-wrap: anywhere; }
14
+ #status.connected { border-color: #35a76a; }
15
+ dt { opacity: .65; margin-top: 12px; }
16
+ dd { margin: 3px 0; overflow-wrap: anywhere; font-family: monospace; }
17
+ #commands { padding: 0; list-style: none; }
18
+ li { border-top: 1px solid #8885; padding: 10px 0; }
19
+ strong { display: block; overflow-wrap: anywhere; font: 13px monospace; }
20
+ small { display: block; opacity: .7; margin-top: 5px; font-size: 12px; }
21
+ .error { color: light-dark(#b42318, #ff938a); white-space: pre-wrap; overflow-wrap: anywhere; }
22
+ </style>
23
+ <header><h1>CDP.js debug</h1><button id="clear" type="button" title="Clear command history">Clear</button></header>
24
+ <p id="status" role="status">Connecting to extension…</p>
25
+ <dl>
26
+ <dt>Browser</dt><dd id="browser">—</dd>
27
+ <dt>Server</dt><dd id="endpoint">—</dd>
28
+ <dt>Extension</dt><dd id="extension">—</dd>
29
+ <dt>Attached sessions</dt><dd id="sessions">0</dd>
30
+ </dl>
31
+ <h2>Recent commands <small>Latest 50 · memory only</small></h2>
32
+ <p id="empty">No commands received.</p>
33
+ <ol id="commands"></ol>
34
+ <script src="sidebar.js"></script>
35
+ </html>
@@ -0,0 +1,43 @@
1
+ const port = chrome.runtime.connect({ name: 'cdp_debug' })
2
+ const status = document.querySelector('#status')
3
+ const heartbeat = setInterval(() => port.postMessage('ping'), 20000)
4
+
5
+ port.onMessage.addListener(state => {
6
+ status.textContent = state.status
7
+ status.classList.toggle('connected', state.connected)
8
+ for (const [id, value] of Object.entries({
9
+ browser: state.browser,
10
+ endpoint: state.endpoint,
11
+ extension: state.extension_id,
12
+ sessions: state.sessions,
13
+ })) document.getElementById(id).textContent = value === '' ? '—' : value
14
+ document.querySelector('#empty').hidden = state.commands.length > 0
15
+ document.querySelector('#commands').replaceChildren(...state.commands.map(command => {
16
+ const row = document.createElement('li')
17
+ const method = document.createElement('strong')
18
+ method.textContent = command.method
19
+ const details = document.createElement('small')
20
+ details.textContent = [
21
+ new Date(command.time).toLocaleTimeString(),
22
+ '#' + (command.id ?? 'notification'),
23
+ command.sessionId ? 'session ' + command.sessionId : 'browser',
24
+ command.status,
25
+ command.duration_ms == null ? '' : command.duration_ms + ' ms',
26
+ ].filter(Boolean).join(' · ')
27
+ row.append(method, details)
28
+ if (command.error) {
29
+ const error = document.createElement('p')
30
+ error.className = 'error'
31
+ error.textContent = command.error
32
+ row.append(error)
33
+ }
34
+ return row
35
+ }))
36
+ })
37
+ port.onDisconnect.addListener(() => {
38
+ clearInterval(heartbeat)
39
+ status.textContent = 'Extension disconnected. Reopen this panel after reloading the extension.'
40
+ status.classList.remove('connected')
41
+ document.querySelector('#clear').disabled = true
42
+ })
43
+ document.querySelector('#clear').addEventListener('click', () => port.postMessage('clear'))
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@mefistofelix/cdp.js",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Low-level Chromium DevTools Protocol client for Node.js, Deno and Bun, with no runtime dependencies.",
5
5
  "type": "module",
6
6
  "exports": "./cdp.js",
7
7
  "files": [
8
8
  "cdp.js",
9
9
  "cdp_ext/manifest.json",
10
- "cdp_ext/cdp_ext.js"
10
+ "cdp_ext/cdp_ext.js",
11
+ "cdp_ext/sidebar.html",
12
+ "cdp_ext/sidebar.js"
11
13
  ],
12
14
  "engines": {
13
15
  "node": ">=22.19.0"