REVAMP : Firefox handling
This commit is contained in:
180
packages/agent/src/bidi.ts
Normal file
180
packages/agent/src/bidi.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user