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(); 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 { 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( method: string, params: Record = {}, timeoutMs = 30_000, ): Promise { if (!this.isOpen) return Promise.reject(new Error('Canal BiDi indisponible')); const id = this.nextId++; return new Promise((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; try { message = JSON.parse(raw) as Record; } 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 { 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); }); }); }