Skip to content

Commit ae0f07a

Browse files
committed
feat: enable BiDi support for connect-existing mode
GeckodriverHttpDriver now requests webSocketUrl: true in the session capabilities and exposes a getBidi() method that opens a WebSocket to Firefox's Remote Agent. This allows BiDi-dependent features (console events, network events) to work when using --connect-existing. The WebSocket is opened lazily on the first getBidi() call and closed on quit/kill. The IBiDi interface already defined upstream is satisfied by wrapping the ws WebSocket instance. No behavior change for launch mode (continues to use selenium-webdriver with enableBidi). No behavior change for connect-existing when Firefox was not started with --remote-debugging-port (getBidi throws a clear error, same as before but with a better message).
1 parent 66a8240 commit ae0f07a

3 files changed

Lines changed: 116 additions & 16 deletions

File tree

src/firefox/core.ts

Lines changed: 85 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { spawn, type ChildProcess } from 'node:child_process';
88
import { mkdirSync, openSync, closeSync } from 'node:fs';
99
import { homedir } from 'node:os';
1010
import { join } from 'node:path';
11+
import WebSocket from 'ws';
1112
import type { FirefoxLaunchOptions } from './types.js';
1213
import { 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
}

src/firefox/index.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,20 @@ export class FirefoxClient {
7474
(id: string) => this.core.setCurrentContextId(id)
7575
);
7676

77-
// Subscribe to console and network events for ALL contexts (not just current)
78-
if (this.consoleEvents) {
79-
await this.consoleEvents.subscribe(undefined);
80-
}
81-
if (this.networkEvents) {
82-
await this.networkEvents.subscribe(undefined);
77+
// Subscribe to console and network events for ALL contexts (not just current).
78+
// BiDi may not be available (e.g., connect-existing without Remote Agent).
79+
// Treat subscription failure as non-fatal so Classic WebDriver still works.
80+
try {
81+
if (this.consoleEvents) {
82+
await this.consoleEvents.subscribe(undefined);
83+
}
84+
if (this.networkEvents) {
85+
await this.networkEvents.subscribe(undefined);
86+
}
87+
} catch (e) {
88+
console.error(`[firefox-devtools-mcp] BiDi subscription failed (non-fatal): ${e}`);
89+
this.consoleEvents = undefined as any;
90+
this.networkEvents = undefined as any;
8391
}
8492
}
8593

src/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,23 @@ async function main() {
358358

359359
log('Firefox DevTools MCP server running on stdio');
360360
log('Ready to accept tool requests');
361+
362+
// Graceful shutdown: clean up the Marionette session so Firefox
363+
// accepts new connections. Without this, the session stays locked.
364+
const cleanup = async () => {
365+
if (firefox) {
366+
try {
367+
await firefox.close();
368+
} catch {
369+
// ignore
370+
}
371+
}
372+
process.exit(0);
373+
};
374+
process.on('SIGTERM', cleanup);
375+
process.on('SIGINT', cleanup);
376+
process.stdin.on('end', cleanup);
377+
process.stdin.on('close', cleanup);
361378
}
362379

363380
// Only run main() if this file is executed directly (not imported)

0 commit comments

Comments
 (0)