browser-remote — une vraie fenêtre, cliquable, sur un Chrome headless
Pourquoi j’ai construit une seule image Docker qui donne à un Chromium headless une vraie barre d’onglets et un écran en direct, interactif — sans browserless, sans VNC.
Mathieu
Fondateur, Spunto

J’automatise pas mal de navigation ces temps-ci — des scripts qui se connectent à des services, déroulent un parcours d’onboarding, récupèrent le contenu d’une page, ou vérifient simplement qu’un déploiement n’a pas cassé un formulaire quelque part. Chrome headless s’en sort très bien, jusqu’au moment où quelque chose se passe mal. Il ne reste alors qu’une stack trace et une capture d’écran prise trois étapes trop tard, et il faut reconstituer à quoi ressemblait vraiment la page à ce moment-là.
Ce que je voulais était beaucoup plus bête : une fenêtre de navigateur ordinaire, avec de vrais onglets, que je puisse regarder et dans laquelle je puisse cliquer quand j’en ai besoin. Pas une vidéo enregistrée, pas une pile de captures — une vue en direct, comme quand quelqu’un partage l’écran de son portable.
J’ai donc construit browser-remote : une image Docker, un port, un Chromium headless à l’intérieur, et une petite interface web qui donne à ce navigateur une barre d’onglets et un écran en direct. Vous ouvrez l’adresse dans votre propre navigateur, et c’est vous qui conduisez.
docker run -p 3000:3000 ghcr.io/spuntodotnet/browser-remote:latestOuvrez http://localhost:3000/, et vous avez un navigateur. C’est toute l’installation — pas de configuration, pas de compte, pas de visionneuse à installer à part.
Ou avec un docker-compose.yml, si vous le voulez à côté d’autres services :
services:
browser-remote:
image: ghcr.io/spuntodotnet/browser-remote:latest
ports:
- "3000:3000"Quelques variables d’environnement existent pour plus tard — une CA personnalisée pour tester des certificats HTTPS locaux (de mkcert, par exemple), un autre chemin vers le binaire Chrome, un drapeau anti-fingerprinting — toutes documentées dans le README.

Ce n’est pas une maquette — c’est la vraie interface, ouverte sur son propre dépôt GitHub, ce qui me semblait la chose à lui montrer pour une capture. Des onglets, précédent/suivant/recharger, une barre d’adresse où taper, et la page elle-même diffusée en direct pendant que vous interagissez avec.
Parce que je ne voulais pas trois pièces mobiles là où une suffit. La façon habituelle d’obtenir un navigateur headless « qu’on peut regarder », c’est soit :
- browserless ou équivalent — très bien, mais c’est un service de plus à faire tourner, avec ses propres idées sur le pooling de sessions et la licence, au-dessus du Chrome qu’il enveloppe.
- Un montage à base de VNC — un serveur X complet, plus un serveur VNC, plus un client web, pour diffuser ce qui revient à un bureau virtuel entier, juste pour regarder une fenêtre de navigateur.
Les deux fonctionnent. Les deux représentent aussi plus d’infrastructure que le problème n’en demande. Chrome a déjà un moyen intégré de diffuser exactement ses propres pixels et de recevoir les entrées en retour : le DevTools Protocol. browser-remote, c’est juste ça, branché directement : Chromium (celui sous licence BSD, apt install chromium, pas de binaire supplémentaire à qui faire confiance) plus un petit serveur Node, dans une seule image.
L’astuce centrale, c’est Page.startScreencast, une méthode CDP qui fait envoyer par Chrome une image JPEG à chaque fois que la page se redessine. Le serveur ouvre une connexion CDP vers l’onglet que vous regardez, relaie ces images par un WebSocket jusqu’à un <canvas> dans votre navigateur, et relaie la souris et le clavier dans l’autre sens avec Input.dispatchMouseEvent / Input.dispatchKeyEvent. Vu de l’extérieur, on dirait que la page s’affiche directement dans votre canvas — ce n’est pas le cas, c’est un screencast, mais l’aller-retour est assez rapide pour qu’on cesse de le remarquer.
La barre d’onglets est une fine couche au-dessus du même protocole : Target.getTargets pour la liste, Target.activateTarget quand vous cliquez sur un onglet, Target.closeTarget quand vous le fermez. Rien de tout cela n’a demandé de bibliothèque d’automatisation — ce sont des appels CDP sur un WebSocket que le serveur avait déjà ouvert.
DevTools a été la partie délicate. Chrome embarque son propre frontend DevTools et le sert volontiers, mais il code en dur l’hôte par lequel il s’attend à être joint — un iframe qui pointe vers /devtools/inspector.html marche donc très bien sur localhost:3000 et casse dès qu’on passe par un proxy ou un tunnel, parce que l’en-tête Host ne correspond plus à ce que Chrome a inscrit dans la page. La solution : une petite couche de reverse proxy devant le DevTools de Chrome et ses endpoints /json/*, qui réécrit le Host au passage — le même iframe marche alors aussi bien sur localhost que derrière trois proxys.

L’interface web est un client de la connexion CDP — rien n’empêche un script d’en être un autre. Comme /json/* et /devtools/* sont relayés tels quels jusqu’à Chrome, un script Puppeteer peut s’attacher exactement au navigateur que montre la barre d’onglets, ouvrir des pages et les piloter directement :
import puppeteer from "puppeteer-core";
const CHROME_URL = "http://localhost:3000"; // or wherever the container is reachable
const { webSocketDebuggerUrl } = await (await fetch(`${CHROME_URL}/json/version`)).json();
const browserWSEndpoint = `${CHROME_URL.replace(/^http/, "ws")}${new URL(webSocketDebuggerUrl).pathname}`;
const browser = await puppeteer.connect({ browserWSEndpoint });
const page = await browser.newPage();
await page.goto("https://example.com");
await page.screenshot({ path: "out.png" });
await browser.disconnect(); // detach — leave Chrome (and the container) runningDeux pièges qui m’ont coûté un peu de temps :
- Ne passez pas
browserURLdirectement àpuppeteer.connect(). Le/json/versionde Chrome renvoie son adresse WebSocket interne (ws://127.0.0.1:9222/...), qui n’a de sens que depuis l’intérieur du conteneur.puppeteer.connect({ browserURL })lit ce champ et essaie de s’y connecter directement, ce qui échoue dès que vous n’êtes pas littéralement surlocalhost. Récupérer/json/versionvous-même et y mettre l’hôte que vous utilisez réellement — l’extrait ci-dessus — fait passer le WebSocket par le même proxy que tout le reste, réécriture duHostcomprise. browser.close()n’est pasbrowser.disconnect(). Puppeteer part du principe que c’est lui qui a lancé le navigateur auquel il parle :.close()envoie doncBrowser.closeet tue le processus Chrome — tous les onglets, y compris ceux ouverts dans l’interface web..disconnect()détache seulement le client de votre script. Puisque vous empruntez ici le Chrome de quelqu’un d’autre,.close()n’est presque jamais ce que vous voulez.
L’avantage de partager une seule connexion CDP entre l’interface et un script : un onglet ouvert par votre script apparaît tout de suite dans la barre d’onglets, et un onglet ouvert à la main n’est qu’une entrée de plus dans browser.pages(). Le même navigateur, deux façons de tenir le volant — on passe de l’une à l’autre en pleine session sans perdre cookies ni état.
La plupart du temps, il tourne à côté de l’agent ou du script qui automatise, comme une seconde fenêtre vers laquelle je peux basculer. Au lieu d’un script Puppeteer qui échoue en silence et me laisse deviner, j’ai une vraie page ouverte, dans un vrai onglet, où je peux cliquer et finir à la main si l’automatisation reste bloquée — même session, mêmes cookies, sans redémarrer. Et quand un script se comporte mal, DevTools est à un clic, sur la page exacte qui pose problème, pas sur une reconstitution.
C’est un petit outil. C’est un peu le but : il fait une chose (vous laisser regarder et piloter un navigateur headless) sans vous demander de monter une ferme de navigateurs pour y arriver.
Le dépôt est ici : spuntodotnet/browser-remote. Issues et PR bienvenues.
Mathieu
Fondateur, SpuntoConstruit Spunto — des environnements de développement auto-hébergés, pour les ingénieurs qui veulent que leurs machines travaillent pour eux, pas contre eux.