@mefistofelix/cdp.js 0.1.2 → 0.3.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/cdp.js CHANGED
@@ -2,6 +2,7 @@ import * as child_process from 'node:child_process'
2
2
  import * as fs from 'node:fs/promises'
3
3
  import { existsSync } from 'node:fs'
4
4
  import * as path from 'node:path'
5
+ import * as crypto from 'node:crypto'
5
6
  import { once, setMaxListeners } from 'node:events'
6
7
 
7
8
  export class util {
@@ -45,15 +46,19 @@ export class util {
45
46
 
46
47
  export class jsrpc extends WebSocket {
47
48
  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 => {
49
+ const socket = typeof url === 'string' || url instanceof URL ? super(url) : url
50
+ setMaxListeners(0, socket)
51
+ socket.id = 0
52
+ socket.pending = {}
53
+ socket.timeout_ms = options.request_timeout_ms ?? 30000
54
+ socket.req = jsrpc.prototype.req
55
+ socket.notify = jsrpc.prototype.notify
56
+ socket.addEventListener('message', event => {
57
+ if (!event.data) return
54
58
  const message = JSON.parse(event.data)
55
- util.emit(this, message.id === undefined ? 'notify' : 'rpc_' + message.id, message)
59
+ util.emit(socket, message.id === undefined ? 'notify' : 'rpc_' + message.id, message)
56
60
  })
61
+ return socket
57
62
  }
58
63
 
59
64
  async req(request, timeout_ms = this.timeout_ms) {
@@ -91,6 +96,12 @@ export class jsrpc extends WebSocket {
91
96
  }
92
97
 
93
98
  export class browser {
99
+ static extension_id(directory) {
100
+ const normalized = path.resolve(directory).replace(/^[a-z]:/, drive => drive.toUpperCase())
101
+ return crypto.createHash('sha256').update(normalized, process.platform === 'win32' ? 'utf16le' : 'utf8')
102
+ .digest('hex').slice(0, 32).replace(/./g, digit => 'abcdefghijklmnop'[parseInt(digit, 16)])
103
+ }
104
+
94
105
  static find_executable_path() {
95
106
  if (process.env.CDP_BROWSER) return process.env.CDP_BROWSER
96
107
  const candidates = process.platform === 'win32'
@@ -167,11 +178,23 @@ export class browser {
167
178
  args.headless = 'new'
168
179
  args['window-size'] = '1440,900'
169
180
  }
170
- if (options.extensions !== true) {
181
+ if (Array.isArray(options.extensions)) args['disable-extensions-except'] = options.extensions
182
+ if (options.extensions !== true && !Array.isArray(options.extensions)) {
171
183
  args['disable-extensions'] = true
172
184
  args['disable-component-extensions-with-background-pages'] = true
173
185
  }
186
+ if (options.cdp_ext) {
187
+ args['remote-debugging-port'] = false
188
+ delete args['disable-extensions']
189
+ delete args['disable-component-extensions-with-background-pages']
190
+ if (options.extension_path) args['disable-extensions-except'] = [
191
+ options.extension_path,
192
+ ...(args['disable-extensions-except'] ?? (options.extensions === true ? ['*'] : [])),
193
+ ]
194
+ }
174
195
  if (options.images === false) args['blink-settings'] = 'imagesEnabled=false'
196
+ if (options.proxy === false) args['no-proxy-server'] = true
197
+ else if (options.proxy != null) args['proxy-server'] = options.proxy
175
198
  if (options.translations !== true) {
176
199
  args['disable-features'].push('Translate', 'TranslateToast', 'EnableTranslatePdf')
177
200
  }
@@ -180,7 +203,39 @@ export class browser {
180
203
  args['disable-sync'] = true
181
204
  args['disable-features'].push('DiceWebSigninInterception', 'SigninPromoOnAvatarPill')
182
205
  }
183
- return Object.assign(args, options.args)
206
+ Object.assign(args, options.args)
207
+ const extensions = args['disable-extensions-except']
208
+ if (extensions != null && extensions !== false) {
209
+ if (extensions === true) args['disable-extensions-except'] = []
210
+ else args['disable-extensions-except'] = Array.isArray(extensions) ? [...extensions] : String(extensions).split(',')
211
+ const features = args['disable-features']
212
+ args['disable-features'] = Array.isArray(features) ? [...features] : features ? String(features).split(',') : []
213
+ const feature = 'DisableDisableExtensionsExceptCommandLineSwitch'
214
+ if (!args['disable-features'].includes(feature)) args['disable-features'].push(feature)
215
+ }
216
+ return args
217
+ }
218
+
219
+ static async profile_extension_paths(args) {
220
+ if (!args['user-data-dir']) throw new Error('Reading profile extensions requires user-data-dir')
221
+ const profile = path.join(args['user-data-dir'], args['profile-directory'] || 'Default')
222
+ const settings = {}
223
+ for (const filename of ['Preferences', 'Secure Preferences']) {
224
+ const content = await fs.readFile(path.join(profile, filename), 'utf8').catch(error => {
225
+ if (error.code !== 'ENOENT') throw error
226
+ return '{}'
227
+ })
228
+ Object.assign(settings, JSON.parse(content).extensions?.settings)
229
+ }
230
+ for (const [id, extension] of Object.entries(settings)) {
231
+ if (!extension.path || [5, 10].includes(extension.location)) delete settings[id]
232
+ else settings[id] = {
233
+ path: path.isAbsolute(extension.path) ? extension.path : path.join(profile, 'Extensions', extension.path),
234
+ enabled: extension.state !== 0 &&
235
+ !(Array.isArray(extension.disable_reasons) ? extension.disable_reasons.length : extension.disable_reasons),
236
+ }
237
+ }
238
+ return settings
184
239
  }
185
240
 
186
241
  static async update_profile_preferences(filename, preferences) {
@@ -201,19 +256,20 @@ export class browser {
201
256
 
202
257
  static async launch(options) {
203
258
  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
- })
259
+ const extensions = args['disable-extensions-except']
260
+ if (extensions && extensions.includes('*')) {
261
+ const installed = Object.values(await browser.profile_extension_paths(args))
262
+ .filter(extension => extension.enabled).map(extension => extension.path)
263
+ args['disable-extensions-except'] = [...new Set(extensions.flatMap(directory =>
264
+ directory === '*' ? installed : directory,
265
+ ))]
266
+ }
211
267
  const proc = child_process.spawn(options.executable_path || browser.find_executable_path(), [...util.args_to_strings(args), 'about:blank'], {
212
268
  windowsHide: options.windowsHide ?? (args.headless != null && args.headless !== false),
213
269
  stdio: ['ignore', 'ignore', 'pipe'],
214
270
  })
215
271
  let diagnostic = ''
216
- const ready = util.on_first(proc, 'ready exit', options.connect_timeout_ms ?? 15000)
272
+ const ready = util.on_first(proc, options.cdp_ext ? 'spawn exit' : 'ready exit', options.connect_timeout_ms ?? 15000)
217
273
  const data = chunk => {
218
274
  diagnostic = (diagnostic + chunk).slice(-8192)
219
275
  const match = diagnostic.match(/DevTools listening on (ws:\/\/\S+)/)
@@ -285,14 +341,112 @@ const custom_methods = {
285
341
  }
286
342
 
287
343
  export class cdp extends EventTarget {
288
- constructor({ base_path = '.cdp', ...options } = {}) {
344
+ constructor({ base_path = '.cdp', reverse_server = {}, ...options } = {}) {
289
345
  super()
290
346
  this.base_path = path.resolve(base_path)
291
347
  this.options = options
348
+ this.reverse_server = { ...reverse_server }
292
349
  this.browsers = Object.create(null)
350
+ this.reverse_sockets = Object.create(null)
293
351
  this.custom_methods = { ...custom_methods }
294
352
  }
295
353
 
354
+ listen_reverse({
355
+ hostname = '127.0.0.1',
356
+ port = 9223,
357
+ } = this.reverse_server) {
358
+ if (!globalThis.Deno && !globalThis.Bun) throw new Error('The reverse WebSocket server requires Deno or Bun')
359
+ if (this.server) throw new Error('Reverse server is already listening')
360
+ const accept = (socket, origin) => {
361
+ socket.addEventListener('message', event => {
362
+ const id = JSON.parse(event.data).browser
363
+ if (id !== origin.slice('chrome-extension://'.length) ||
364
+ this.reverse_sockets[id]?.readyState < WebSocket.CLOSING) {
365
+ return socket.close(1008, 'Invalid or already connected extension ID')
366
+ }
367
+ this.reverse_sockets[id] = socket
368
+ socket.addEventListener('close', () => {
369
+ if (this.reverse_sockets[id] === socket) delete this.reverse_sockets[id]
370
+ this._close_reverse_if_idle()
371
+ })
372
+ util.emit(this, 'reverse_' + encodeURIComponent(id), socket)
373
+ }, { once: true })
374
+ }
375
+ const fetch = (request, server) => {
376
+ const origin = request.headers.get('origin')
377
+ if (request.headers.get('upgrade') !== 'websocket' ||
378
+ !origin?.startsWith('chrome-extension://')) {
379
+ return new Response(null, { status: 403 })
380
+ }
381
+ if (globalThis.Bun) {
382
+ if (server.upgrade(request, {
383
+ data: {
384
+ origin,
385
+ socket: new EventTarget(),
386
+ },
387
+ })) return
388
+ return new Response(null, { status: 400 })
389
+ }
390
+ const { socket, response } = Deno.upgradeWebSocket(request)
391
+ accept(socket, origin)
392
+ return response
393
+ }
394
+ if (globalThis.Bun) {
395
+ const server = this.server = Bun.serve({
396
+ hostname,
397
+ port,
398
+ fetch,
399
+ websocket: {
400
+ open(socket) {
401
+ const events = socket.data.socket
402
+ events.send = socket.send.bind(socket)
403
+ events.close = socket.close.bind(socket)
404
+ Object.defineProperty(events, 'readyState', { get: () => socket.readyState })
405
+ accept(events, socket.data.origin)
406
+ },
407
+ message(socket, data) {
408
+ socket.data.socket.dispatchEvent(new MessageEvent('message', { data }))
409
+ },
410
+ close(socket, code, reason) {
411
+ socket.data.socket.dispatchEvent(Object.assign(new Event('close'), {
412
+ code,
413
+ reason,
414
+ }))
415
+ },
416
+ },
417
+ })
418
+ server.addr = {
419
+ hostname,
420
+ port: server.port,
421
+ }
422
+ const finished = Promise.withResolvers()
423
+ server.finished = finished.promise
424
+ server.shutdown = () => server.stop().then(finished.resolve)
425
+ } else this.server = Deno.serve({
426
+ hostname,
427
+ port,
428
+ }, fetch)
429
+ this.reverse_server = {
430
+ hostname,
431
+ port: this.server.addr.port,
432
+ }
433
+ return this.server
434
+ }
435
+
436
+ _close_reverse_if_idle() {
437
+ if (this.server_closing) return this.server_closing
438
+ if (!this.server || Object.values(this.reverse_sockets).some(socket => socket.readyState < WebSocket.CLOSING)) return
439
+ for (const record of Object.values(this.browsers)) {
440
+ if (!record.reverse && !record.cdp_ext) continue
441
+ if (record.connecting) return
442
+ if (record.proc?.exitCode === null && record.proc.signalCode === null) return
443
+ }
444
+ return this.server_closing = this.server.shutdown().finally(() => {
445
+ this.server = null
446
+ this.server_closing = null
447
+ })
448
+ }
449
+
296
450
  evaluate_xpath(params, fn, options) {
297
451
  if (params.target == null) throw new Error('Custom method requires target')
298
452
  const xpath = util.normalize_xpath(params.xpath)
@@ -326,7 +480,8 @@ export class cdp extends EventTarget {
326
480
  browser: browser.name,
327
481
  }
328
482
  const sessionId = params.sessionId || message.sessionId
329
- const targetId = params.targetInfo?.targetId || params.targetId || browser.session_targets[sessionId]
483
+ const targetId = params.targetInfo?.targetId || params.targetId || browser.session_targets[sessionId] ||
484
+ (params.tabId >= 0 ? String(params.tabId) : undefined)
330
485
  const target = Object.values(browser.targets).find(value => value.targetId === targetId)
331
486
  if (params.targetInfo) {
332
487
  Object.assign(browser.target_info[targetId] ??= {}, params.targetInfo)
@@ -347,26 +502,152 @@ export class cdp extends EventTarget {
347
502
  if (['Target.targetDestroyed', 'Target.targetCrashed'].includes(message.method)) {
348
503
  this._detach(browser, info?.sessionId)
349
504
  delete browser.target_info[targetId]
505
+ this._close_contexts_if_idle(browser)
506
+ }
507
+ }
508
+
509
+ async _context(browser, spec) {
510
+ if (typeof spec === 'string') spec = { name: spec }
511
+ const context = browser.contexts[spec.name] ??= {
512
+ ...spec,
513
+ browserContextId: null,
514
+ }
515
+ await context.closing
516
+ if (!context.browserContextId) {
517
+ context.connecting ??= browser.socket.req({
518
+ method: 'Target.createBrowserContext',
519
+ params: {
520
+ proxyServer: context.proxy === false ? 'direct://' : context.proxy,
521
+ ...context.create_params,
522
+ },
523
+ }).then(result => {
524
+ context.browserContextId = result.browserContextId
525
+ }).finally(() => {
526
+ context.connecting = null
527
+ })
528
+ await context.connecting
529
+ }
530
+ return context
531
+ }
532
+
533
+ _dispose_context(browser, context, params = { browserContextId: context.browserContextId }) {
534
+ const id = context.browserContextId
535
+ return context.closing ??= browser.socket.req({
536
+ method: 'Target.disposeBrowserContext',
537
+ params,
538
+ }).then(result => {
539
+ context.browserContextId = null
540
+ for (const [targetId, info] of Object.entries(browser.target_info)) {
541
+ if (info.browserContextId !== id) continue
542
+ this._detach(browser, info.sessionId)
543
+ delete browser.target_info[targetId]
544
+ }
545
+ util.emit(this, 'context_closed', {
546
+ browser: browser.name,
547
+ context: context.name,
548
+ browserContextId: id,
549
+ })
550
+ return result
551
+ }).finally(() => {
552
+ context.closing = null
553
+ })
554
+ }
555
+
556
+ _close_contexts_if_idle(browser) {
557
+ if (browser.context_checking) {
558
+ browser.context_check_pending = true
559
+ return browser.context_checking
350
560
  }
561
+ if (browser.connecting || browser.closing || browser.socket?.readyState !== WebSocket.OPEN) return
562
+ const contexts = Object.values(browser.contexts).filter(context =>
563
+ context.browserContextId && !context.connecting && !context.closing &&
564
+ !Object.values(browser.target_info).some(info =>
565
+ info.type !== 'browser_ui' && info.browserContextId === context.browserContextId,
566
+ ),
567
+ )
568
+ if (!contexts.length) return
569
+ const socket = browser.socket
570
+ return browser.context_checking = (async () => {
571
+ do {
572
+ browser.context_check_pending = false
573
+ const { targetInfos } = await socket.req({ method: 'Target.getTargets' })
574
+ if (browser.socket !== socket || browser.closing) return
575
+ for (const context of Object.values(browser.contexts)) {
576
+ if (!context.browserContextId || context.connecting || context.closing) continue
577
+ const belongs = info => info.type !== 'browser_ui' && info.browserContextId === context.browserContextId
578
+ if (targetInfos.some(belongs) || Object.values(browser.target_info).some(belongs)) continue
579
+ if (Object.values(browser.targets).some(target =>
580
+ target.connecting && (target.context?.name ?? target.context) === context.name,
581
+ )) continue
582
+ await this._dispose_context(browser, context)
583
+ }
584
+ } while (browser.context_check_pending)
585
+ })().catch(error => util.emit(this, 'error', {
586
+ browser: browser.name,
587
+ error,
588
+ })).finally(() => {
589
+ browser.context_checking = null
590
+ })
351
591
  }
352
592
 
353
593
  async _connect(record) {
594
+ if (record.reverse || record.cdp_ext) {
595
+ await this.server_closing
596
+ if (!this.server) this.listen_reverse()
597
+ }
354
598
  let url = record.websocket_url
355
- if (!url && (record.http_url || record.port)) {
599
+ if (!record.reverse && !record.cdp_ext && !url && (record.http_url || record.port)) {
356
600
  const discovery = new URL(record.http_url || 'http://' + (record.host || '127.0.0.1') + ':' + record.port)
357
601
  if (!discovery.pathname.endsWith('/json/version')) discovery.pathname = discovery.pathname.replace(/\/$/, '') + '/json/version'
358
602
  const response = await fetch(discovery, { signal: AbortSignal.timeout(record.connect_timeout_ms ?? 15000) })
359
603
  if (!response.ok) throw new Error('CDP discovery: HTTP ' + response.status)
360
604
  url = (await response.json()).webSocketDebuggerUrl
361
605
  }
362
- if (!url) {
363
- if (record.proc?.exitCode === null && record.proc.signalCode === null) url = record.socket.url
606
+ if (record.cdp_ext || (!record.reverse && !url)) {
607
+ if (record.proc?.exitCode === null && record.proc.signalCode === null) url = record.socket?.url
364
608
  else {
609
+ const args = browser.build_args(record)
610
+ if (record.cdp_ext) {
611
+ record.extension_path = path.resolve(
612
+ args['user-data-dir'] || path.join(this.base_path, record.name),
613
+ args['profile-directory'] || 'Default',
614
+ 'cdp_ext',
615
+ )
616
+ await fs.mkdir(record.extension_path, { recursive: true })
617
+ record.extension_path = await fs.realpath(record.extension_path)
618
+ 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))
625
+ await fs.copyFile(new URL('./cdp_ext/cdp_ext.js', import.meta.url), path.join(record.extension_path, 'cdp_ext.js'))
626
+ }
627
+ if (args['user-data-dir'] && record.update_preferences !== false) {
628
+ await browser.update_profile_preferences(path.join(args['user-data-dir'], args['profile-directory'] || 'Default', 'Preferences'), {
629
+ 'translate.enabled': record.translations === true,
630
+ 'signin.allowed': record.login === true,
631
+ 'signin.allowed_on_next_startup': record.login === true,
632
+ ...record.preferences,
633
+ })
634
+ if (Object.keys(record.local_state).length) {
635
+ await browser.update_profile_preferences(path.join(args['user-data-dir'], 'Local State'), record.local_state)
636
+ }
637
+ }
365
638
  const launched = await browser.launch(record)
366
639
  record.proc = launched.proc
640
+ if (record.cdp_ext) record.proc.once('exit', () => this._close_reverse_if_idle())
367
641
  url = launched.url
368
642
  }
369
643
  }
644
+ if (record.reverse || record.cdp_ext) {
645
+ const id = record.extension_id ?? record.name
646
+ url = this.reverse_sockets[id]
647
+ if (url?.readyState !== WebSocket.OPEN) {
648
+ url = (await util.on_first(this, 'reverse_' + encodeURIComponent(id), record.connect_timeout_ms ?? 15000)).args[0].detail
649
+ }
650
+ }
370
651
  const socket = record.socket = new jsrpc(url, record)
371
652
  record.target_info = {}
372
653
  record.session_targets = {}
@@ -380,7 +661,9 @@ export class cdp extends EventTarget {
380
661
  code: event.code,
381
662
  })
382
663
  })
383
- await util.on_first(socket, 'open error close', record.connect_timeout_ms ?? 15000)
664
+ if (socket.readyState !== WebSocket.OPEN) {
665
+ await util.on_first(socket, 'open error close', record.connect_timeout_ms ?? 15000)
666
+ }
384
667
  await socket.req({
385
668
  method: 'Target.setAutoAttach',
386
669
  params: {
@@ -393,21 +676,50 @@ export class cdp extends EventTarget {
393
676
  method: 'Target.setDiscoverTargets',
394
677
  params: { discover: true },
395
678
  })
679
+ if (Object.values(record.contexts).some(context => context.browserContextId)) {
680
+ const { browserContextIds } = await socket.req({ method: 'Target.getBrowserContexts' })
681
+ for (const context of Object.values(record.contexts)) {
682
+ if (!browserContextIds.includes(context.browserContextId)) context.browserContextId = null
683
+ }
684
+ }
396
685
  }
397
686
 
398
687
  async _target(browser, target) {
399
688
  const socket = browser.socket
689
+ const reverse = browser.reverse || browser.cdp_ext
690
+ if (target.incognito && !reverse && target.context == null && target.create_params?.browserContextId == null) {
691
+ target.context = 'incognito'
692
+ }
693
+ if (target.context != null && target.create_params?.browserContextId != null) {
694
+ throw new Error('Use target.context or create_params.browserContextId, not both')
695
+ }
696
+ const context = target.context == null ? null : await this._context(browser, target.context)
697
+ if (context) target.context = context.name
400
698
  if (!browser.target_info[target.targetId]) {
401
699
  target.targetId = (await socket.req({
402
700
  method: 'Target.createTarget',
403
701
  params: {
404
702
  url: 'about:blank',
405
703
  background: true,
704
+ browserContextId: context?.browserContextId,
705
+ ...(reverse && target.incognito ? { incognito: true } : {}),
406
706
  ...target.create_params,
407
707
  },
408
708
  })).targetId
709
+ if (context) (browser.target_info[target.targetId] ??= {}).browserContextId = context.browserContextId
409
710
  }
410
711
  const info = browser.target_info[target.targetId] ??= {}
712
+ if (context && info.browserContextId !== context.browserContextId) {
713
+ throw new Error('Existing target belongs to a different browser context')
714
+ }
715
+ if (reverse && target.incognito && !info.incognito) {
716
+ const { targetInfo } = await socket.req({
717
+ method: 'Target.getTargetInfo',
718
+ params: { targetId: target.targetId },
719
+ })
720
+ Object.assign(info, targetInfo)
721
+ if (!info.incognito) throw new Error('Existing target is not incognito')
722
+ }
411
723
  if (!info.sessionId) {
412
724
  info.sessionId = (await socket.req({
413
725
  method: 'Target.attachToTarget',
@@ -478,15 +790,25 @@ export class cdp extends EventTarget {
478
790
  ...this.options.preferences,
479
791
  ...spec.preferences,
480
792
  },
793
+ local_state: {
794
+ ...this.options.local_state,
795
+ ...spec.local_state,
796
+ },
481
797
  targets: Object.create(null),
798
+ contexts: Object.create(null),
799
+ }
800
+ if (target?.context != null && (record.reverse || record.cdp_ext)) {
801
+ throw new Error('Named browser contexts are unavailable through the reverse Chrome extension')
482
802
  }
483
803
  if (!record.connecting && record.socket?.readyState !== WebSocket.OPEN) {
484
804
  record.connecting = this._connect(record).catch(error => {
485
805
  record.socket?.close()
486
- record.proc?.kill()
806
+ if (!record.cdp_ext) record.proc?.kill()
487
807
  throw error
488
- }).finally(() => {
808
+ }).finally(async () => {
489
809
  record.connecting = null
810
+ await this._close_reverse_if_idle()
811
+ this._close_contexts_if_idle(record)
490
812
  })
491
813
  }
492
814
  await record.connecting
@@ -503,10 +825,15 @@ export class cdp extends EventTarget {
503
825
  if (!entry.sessionId) {
504
826
  entry.connecting ??= this._target(record, entry).finally(() => {
505
827
  entry.connecting = null
828
+ this._close_contexts_if_idle(record)
506
829
  })
507
830
  await entry.connecting
508
831
  }
509
832
  }
833
+ if (method === 'Target.disposeBrowserContext' && target == null) {
834
+ const context = Object.values(record.contexts).find(value => value.browserContextId === native.browserContextId)
835
+ if (context) return this._dispose_context(record, context, native)
836
+ }
510
837
  const result = await record.socket.req({
511
838
  method,
512
839
  params: native,
@@ -516,23 +843,39 @@ export class cdp extends EventTarget {
516
843
  if (method === 'Target.closeTarget' && result.success) {
517
844
  this._detach(record, record.target_info[native.targetId]?.sessionId)
518
845
  delete record.target_info[native.targetId]
846
+ await this._close_contexts_if_idle(record)
519
847
  }
520
848
  return result
521
849
  }
522
850
 
523
851
  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' })
852
+ try {
853
+ await Promise.all(Object.values(this.browsers).map(async browser => {
854
+ try {
855
+ await browser.connecting
856
+ browser.closing = true
857
+ await Promise.allSettled(Object.values(browser.targets).map(target => target.connecting))
858
+ await browser.context_checking
859
+ for (const context of Object.values(browser.contexts)) {
860
+ await context.closing
861
+ if (context.browserContextId && browser.socket?.readyState === WebSocket.OPEN) {
862
+ await this._dispose_context(browser, context)
863
+ }
864
+ }
865
+ if (browser.proc && !browser.cdp_ext && browser.socket?.readyState === WebSocket.OPEN) {
866
+ await browser.socket.req({ method: 'Browser.close' })
867
+ }
868
+ } finally {
869
+ browser.socket?.close()
870
+ browser.proc?.kill()
529
871
  }
530
- } finally {
531
- browser.socket?.close()
532
- browser.proc?.kill()
533
- }
534
- }))
535
- this.browsers = Object.create(null)
872
+ }))
873
+ } finally {
874
+ this.browsers = Object.create(null)
875
+ for (const socket of Object.values(this.reverse_sockets)) socket.close()
876
+ this.reverse_sockets = Object.create(null)
877
+ await this._close_reverse_if_idle()
878
+ }
536
879
  }
537
880
  }
538
881