@mefistofelix/cdp.js 0.1.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.
Files changed (3) hide show
  1. package/README.md +835 -0
  2. package/cdp.js +544 -0
  3. package/package.json +32 -0
package/cdp.js ADDED
@@ -0,0 +1,544 @@
1
+ import * as child_process from 'node:child_process'
2
+ import * as fs from 'node:fs/promises'
3
+ import { existsSync } from 'node:fs'
4
+ import * as path from 'node:path'
5
+ import { once, setMaxListeners } from 'node:events'
6
+
7
+ export class util {
8
+ static emit(target, name, detail) {
9
+ return target.dispatchEvent(new CustomEvent(name, { detail }))
10
+ }
11
+
12
+ static async on_first(target, names, timeout_ms = 30000) {
13
+ const controller = new AbortController()
14
+ const timer = setTimeout(() => controller.abort(new Error('Timed out waiting for ' + names)), timeout_ms)
15
+ try {
16
+ return await Promise.race(names.split(' ').map(type =>
17
+ once(target, type, { signal: controller.signal }).then(args => ({
18
+ type,
19
+ args,
20
+ })),
21
+ ))
22
+ } finally {
23
+ clearTimeout(timer)
24
+ controller.abort()
25
+ }
26
+ }
27
+
28
+ static normalize_xpath(xpath) {
29
+ return xpath
30
+ .replace(/ends-with\(([^,]+),([^\)]+)\)/g, '(substring($1, string-length($1)-string-length($2)+1) = $2)')
31
+ .replace(/icontains\(([^,]+),([^\)]+)\)/g,
32
+ "contains(translate($1,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),translate($2,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'))")
33
+ }
34
+
35
+ static args_to_strings(args) {
36
+ const result = []
37
+ for (const [name, value] of Object.entries(args)) {
38
+ if (value === false || value == null) continue
39
+ const text = Array.isArray(value) ? value.join(',') : value
40
+ result.push('--' + name + (value === true ? '' : '=' + text))
41
+ }
42
+ return result
43
+ }
44
+ }
45
+
46
+ export class jsrpc extends WebSocket {
47
+ constructor(url, options = {}) {
48
+ super(url)
49
+ setMaxListeners(0, this)
50
+ this.id = 0
51
+ this.pending = {}
52
+ this.timeout_ms = options.request_timeout_ms ?? 30000
53
+ this.addEventListener('message', event => {
54
+ const message = JSON.parse(event.data)
55
+ util.emit(this, message.id === undefined ? 'notify' : 'rpc_' + message.id, message)
56
+ })
57
+ }
58
+
59
+ async req(request, timeout_ms = this.timeout_ms) {
60
+ if (this.readyState !== WebSocket.OPEN) throw new Error('WebSocket is not open')
61
+ const id = ++this.id
62
+ const data = JSON.stringify({
63
+ ...request,
64
+ id,
65
+ })
66
+ this.pending[id] = util.on_first(this, 'rpc_' + id + ' close error', timeout_ms)
67
+ try {
68
+ this.send(data)
69
+ const event = await this.pending[id]
70
+ if (event.type !== 'rpc_' + id) throw new Error('WebSocket connection closed')
71
+ const response = event.args[0].detail
72
+ if (response.error) {
73
+ const error = new Error(response.error.message, {
74
+ cause: {
75
+ req: request,
76
+ ret: response,
77
+ },
78
+ })
79
+ error.cdp = response
80
+ throw error
81
+ }
82
+ return response.result
83
+ } finally {
84
+ delete this.pending[id]
85
+ }
86
+ }
87
+
88
+ notify(request) {
89
+ this.send(JSON.stringify(request))
90
+ }
91
+ }
92
+
93
+ export class browser {
94
+ static find_executable_path() {
95
+ if (process.env.CDP_BROWSER) return process.env.CDP_BROWSER
96
+ const candidates = process.platform === 'win32'
97
+ ? [process.env.ProgramFiles, process.env['ProgramFiles(x86)'], process.env.LOCALAPPDATA]
98
+ .filter(Boolean).flatMap(root => [
99
+ path.join(root, 'Google/Chrome/Application/chrome.exe'),
100
+ path.join(root, 'Microsoft/Edge/Application/msedge.exe'),
101
+ ])
102
+ : [
103
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
104
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
105
+ '/usr/bin/google-chrome',
106
+ '/usr/bin/google-chrome-stable',
107
+ '/usr/bin/chromium',
108
+ '/usr/bin/chromium-browser',
109
+ '/usr/bin/microsoft-edge',
110
+ ]
111
+ const executable = candidates.find(existsSync)
112
+ if (!executable) throw new Error('Set executable_path or CDP_BROWSER to a Chromium executable')
113
+ return executable
114
+ }
115
+
116
+ static build_args(options = {}) {
117
+ const args = {
118
+ 'enable-automation': true,
119
+ 'mute-audio': true,
120
+ 'disable-blink-features': 'AutomationControlled',
121
+ 'hide-crash-restore-bubble': true,
122
+ 'disable-field-trial-config': true,
123
+ 'disable-background-networking': true,
124
+ 'enable-features': ['NetworkService', 'NetworkServiceInProcess'],
125
+ 'disable-background-timer-throttling': true,
126
+ 'disable-backgrounding-occluded-windows': true,
127
+ 'disable-renderer-backgrounding': true,
128
+ 'disable-back-forward-cache': true,
129
+ 'disable-breakpad': true,
130
+ 'disable-client-side-phishing-detection': true,
131
+ 'disable-component-update': true,
132
+ 'no-default-browser-check': true,
133
+ 'disable-default-apps': true,
134
+ 'disable-dev-shm-usage': true,
135
+ 'allow-pre-commit-input': true,
136
+ 'disable-hang-monitor': true,
137
+ 'disable-ipc-flooding-protection': true,
138
+ 'disable-popup-blocking': true,
139
+ 'disable-prompt-on-repost': true,
140
+ 'force-color-profile': 'srgb',
141
+ 'metrics-recording-only': true,
142
+ 'no-first-run': true,
143
+ 'password-store': 'basic',
144
+ 'use-mock-keychain': true,
145
+ 'no-service-autorun': true,
146
+ 'export-tagged-pdf': true,
147
+ 'disable-search-engine-choice-screen': true,
148
+ 'disable-features': [
149
+ 'InfiniteSessionRestore',
150
+ 'ImprovedCookieControls',
151
+ 'LazyFrameLoading',
152
+ 'GlobalMediaControls',
153
+ 'DestroyProfileOnBrowserClose',
154
+ 'MediaRouter',
155
+ 'DialMediaRouteProvider',
156
+ 'AcceptCHFrame',
157
+ 'AutoExpandDetailsElement',
158
+ 'CertificateTransparencyComponentUpdater',
159
+ 'AvoidUnnecessaryBeforeUnloadCheckSync',
160
+ 'HttpsUpgrades',
161
+ 'PaintHolding',
162
+ ],
163
+ 'user-data-dir': options.user_data_dir,
164
+ 'remote-debugging-port': 0,
165
+ }
166
+ if (options.headless) {
167
+ args.headless = 'new'
168
+ args['window-size'] = '1440,900'
169
+ }
170
+ if (options.extensions !== true) {
171
+ args['disable-extensions'] = true
172
+ args['disable-component-extensions-with-background-pages'] = true
173
+ }
174
+ if (options.images === false) args['blink-settings'] = 'imagesEnabled=false'
175
+ if (options.translations !== true) {
176
+ args['disable-features'].push('Translate', 'TranslateToast', 'EnableTranslatePdf')
177
+ }
178
+ if (options.login !== true) {
179
+ args['disable-signin-promo-on-avatar-pill-for-testing'] = true
180
+ args['disable-sync'] = true
181
+ args['disable-features'].push('DiceWebSigninInterception', 'SigninPromoOnAvatarPill')
182
+ }
183
+ return Object.assign(args, options.args)
184
+ }
185
+
186
+ static async update_profile_preferences(filename, preferences) {
187
+ await fs.mkdir(path.dirname(filename), { recursive: true })
188
+ const content = await fs.readFile(filename, 'utf8').catch(error => {
189
+ if (error.code !== 'ENOENT') throw error
190
+ return '{}'
191
+ })
192
+ const prefs = JSON.parse(content)
193
+ for (const [key, value] of Object.entries(preferences)) {
194
+ const parts = key.split('.')
195
+ let object = prefs
196
+ for (const part of parts.slice(0, -1)) object = object[part] ??= {}
197
+ object[parts.at(-1)] = value
198
+ }
199
+ await fs.writeFile(filename, JSON.stringify(prefs))
200
+ }
201
+
202
+ static async launch(options) {
203
+ const args = browser.build_args(options)
204
+ const filename = path.join(args['user-data-dir'], args['profile-directory'] || 'Default', 'Preferences')
205
+ await browser.update_profile_preferences(filename, {
206
+ 'translate.enabled': options.translations === true,
207
+ 'signin.allowed': options.login === true,
208
+ 'signin.allowed_on_next_startup': options.login === true,
209
+ ...options.preferences,
210
+ })
211
+ const proc = child_process.spawn(options.executable_path || browser.find_executable_path(), [...util.args_to_strings(args), 'about:blank'], {
212
+ windowsHide: true,
213
+ stdio: ['ignore', 'ignore', 'pipe'],
214
+ })
215
+ let diagnostic = ''
216
+ const ready = util.on_first(proc, 'ready exit', options.connect_timeout_ms ?? 15000)
217
+ const data = chunk => {
218
+ diagnostic = (diagnostic + chunk).slice(-8192)
219
+ const match = diagnostic.match(/DevTools listening on (ws:\/\/\S+)/)
220
+ if (match) proc.emit('ready', match[1])
221
+ }
222
+ proc.stderr.on('data', data)
223
+ try {
224
+ const event = await ready
225
+ if (event.type === 'exit') throw new Error('Browser exited: ' + diagnostic)
226
+ return {
227
+ proc,
228
+ url: event.args[0],
229
+ }
230
+ } catch (error) {
231
+ proc.kill()
232
+ throw error
233
+ } finally {
234
+ proc.stderr.removeListener('data', data)
235
+ proc.stderr.resume()
236
+ }
237
+ }
238
+ }
239
+
240
+ const custom_methods = {
241
+ '_.click'(params) {
242
+ const options = {
243
+ attempts: params.attempts ?? 5,
244
+ interval_ms: params.interval_ms ?? 300,
245
+ }
246
+ return this.evaluate_xpath(params, async (xpath, options) => {
247
+ for (let i = 0; i < options.attempts; i++) {
248
+ const node = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue
249
+ if (node) {
250
+ if (typeof node.click !== 'function') return false
251
+ node.click()
252
+ return true
253
+ }
254
+ if (i + 1 < options.attempts) await new Promise(resolve => setTimeout(resolve, options.interval_ms))
255
+ }
256
+ return false
257
+ }, options)
258
+ },
259
+ '_.find'(params) {
260
+ const limit = params.limit ?? 20
261
+ return this.evaluate_xpath(params, (xpath, limit) => {
262
+ const nodes = document.evaluate(xpath, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
263
+ const items = []
264
+ for (let i = 0; i < Math.min(nodes.snapshotLength, limit); i++) {
265
+ const node = nodes.snapshotItem(i)
266
+ const text = String(node.innerText ?? node.textContent ?? '').trim().slice(0, 2000)
267
+ items.push({
268
+ node_type: node.nodeType,
269
+ node_name: node.nodeName,
270
+ text,
271
+ tag: node.tagName?.toLowerCase(),
272
+ id: node.id,
273
+ class: node.className == null ? undefined : String(node.className),
274
+ href: node.href,
275
+ value: node.value,
276
+ rect: node.getBoundingClientRect?.().toJSON(),
277
+ })
278
+ }
279
+ return {
280
+ count: nodes.snapshotLength,
281
+ items,
282
+ }
283
+ }, limit)
284
+ },
285
+ }
286
+
287
+ export class cdp extends EventTarget {
288
+ constructor({ base_path = '.cdp', ...options } = {}) {
289
+ super()
290
+ this.base_path = path.resolve(base_path)
291
+ this.options = options
292
+ this.browsers = Object.create(null)
293
+ this.custom_methods = { ...custom_methods }
294
+ }
295
+
296
+ evaluate_xpath(params, fn, options) {
297
+ if (params.target == null) throw new Error('Custom method requires target')
298
+ const xpath = util.normalize_xpath(params.xpath)
299
+ return this.call({
300
+ method: 'Runtime.evaluate',
301
+ params: {
302
+ browser: params.browser,
303
+ target: params.target,
304
+ expression: '(' + fn + ')(...' + JSON.stringify([xpath, options]) + ')',
305
+ returnByValue: true,
306
+ awaitPromise: true,
307
+ silent: true,
308
+ userGesture: true,
309
+ },
310
+ })
311
+ }
312
+
313
+ _detach(browser, sessionId) {
314
+ const targetId = browser.session_targets[sessionId]
315
+ delete browser.session_targets[sessionId]
316
+ const info = browser.target_info[targetId]
317
+ if (info && info.sessionId === sessionId) delete info.sessionId
318
+ for (const target of Object.values(browser.targets)) {
319
+ if (target.sessionId === sessionId) target.sessionId = null
320
+ }
321
+ }
322
+
323
+ _notify(browser, message) {
324
+ const params = {
325
+ ...message.params,
326
+ browser: browser.name,
327
+ }
328
+ const sessionId = params.sessionId || message.sessionId
329
+ const targetId = params.targetInfo?.targetId || params.targetId || browser.session_targets[sessionId]
330
+ const target = Object.values(browser.targets).find(value => value.targetId === targetId)
331
+ if (params.targetInfo) {
332
+ Object.assign(browser.target_info[targetId] ??= {}, params.targetInfo)
333
+ }
334
+ const info = browser.target_info[targetId]
335
+ if (message.method === 'Target.attachedToTarget') {
336
+ this._detach(browser, info?.sessionId)
337
+ info.sessionId = sessionId
338
+ browser.session_targets[sessionId] = targetId
339
+ }
340
+ if (target) params.target = target.name
341
+ util.emit(this, 'notify', {
342
+ ...message,
343
+ params,
344
+ })
345
+ util.emit(this, message.method, params)
346
+ if (message.method === 'Target.detachedFromTarget') this._detach(browser, sessionId)
347
+ if (['Target.targetDestroyed', 'Target.targetCrashed'].includes(message.method)) {
348
+ this._detach(browser, info?.sessionId)
349
+ delete browser.target_info[targetId]
350
+ }
351
+ }
352
+
353
+ async _connect(record) {
354
+ let url = record.websocket_url
355
+ if (!url && (record.http_url || record.port)) {
356
+ const discovery = new URL(record.http_url || 'http://' + (record.host || '127.0.0.1') + ':' + record.port)
357
+ if (!discovery.pathname.endsWith('/json/version')) discovery.pathname = discovery.pathname.replace(/\/$/, '') + '/json/version'
358
+ const response = await fetch(discovery, { signal: AbortSignal.timeout(record.connect_timeout_ms ?? 15000) })
359
+ if (!response.ok) throw new Error('CDP discovery: HTTP ' + response.status)
360
+ url = (await response.json()).webSocketDebuggerUrl
361
+ }
362
+ if (!url) {
363
+ if (record.proc?.exitCode === null && record.proc.signalCode === null) url = record.socket.url
364
+ else {
365
+ const launched = await browser.launch(record)
366
+ record.proc = launched.proc
367
+ url = launched.url
368
+ }
369
+ }
370
+ const socket = record.socket = new jsrpc(url, record)
371
+ record.target_info = {}
372
+ record.session_targets = {}
373
+ for (const target of Object.values(record.targets)) target.sessionId = null
374
+ socket.addEventListener('notify', event => this._notify(record, event.detail))
375
+ socket.addEventListener('close', event => {
376
+ if (record.socket !== socket) return
377
+ for (const sessionId of Object.keys(record.session_targets)) this._detach(record, sessionId)
378
+ util.emit(this, 'close', {
379
+ browser: record.name,
380
+ code: event.code,
381
+ })
382
+ })
383
+ await util.on_first(socket, 'open error close', record.connect_timeout_ms ?? 15000)
384
+ await socket.req({
385
+ method: 'Target.setAutoAttach',
386
+ params: {
387
+ autoAttach: true,
388
+ flatten: true,
389
+ waitForDebuggerOnStart: false,
390
+ },
391
+ })
392
+ await socket.req({
393
+ method: 'Target.setDiscoverTargets',
394
+ params: { discover: true },
395
+ })
396
+ }
397
+
398
+ async _target(browser, target) {
399
+ const socket = browser.socket
400
+ if (!browser.target_info[target.targetId]) {
401
+ target.targetId = (await socket.req({
402
+ method: 'Target.createTarget',
403
+ params: {
404
+ url: 'about:blank',
405
+ background: true,
406
+ ...target.create_params,
407
+ },
408
+ })).targetId
409
+ }
410
+ const info = browser.target_info[target.targetId] ??= {}
411
+ if (!info.sessionId) {
412
+ info.sessionId = (await socket.req({
413
+ method: 'Target.attachToTarget',
414
+ params: {
415
+ targetId: target.targetId,
416
+ flatten: true,
417
+ },
418
+ })).sessionId
419
+ browser.session_targets[info.sessionId] = target.targetId
420
+ }
421
+ const sessionId = info.sessionId
422
+ const steps = []
423
+ if (target.initialize !== false) {
424
+ for (const [option, domain] of Object.entries({
425
+ runtime: 'Runtime',
426
+ page: 'Page',
427
+ network: 'Network',
428
+ service_worker: 'ServiceWorker',
429
+ })) {
430
+ if (target[option] !== false) steps.push([domain + '.enable'])
431
+ }
432
+ if (target.focus_emulation !== false) steps.push(['Emulation.setFocusEmulationEnabled', { enabled: true }])
433
+ if (target.binding) steps.push(['Runtime.addBinding', { name: target.binding }])
434
+ if (target.background_service !== false) steps.push(
435
+ ['BackgroundService.clearEvents', { service: 'pushMessaging' }],
436
+ ['BackgroundService.startObserving', { service: 'pushMessaging' }],
437
+ ['BackgroundService.setRecording', {
438
+ service: 'pushMessaging',
439
+ shouldRecord: true,
440
+ }],
441
+ )
442
+ if ((target.runtime ?? 'bootstrap') === 'bootstrap') steps.push(['Runtime.disable'])
443
+ }
444
+ steps.push(['Runtime.runIfWaitingForDebugger'])
445
+ target.setup_errors = []
446
+ for (const [method, params] of steps) {
447
+ try {
448
+ await socket.req({
449
+ method,
450
+ params,
451
+ sessionId,
452
+ })
453
+ } catch (error) {
454
+ if (!error.cdp) throw error
455
+ target.setup_errors.push(error.message)
456
+ }
457
+ }
458
+ target.sessionId = sessionId
459
+ }
460
+
461
+ async call({ method, params = {} }) {
462
+ if (method.startsWith('_.')) {
463
+ const handler = this.custom_methods[method]
464
+ if (!handler) throw new Error('Unknown CDP extension: ' + method)
465
+ return handler.call(this, params)
466
+ }
467
+ const { browser = 'main', target, ...native } = params
468
+ const spec = typeof browser === 'string' ? { name: browser } : browser
469
+ const record = this.browsers[spec.name] ??= {
470
+ user_data_dir: path.join(this.base_path, spec.name),
471
+ ...this.options,
472
+ ...spec,
473
+ args: {
474
+ ...this.options.args,
475
+ ...spec.args,
476
+ },
477
+ preferences: {
478
+ ...this.options.preferences,
479
+ ...spec.preferences,
480
+ },
481
+ targets: Object.create(null),
482
+ }
483
+ if (!record.connecting && record.socket?.readyState !== WebSocket.OPEN) {
484
+ record.connecting = this._connect(record).catch(error => {
485
+ record.socket?.close()
486
+ record.proc?.kill()
487
+ throw error
488
+ }).finally(() => {
489
+ record.connecting = null
490
+ })
491
+ }
492
+ await record.connecting
493
+ let entry
494
+ if (target != null) {
495
+ const spec = typeof target === 'string' ? { name: target } : target
496
+ entry = record.targets[spec.name] ??= {
497
+ targetId: null,
498
+ sessionId: null,
499
+ binding: '_send_to_cdp',
500
+ ...spec,
501
+ }
502
+ if (entry.binding === true) entry.binding = '_send_to_cdp'
503
+ if (!entry.sessionId) {
504
+ entry.connecting ??= this._target(record, entry).finally(() => {
505
+ entry.connecting = null
506
+ })
507
+ await entry.connecting
508
+ }
509
+ }
510
+ const result = await record.socket.req({
511
+ method,
512
+ params: native,
513
+ sessionId: entry?.sessionId,
514
+ })
515
+ if (method === 'Target.detachFromTarget') this._detach(record, native.sessionId)
516
+ if (method === 'Target.closeTarget' && result.success) {
517
+ this._detach(record, record.target_info[native.targetId]?.sessionId)
518
+ delete record.target_info[native.targetId]
519
+ }
520
+ return result
521
+ }
522
+
523
+ async close() {
524
+ await Promise.all(Object.values(this.browsers).map(async browser => {
525
+ try {
526
+ await browser.connecting
527
+ if (browser.proc && browser.socket?.readyState === WebSocket.OPEN) {
528
+ await browser.socket.req({ method: 'Browser.close' })
529
+ }
530
+ } finally {
531
+ browser.socket?.close()
532
+ browser.proc?.kill()
533
+ }
534
+ }))
535
+ this.browsers = Object.create(null)
536
+ }
537
+ }
538
+
539
+ export default {
540
+ util,
541
+ jsrpc,
542
+ browser,
543
+ cdp,
544
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@mefistofelix/cdp.js",
3
+ "version": "0.1.0",
4
+ "description": "Low-level Chromium DevTools Protocol client for Node.js, with no runtime dependencies.",
5
+ "type": "module",
6
+ "exports": "./cdp.js",
7
+ "files": [
8
+ "cdp.js"
9
+ ],
10
+ "engines": {
11
+ "node": ">=22.19.0"
12
+ },
13
+ "keywords": [
14
+ "cdp",
15
+ "chrome",
16
+ "chromium",
17
+ "devtools",
18
+ "browser",
19
+ "automation"
20
+ ],
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/mefistofelix/cdp.js.git"
24
+ },
25
+ "homepage": "https://github.com/mefistofelix/cdp.js#readme",
26
+ "bugs": {
27
+ "url": "https://github.com/mefistofelix/cdp.js/issues"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ }
32
+ }