@mefistofelix/cdp.js 0.1.2 → 0.2.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 = url instanceof WebSocket ? url : super(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,68 @@ 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) throw new Error('The reverse WebSocket server requires Deno')
359
+ if (this.server) throw new Error('Reverse server is already listening')
360
+ this.server = Deno.serve({
361
+ hostname,
362
+ port,
363
+ }, request => {
364
+ const origin = request.headers.get('origin')
365
+ if (request.headers.get('upgrade') !== 'websocket' ||
366
+ !origin?.startsWith('chrome-extension://')) {
367
+ return new Response(null, { status: 403 })
368
+ }
369
+ const { socket, response } = Deno.upgradeWebSocket(request)
370
+ socket.addEventListener('message', event => {
371
+ const id = JSON.parse(event.data).browser
372
+ if (id !== origin.slice('chrome-extension://'.length) ||
373
+ this.reverse_sockets[id]?.readyState < WebSocket.CLOSING) {
374
+ return socket.close(1008, 'Invalid or already connected extension ID')
375
+ }
376
+ this.reverse_sockets[id] = socket
377
+ socket.addEventListener('close', () => {
378
+ if (this.reverse_sockets[id] === socket) delete this.reverse_sockets[id]
379
+ this._close_reverse_if_idle()
380
+ })
381
+ util.emit(this, 'reverse_' + encodeURIComponent(id), socket)
382
+ }, { once: true })
383
+ return response
384
+ })
385
+ this.reverse_server = {
386
+ hostname,
387
+ port: this.server.addr.port,
388
+ }
389
+ return this.server
390
+ }
391
+
392
+ _close_reverse_if_idle() {
393
+ if (this.server_closing) return this.server_closing
394
+ if (!this.server || Object.values(this.reverse_sockets).some(socket => socket.readyState < WebSocket.CLOSING)) return
395
+ for (const record of Object.values(this.browsers)) {
396
+ if (!record.reverse && !record.cdp_ext) continue
397
+ if (record.connecting) return
398
+ if (record.proc?.exitCode === null && record.proc.signalCode === null) return
399
+ }
400
+ return this.server_closing = this.server.shutdown().finally(() => {
401
+ this.server = null
402
+ this.server_closing = null
403
+ })
404
+ }
405
+
296
406
  evaluate_xpath(params, fn, options) {
297
407
  if (params.target == null) throw new Error('Custom method requires target')
298
408
  const xpath = util.normalize_xpath(params.xpath)
@@ -326,7 +436,8 @@ export class cdp extends EventTarget {
326
436
  browser: browser.name,
327
437
  }
328
438
  const sessionId = params.sessionId || message.sessionId
329
- const targetId = params.targetInfo?.targetId || params.targetId || browser.session_targets[sessionId]
439
+ const targetId = params.targetInfo?.targetId || params.targetId || browser.session_targets[sessionId] ||
440
+ (params.tabId >= 0 ? String(params.tabId) : undefined)
330
441
  const target = Object.values(browser.targets).find(value => value.targetId === targetId)
331
442
  if (params.targetInfo) {
332
443
  Object.assign(browser.target_info[targetId] ??= {}, params.targetInfo)
@@ -347,26 +458,152 @@ export class cdp extends EventTarget {
347
458
  if (['Target.targetDestroyed', 'Target.targetCrashed'].includes(message.method)) {
348
459
  this._detach(browser, info?.sessionId)
349
460
  delete browser.target_info[targetId]
461
+ this._close_contexts_if_idle(browser)
350
462
  }
351
463
  }
352
464
 
465
+ async _context(browser, spec) {
466
+ if (typeof spec === 'string') spec = { name: spec }
467
+ const context = browser.contexts[spec.name] ??= {
468
+ ...spec,
469
+ browserContextId: null,
470
+ }
471
+ await context.closing
472
+ if (!context.browserContextId) {
473
+ context.connecting ??= browser.socket.req({
474
+ method: 'Target.createBrowserContext',
475
+ params: {
476
+ proxyServer: context.proxy === false ? 'direct://' : context.proxy,
477
+ ...context.create_params,
478
+ },
479
+ }).then(result => {
480
+ context.browserContextId = result.browserContextId
481
+ }).finally(() => {
482
+ context.connecting = null
483
+ })
484
+ await context.connecting
485
+ }
486
+ return context
487
+ }
488
+
489
+ _dispose_context(browser, context, params = { browserContextId: context.browserContextId }) {
490
+ const id = context.browserContextId
491
+ return context.closing ??= browser.socket.req({
492
+ method: 'Target.disposeBrowserContext',
493
+ params,
494
+ }).then(result => {
495
+ context.browserContextId = null
496
+ for (const [targetId, info] of Object.entries(browser.target_info)) {
497
+ if (info.browserContextId !== id) continue
498
+ this._detach(browser, info.sessionId)
499
+ delete browser.target_info[targetId]
500
+ }
501
+ util.emit(this, 'context_closed', {
502
+ browser: browser.name,
503
+ context: context.name,
504
+ browserContextId: id,
505
+ })
506
+ return result
507
+ }).finally(() => {
508
+ context.closing = null
509
+ })
510
+ }
511
+
512
+ _close_contexts_if_idle(browser) {
513
+ if (browser.context_checking) {
514
+ browser.context_check_pending = true
515
+ return browser.context_checking
516
+ }
517
+ if (browser.connecting || browser.closing || browser.socket?.readyState !== WebSocket.OPEN) return
518
+ const contexts = Object.values(browser.contexts).filter(context =>
519
+ context.browserContextId && !context.connecting && !context.closing &&
520
+ !Object.values(browser.target_info).some(info =>
521
+ info.type !== 'browser_ui' && info.browserContextId === context.browserContextId,
522
+ ),
523
+ )
524
+ if (!contexts.length) return
525
+ const socket = browser.socket
526
+ return browser.context_checking = (async () => {
527
+ do {
528
+ browser.context_check_pending = false
529
+ const { targetInfos } = await socket.req({ method: 'Target.getTargets' })
530
+ if (browser.socket !== socket || browser.closing) return
531
+ for (const context of Object.values(browser.contexts)) {
532
+ if (!context.browserContextId || context.connecting || context.closing) continue
533
+ const belongs = info => info.type !== 'browser_ui' && info.browserContextId === context.browserContextId
534
+ if (targetInfos.some(belongs) || Object.values(browser.target_info).some(belongs)) continue
535
+ if (Object.values(browser.targets).some(target =>
536
+ target.connecting && (target.context?.name ?? target.context) === context.name,
537
+ )) continue
538
+ await this._dispose_context(browser, context)
539
+ }
540
+ } while (browser.context_check_pending)
541
+ })().catch(error => util.emit(this, 'error', {
542
+ browser: browser.name,
543
+ error,
544
+ })).finally(() => {
545
+ browser.context_checking = null
546
+ })
547
+ }
548
+
353
549
  async _connect(record) {
550
+ if (record.reverse || record.cdp_ext) {
551
+ await this.server_closing
552
+ if (!this.server) this.listen_reverse()
553
+ }
354
554
  let url = record.websocket_url
355
- if (!url && (record.http_url || record.port)) {
555
+ if (!record.reverse && !record.cdp_ext && !url && (record.http_url || record.port)) {
356
556
  const discovery = new URL(record.http_url || 'http://' + (record.host || '127.0.0.1') + ':' + record.port)
357
557
  if (!discovery.pathname.endsWith('/json/version')) discovery.pathname = discovery.pathname.replace(/\/$/, '') + '/json/version'
358
558
  const response = await fetch(discovery, { signal: AbortSignal.timeout(record.connect_timeout_ms ?? 15000) })
359
559
  if (!response.ok) throw new Error('CDP discovery: HTTP ' + response.status)
360
560
  url = (await response.json()).webSocketDebuggerUrl
361
561
  }
362
- if (!url) {
363
- if (record.proc?.exitCode === null && record.proc.signalCode === null) url = record.socket.url
562
+ if (record.cdp_ext || (!record.reverse && !url)) {
563
+ if (record.proc?.exitCode === null && record.proc.signalCode === null) url = record.socket?.url
364
564
  else {
565
+ const args = browser.build_args(record)
566
+ if (record.cdp_ext) {
567
+ record.extension_path = path.resolve(
568
+ args['user-data-dir'] || path.join(this.base_path, record.name),
569
+ args['profile-directory'] || 'Default',
570
+ 'cdp_ext',
571
+ )
572
+ await fs.mkdir(record.extension_path, { recursive: true })
573
+ record.extension_path = await fs.realpath(record.extension_path)
574
+ record.extension_id = browser.extension_id(record.extension_path)
575
+ const manifest = JSON.parse(await fs.readFile(new URL('./cdp_ext/manifest.json', import.meta.url), 'utf8'))
576
+ let host = this.server.addr.hostname
577
+ if (host === '0.0.0.0') host = '127.0.0.1'
578
+ if (host === '::') host = '::1'
579
+ manifest.cdp.websocket_url = 'ws://' + (host.includes(':') ? '[' + host + ']' : host) + ':' + this.server.addr.port
580
+ await fs.writeFile(path.join(record.extension_path, 'manifest.json'), JSON.stringify(manifest))
581
+ await fs.copyFile(new URL('./cdp_ext/cdp_ext.js', import.meta.url), path.join(record.extension_path, 'cdp_ext.js'))
582
+ }
583
+ if (args['user-data-dir'] && record.update_preferences !== false) {
584
+ await browser.update_profile_preferences(path.join(args['user-data-dir'], args['profile-directory'] || 'Default', 'Preferences'), {
585
+ 'translate.enabled': record.translations === true,
586
+ 'signin.allowed': record.login === true,
587
+ 'signin.allowed_on_next_startup': record.login === true,
588
+ ...record.preferences,
589
+ })
590
+ if (Object.keys(record.local_state).length) {
591
+ await browser.update_profile_preferences(path.join(args['user-data-dir'], 'Local State'), record.local_state)
592
+ }
593
+ }
365
594
  const launched = await browser.launch(record)
366
595
  record.proc = launched.proc
596
+ if (record.cdp_ext) record.proc.once('exit', () => this._close_reverse_if_idle())
367
597
  url = launched.url
368
598
  }
369
599
  }
600
+ if (record.reverse || record.cdp_ext) {
601
+ const id = record.extension_id ?? record.name
602
+ url = this.reverse_sockets[id]
603
+ if (url?.readyState !== WebSocket.OPEN) {
604
+ url = (await util.on_first(this, 'reverse_' + encodeURIComponent(id), record.connect_timeout_ms ?? 15000)).args[0].detail
605
+ }
606
+ }
370
607
  const socket = record.socket = new jsrpc(url, record)
371
608
  record.target_info = {}
372
609
  record.session_targets = {}
@@ -380,7 +617,9 @@ export class cdp extends EventTarget {
380
617
  code: event.code,
381
618
  })
382
619
  })
383
- await util.on_first(socket, 'open error close', record.connect_timeout_ms ?? 15000)
620
+ if (socket.readyState !== WebSocket.OPEN) {
621
+ await util.on_first(socket, 'open error close', record.connect_timeout_ms ?? 15000)
622
+ }
384
623
  await socket.req({
385
624
  method: 'Target.setAutoAttach',
386
625
  params: {
@@ -393,21 +632,50 @@ export class cdp extends EventTarget {
393
632
  method: 'Target.setDiscoverTargets',
394
633
  params: { discover: true },
395
634
  })
635
+ if (Object.values(record.contexts).some(context => context.browserContextId)) {
636
+ const { browserContextIds } = await socket.req({ method: 'Target.getBrowserContexts' })
637
+ for (const context of Object.values(record.contexts)) {
638
+ if (!browserContextIds.includes(context.browserContextId)) context.browserContextId = null
639
+ }
640
+ }
396
641
  }
397
642
 
398
643
  async _target(browser, target) {
399
644
  const socket = browser.socket
645
+ const reverse = browser.reverse || browser.cdp_ext
646
+ if (target.incognito && !reverse && target.context == null && target.create_params?.browserContextId == null) {
647
+ target.context = 'incognito'
648
+ }
649
+ if (target.context != null && target.create_params?.browserContextId != null) {
650
+ throw new Error('Use target.context or create_params.browserContextId, not both')
651
+ }
652
+ const context = target.context == null ? null : await this._context(browser, target.context)
653
+ if (context) target.context = context.name
400
654
  if (!browser.target_info[target.targetId]) {
401
655
  target.targetId = (await socket.req({
402
656
  method: 'Target.createTarget',
403
657
  params: {
404
658
  url: 'about:blank',
405
659
  background: true,
660
+ browserContextId: context?.browserContextId,
661
+ ...(reverse && target.incognito ? { incognito: true } : {}),
406
662
  ...target.create_params,
407
663
  },
408
664
  })).targetId
665
+ if (context) (browser.target_info[target.targetId] ??= {}).browserContextId = context.browserContextId
409
666
  }
410
667
  const info = browser.target_info[target.targetId] ??= {}
668
+ if (context && info.browserContextId !== context.browserContextId) {
669
+ throw new Error('Existing target belongs to a different browser context')
670
+ }
671
+ if (reverse && target.incognito && !info.incognito) {
672
+ const { targetInfo } = await socket.req({
673
+ method: 'Target.getTargetInfo',
674
+ params: { targetId: target.targetId },
675
+ })
676
+ Object.assign(info, targetInfo)
677
+ if (!info.incognito) throw new Error('Existing target is not incognito')
678
+ }
411
679
  if (!info.sessionId) {
412
680
  info.sessionId = (await socket.req({
413
681
  method: 'Target.attachToTarget',
@@ -478,15 +746,25 @@ export class cdp extends EventTarget {
478
746
  ...this.options.preferences,
479
747
  ...spec.preferences,
480
748
  },
749
+ local_state: {
750
+ ...this.options.local_state,
751
+ ...spec.local_state,
752
+ },
481
753
  targets: Object.create(null),
754
+ contexts: Object.create(null),
755
+ }
756
+ if (target?.context != null && (record.reverse || record.cdp_ext)) {
757
+ throw new Error('Named browser contexts are unavailable through the reverse Chrome extension')
482
758
  }
483
759
  if (!record.connecting && record.socket?.readyState !== WebSocket.OPEN) {
484
760
  record.connecting = this._connect(record).catch(error => {
485
761
  record.socket?.close()
486
- record.proc?.kill()
762
+ if (!record.cdp_ext) record.proc?.kill()
487
763
  throw error
488
- }).finally(() => {
764
+ }).finally(async () => {
489
765
  record.connecting = null
766
+ await this._close_reverse_if_idle()
767
+ this._close_contexts_if_idle(record)
490
768
  })
491
769
  }
492
770
  await record.connecting
@@ -503,10 +781,15 @@ export class cdp extends EventTarget {
503
781
  if (!entry.sessionId) {
504
782
  entry.connecting ??= this._target(record, entry).finally(() => {
505
783
  entry.connecting = null
784
+ this._close_contexts_if_idle(record)
506
785
  })
507
786
  await entry.connecting
508
787
  }
509
788
  }
789
+ if (method === 'Target.disposeBrowserContext' && target == null) {
790
+ const context = Object.values(record.contexts).find(value => value.browserContextId === native.browserContextId)
791
+ if (context) return this._dispose_context(record, context, native)
792
+ }
510
793
  const result = await record.socket.req({
511
794
  method,
512
795
  params: native,
@@ -516,23 +799,39 @@ export class cdp extends EventTarget {
516
799
  if (method === 'Target.closeTarget' && result.success) {
517
800
  this._detach(record, record.target_info[native.targetId]?.sessionId)
518
801
  delete record.target_info[native.targetId]
802
+ await this._close_contexts_if_idle(record)
519
803
  }
520
804
  return result
521
805
  }
522
806
 
523
807
  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' })
808
+ try {
809
+ await Promise.all(Object.values(this.browsers).map(async browser => {
810
+ try {
811
+ await browser.connecting
812
+ browser.closing = true
813
+ await Promise.allSettled(Object.values(browser.targets).map(target => target.connecting))
814
+ await browser.context_checking
815
+ for (const context of Object.values(browser.contexts)) {
816
+ await context.closing
817
+ if (context.browserContextId && browser.socket?.readyState === WebSocket.OPEN) {
818
+ await this._dispose_context(browser, context)
819
+ }
820
+ }
821
+ if (browser.proc && !browser.cdp_ext && browser.socket?.readyState === WebSocket.OPEN) {
822
+ await browser.socket.req({ method: 'Browser.close' })
823
+ }
824
+ } finally {
825
+ browser.socket?.close()
826
+ browser.proc?.kill()
529
827
  }
530
- } finally {
531
- browser.socket?.close()
532
- browser.proc?.kill()
533
- }
534
- }))
535
- this.browsers = Object.create(null)
828
+ }))
829
+ } finally {
830
+ this.browsers = Object.create(null)
831
+ for (const socket of Object.values(this.reverse_sockets)) socket.close()
832
+ this.reverse_sockets = Object.create(null)
833
+ await this._close_reverse_if_idle()
834
+ }
536
835
  }
537
836
  }
538
837