@@ -8,6 +8,7 @@ import { spawn, type ChildProcess } from 'node:child_process';
88import { mkdirSync , openSync , closeSync } from 'node:fs' ;
99import { homedir } from 'node:os' ;
1010import { join } from 'node:path' ;
11+ import WebSocket from 'ws' ;
1112import type { FirefoxLaunchOptions } from './types.js' ;
1213import { log , logDebug } from '../utils/logger.js' ;
1314
@@ -129,11 +130,14 @@ class GeckodriverHttpDriver implements IDriver {
129130 private baseUrl : string ;
130131 private sessionId : string ;
131132 private gdProcess : ChildProcess ;
133+ private webSocketUrl : string | null ;
134+ private bidiConnection : IBiDi | null = null ;
132135
133- constructor ( baseUrl : string , sessionId : string , gdProcess : ChildProcess ) {
136+ constructor ( baseUrl : string , sessionId : string , gdProcess : ChildProcess , webSocketUrl : string | null ) {
134137 this . baseUrl = baseUrl ;
135138 this . sessionId = sessionId ;
136139 this . gdProcess = gdProcess ;
140+ this . webSocketUrl = webSocketUrl ;
137141 }
138142
139143 static async connect ( marionettePort : number ) : Promise < GeckodriverHttpDriver > {
@@ -206,11 +210,11 @@ class GeckodriverHttpDriver implements IDriver {
206210
207211 const baseUrl = `http://127.0.0.1:${ port } ` ;
208212
209- // Create a WebDriver session
213+ // Create a WebDriver session with BiDi opt-in
210214 const resp = await fetch ( `${ baseUrl } /session` , {
211215 method : 'POST' ,
212216 headers : { 'Content-Type' : 'application/json' } ,
213- body : JSON . stringify ( { capabilities : { alwaysMatch : { } } } ) ,
217+ body : JSON . stringify ( { capabilities : { alwaysMatch : { webSocketUrl : true } } } ) ,
214218 } ) ;
215219 const json = ( await resp . json ( ) ) as {
216220 value : { sessionId : string ; capabilities : Record < string , unknown > } ;
@@ -219,7 +223,14 @@ class GeckodriverHttpDriver implements IDriver {
219223 throw new Error ( `Failed to create session: ${ JSON . stringify ( json ) } ` ) ;
220224 }
221225
222- return new GeckodriverHttpDriver ( baseUrl , json . value . sessionId , gd ) ;
226+ const wsUrl = json . value . capabilities . webSocketUrl as string | undefined ;
227+ if ( wsUrl ) {
228+ logDebug ( `BiDi WebSocket URL: ${ wsUrl } ` ) ;
229+ } else {
230+ logDebug ( 'BiDi WebSocket URL not available (Firefox may not support it or Remote Agent is not running)' ) ;
231+ }
232+
233+ return new GeckodriverHttpDriver ( baseUrl , json . value . sessionId , gd , wsUrl ?? null ) ;
223234 }
224235
225236 private async cmd ( method : string , path : string , body ?: unknown ) : Promise < unknown > {
@@ -422,6 +433,10 @@ class GeckodriverHttpDriver implements IDriver {
422433 }
423434
424435 async quit ( ) : Promise < void > {
436+ if ( this . bidiConnection ) {
437+ ( this . bidiConnection . socket as unknown as WebSocket ) . close ( ) ;
438+ this . bidiConnection = null ;
439+ }
425440 try {
426441 await this . cmd ( 'DELETE' , '' ) ;
427442 } catch {
@@ -430,13 +445,73 @@ class GeckodriverHttpDriver implements IDriver {
430445 this . gdProcess . kill ( ) ;
431446 }
432447
433- /** Kill the geckodriver process without closing Firefox */
434- kill ( ) : void {
448+ /** Kill the geckodriver process without closing Firefox.
449+ * Deletes the session first so Marionette accepts new connections. */
450+ async kill ( ) : Promise < void > {
451+ if ( this . bidiConnection ) {
452+ ( this . bidiConnection . socket as unknown as WebSocket ) . close ( ) ;
453+ this . bidiConnection = null ;
454+ }
455+ try {
456+ await this . cmd ( 'DELETE' , '' ) ;
457+ } catch {
458+ // ignore
459+ }
435460 this . gdProcess . kill ( ) ;
436461 }
437462
438- getBidi ( ) : Promise < IBiDi > {
439- throw new Error ( 'BiDi not available in connect-existing mode' ) ;
463+ /**
464+ * Return a BiDi handle. Opens a WebSocket to Firefox's Remote Agent on
465+ * first call, using the webSocketUrl returned in the session capabilities.
466+ */
467+ async getBidi ( ) : Promise < IBiDi > {
468+ if ( this . bidiConnection ) return this . bidiConnection ;
469+ if ( ! this . webSocketUrl ) {
470+ throw new Error (
471+ 'BiDi is not available: no webSocketUrl in session capabilities. ' +
472+ 'Ensure Firefox was started with --remote-debugging-port.'
473+ ) ;
474+ }
475+
476+ const ws = new WebSocket ( this . webSocketUrl ) ;
477+ await new Promise < void > ( ( resolve , reject ) => {
478+ ws . on ( 'open' , resolve ) ;
479+ ws . on ( 'error' , reject ) ;
480+ } ) ;
481+ logDebug ( 'BiDi WebSocket connected' ) ;
482+
483+ let cmdId = 0 ;
484+ const subscribe = async ( event : string , contexts ?: string [ ] ) : Promise < void > => {
485+ const msg : Record < string , unknown > = {
486+ id : ++ cmdId ,
487+ method : 'session.subscribe' ,
488+ params : { events : [ event ] } ,
489+ } ;
490+ if ( contexts ) msg . params = { events : [ event ] , contexts } ;
491+ ws . send ( JSON . stringify ( msg ) ) ;
492+ await new Promise < void > ( ( resolve , reject ) => {
493+ const timeout = setTimeout ( ( ) => reject ( new Error ( `BiDi subscribe timeout for ${ event } ` ) ) , 5000 ) ;
494+ const onMsg = ( data : WebSocket . Data ) => {
495+ try {
496+ const payload = JSON . parse ( data . toString ( ) ) ;
497+ if ( payload . id === cmdId ) {
498+ clearTimeout ( timeout ) ;
499+ ws . off ( 'message' , onMsg ) ;
500+ if ( payload . error ) {
501+ reject ( new Error ( `BiDi subscribe error: ${ payload . error } ` ) ) ;
502+ } else {
503+ resolve ( ) ;
504+ }
505+ }
506+ } catch { /* ignore parse errors from event messages */ }
507+ } ;
508+ ws . on ( 'message' , onMsg ) ;
509+ } ) ;
510+ logDebug ( `BiDi subscribed to ${ event } ` ) ;
511+ } ;
512+
513+ this . bidiConnection = { subscribe, socket : ws as unknown as IBiDiSocket } as any ;
514+ return this . bidiConnection ;
440515 }
441516}
442517
@@ -640,7 +715,7 @@ export class FirefoxCore {
640715 */
641716 reset ( ) : void {
642717 if ( this . driver && this . options . connectExisting && 'kill' in this . driver ) {
643- ( this . driver as { kill ( ) : void } ) . kill ( ) ;
718+ ( this . driver as { kill ( ) : Promise < void > } ) . kill ( ) ;
644719 }
645720 this . driver = null ;
646721 this . currentContextId = null ;
@@ -762,7 +837,7 @@ export class FirefoxCore {
762837 async close ( ) : Promise < void > {
763838 if ( this . driver ) {
764839 if ( this . options . connectExisting && 'kill' in this . driver ) {
765- ( this . driver as { kill ( ) : void } ) . kill ( ) ;
840+ await ( this . driver as { kill ( ) : Promise < void > } ) . kill ( ) ;
766841 } else if ( 'quit' in this . driver ) {
767842 await ( this . driver as { quit ( ) : Promise < void > } ) . quit ( ) ;
768843 }
0 commit comments