Files
stream-control/packages/agent/src/bidi.ts
jeanotx32 77bfa0b8c5
All checks were successful
release / build (push) Successful in 25s
release / verify-windows (push) Successful in 1m15s
REVAMP : Firefox handling
2026-08-12 01:46:14 +02:00

181 lines
5.9 KiB
TypeScript

import { EventEmitter } from 'node:events';
import { WebSocket } from 'ws';
/**
* Client WebDriver BiDi minimal.
*
* Firefox expose ce protocole dès qu'on le lance avec `--remote-debugging-port`
* : pas de geckodriver, pas de Selenium, une simple WebSocket JSON. On n'en
* implémente que le transport — corrélation requête/réponse et distribution des
* évènements — parce que les quatre commandes dont l'agent a besoin
* (`session.new`, `browsingContext.navigate`, `input.performActions`,
* `script.evaluate`) ne justifient pas une dépendance de plus dans un bundle
* qui se télécharge à chaque mise à jour.
*
* Intérêt décisif ici : ce canal parle à Firefox, pas au serveur d'affichage.
* Il fonctionne donc identiquement en session Wayland native, où xdotool ne
* voit rien.
*/
interface Pending {
resolve(value: unknown): void;
reject(error: Error): void;
timer: NodeJS.Timeout;
}
/** Réponse d'erreur du protocole : `error` est un code, `message` du texte. */
interface BidiError {
error: string;
message?: string;
stacktrace?: string;
}
export class BidiClient extends EventEmitter {
private nextId = 1;
private readonly pending = new Map<number, Pending>();
private closing = false;
private constructor(private readonly socket: WebSocket) {
super();
socket.on('message', (raw) => this.dispatch(raw.toString()));
socket.on('close', () => this.fail(new Error('Canal BiDi fermé par Firefox')));
socket.on('error', (err: Error) => this.fail(err));
}
get isOpen(): boolean {
return !this.closing && this.socket.readyState === WebSocket.OPEN;
}
/**
* Se connecte au remote agent, en retentant jusqu'à `timeoutMs`.
*
* La boucle n'est pas de la superstition : Firefox ouvre son port plusieurs
* secondes après le `spawn`, et le délai varie du simple au décuple selon que
* le profil est neuf ou déjà chaud. Sans réessai, le premier enregistrement
* après un démarrage de VM échouerait systématiquement.
*/
static async open(port: number, timeoutMs: number, giveUp?: () => string | null): Promise<BidiClient> {
const deadline = Date.now() + timeoutMs;
let lastError = 'aucune tentative';
for (;;) {
try {
return new BidiClient(await handshake(port, 4000));
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
}
// Attendre la fin du délai quand le processus est déjà mort ne renseigne
// personne : ça ne fait que retarder de 45 s un diagnostic déjà connu.
const abandon = giveUp?.();
if (abandon) throw new Error(abandon);
if (Date.now() >= deadline) {
throw new Error(
`Aucune réponse BiDi sur 127.0.0.1:${port} après ${Math.round(timeoutMs / 1000)} s ` +
`(${lastError}). Firefox a-t-il bien démarré avec --remote-debugging-port ?`,
);
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
/**
* Envoie une commande et attend son résultat.
*
* Le délai n'est pas une commodité : une navigation vers une page qui ne
* finit jamais de charger laisserait sinon la promesse en suspens pour
* toujours, et avec elle la séquence de capture.
*/
send<T = unknown>(
method: string,
params: Record<string, unknown> = {},
timeoutMs = 30_000,
): Promise<T> {
if (!this.isOpen) return Promise.reject(new Error('Canal BiDi indisponible'));
const id = this.nextId++;
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`Commande « ${method} » sans réponse après ${timeoutMs} ms`));
}, timeoutMs);
timer.unref?.();
this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timer });
this.socket.send(JSON.stringify({ id, method, params }));
});
}
close(): void {
this.closing = true;
this.fail(new Error('Canal BiDi fermé par l\'agent'));
this.socket.close();
}
private dispatch(raw: string): void {
let message: Record<string, unknown>;
try {
message = JSON.parse(raw) as Record<string, unknown>;
} catch {
return; // trame illisible : rien de mieux à faire que l'ignorer
}
if (message.type === 'event') {
this.emit('bidi-event', message.method as string, message.params);
return;
}
const entry = this.pending.get(message.id as number);
if (!entry) return;
this.pending.delete(message.id as number);
clearTimeout(entry.timer);
if (message.type === 'success') {
entry.resolve(message.result);
} else {
const error = message as unknown as BidiError;
entry.reject(new Error(`${error.error}${error.message ? ` : ${error.message}` : ''}`));
}
}
/** Rejette tout ce qui attend encore : une socket morte ne répondra jamais. */
private fail(err: Error): void {
for (const [id, entry] of this.pending) {
clearTimeout(entry.timer);
entry.reject(err);
this.pending.delete(id);
}
if (!this.closing) {
this.closing = true;
this.emit('closed', err);
}
}
}
/** Une tentative de connexion, résolue seulement si la socket s'ouvre. */
function handshake(port: number, timeoutMs: number): Promise<WebSocket> {
return new Promise((resolve, reject) => {
// 127.0.0.1 explicitement, jamais « localhost » : sur une machine où celui-ci
// résout d'abord en ::1, la connexion échouerait alors que Firefox écoute.
const socket = new WebSocket(`ws://127.0.0.1:${port}/session`, {
handshakeTimeout: timeoutMs,
});
const cleanup = () => {
socket.removeAllListeners('open');
socket.removeAllListeners('error');
};
socket.once('open', () => {
cleanup();
resolve(socket);
});
socket.once('error', (err: Error) => {
cleanup();
socket.close();
reject(err);
});
});
}