181 lines
5.9 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
}
|