~/blog / extension-chrome-10-plateformes
⚙️ craft & architecture · extension Chrome multi-plateformes

Une extension qui s'injecte sur 10+ plateformes sans en casser une seule

Luc Del Beato 11 juin 2026 11 min de lecture

Sur une extension Chrome multi-plateformes, j'ai dû la faire tenir sur 10+ plateformes (messagerie, CRM, productivité : Gmail, Slack, et bien d'autres). Dix apps, dix DOM incompatibles, dix modèles d'événements, et la même promesse à chaque fois : lire le texte que l'utilisateur est en train d'écrire et injecter notre UI sans rien casser. Voici l'architecture qui a rendu ça gérable plutôt qu'ingérable.

TL;DR

Le vrai problème : dix apps qui ne se ressemblent en rien

Sur le papier, l'extension fait une chose simple : repérer le champ où l'utilisateur compose un message, lire son contenu, et afficher à côté un petit bouton qui déclenche une analyse. Sauf que « le champ où l'on compose » n'a aucune définition commune d'une plateforme à l'autre.

Gmail utilise une div contenteditable dans une fenêtre de composition qui apparaît à la demande. Slack a son propre éditeur Quill. LinkedIn lazy-load la zone de saisie bien après le reste de la page. Outlook web change de DOM selon que vous lisez ou répondez. Zendesk, Teams, HubSpot, Notion… chacun a sa structure, ses classes CSS générées, son modèle d'événements. Écrire un if par plateforme dans un seul fichier, c'était la garantie d'un cauchemar de 4000 lignes ingérable au troisième ajout.

La bonne abstraction transforme « 10 intégrations bordéliques » en « 1 pipeline + 10 adaptateurs fins ». Tout le travail d'architecture consiste à trouver cette frontière.

Pilier 1, Le Strategy pattern, agnostique au canal

La pièce maîtresse, c'est un registre qui associe chaque domaine (via une regex sur l'URL) à une classe Channel. Le service worker en arrière-plan détecte l'URL de l'onglet actif, identifie le canal correspondant, et envoie un message INIT_CHANNEL au content script. Celui-ci instancie alors la bonne classe, et seulement celle-là.

Chaque Channel hérite d'une interface abstraite commune (AbstractChannel / AbstractChannelInstance) qui force chaque plateforme à implémenter le même contrat : getText, getSubject, getImages, setButton, startObserver. Une fois ce contrat respecté, tout le reste de l'application est rigoureusement identique, quelle que soit la plateforme. Le pipeline d'analyse, l'UI, la télémétrie : ils ne parlent qu'à l'interface, jamais à Gmail ou à Slack directement.

// registre : la seule chose qui connaît les plateformes
const CHANNELS = [
  { match: /mail\.google\.com/,      Channel: GmailChannel },
  { match: /linkedin\.com/,          Channel: LinkedInChannel },
  { match: /app\.slack\.com/,        Channel: SlackChannel },
  { match: /outlook\.(live|office)/, Channel: OutlookChannel },
  { match: /\.zendesk\.com/,         Channel: ZendeskChannel },
  // … teams, hubspot, notion …
];

// contrat que CHAQUE plateforme doit honorer
class AbstractChannelInstance {
  getText()      { throw new Error('not implemented'); }
  getSubject()   { throw new Error('not implemented'); }
  getImages()    { throw new Error('not implemented'); }
  setButton(el)  { throw new Error('not implemented'); }
  startObserver(onChange) { throw new Error('not implemented'); }
}

// background : détecte l'URL → demande l'init du bon canal
const hit = CHANNELS.find(c => c.match.test(tab.url));
if (hit) chrome.tabs.sendMessage(tab.id, { type: 'INIT_CHANNEL', name: hit.Channel.name });

// content script : instancie, le reste de l'app ignore TOUT du domaine
const channel = new (resolve(msg.name))();
pipeline.run(channel);   // pipeline.run() est identique pour les 10 plateformes
💡
Le gain réel : ajouter une 11ᵉ plateforme, ce n'est plus toucher au cœur de l'app. C'est écrire une classe de 80 lignes qui implémente cinq méthodes, et ajouter une ligne au registre. Le risque de régression sur les dix autres tombe à zéro, parce qu'on ne touche littéralement à rien d'autre.

Pilier 2, Shadow DOM, la seule isolation viable en territoire hostile

Injecter un bouton dans Gmail, c'est entrer chez quelqu'un qui n'attend pas de visite. Leur CSS est agressif : des sélecteurs globaux, des !important, des resets qui écrasent tout. Si on injecte un simple <div> dans leur arbre, notre UI est défigurée en quelques secondes, et inversement, notre CSS risque de casser leur mise en page.

La parade : tout ce qu'on injecte (boutons, surlignages, panneaux) vit dans un Shadow root attaché via attachShadow. Le CSS de la page hôte ne franchit pas la frontière du shadow, et le nôtre non plus. C'est une bulle étanche, par design du navigateur. Aucune guerre de spécificité, aucun !important défensif, aucune surprise quand la plateforme refait son thème.

// une bulle CSS étanche, dans les deux sens
const host  = document.createElement('div');
host.style.cssText = 'position:absolute; pointer-events:none;'; // l'enveloppe laisse passer les clics
const shadow = host.attachShadow({ mode: 'open' });

shadow.innerHTML = `
  <style>/* notre CSS, totalement isolé du site */</style>
  <div class="overlay">
    <button class="logo" /* cliquable */ ></button>
  </div>`;

// le logo, lui, redevient cliquable
shadow.querySelector('.logo').style.pointerEvents = 'all';
document.body.appendChild(host);

Le détail qui change tout, c'est l'astuce pointer-events. Notre overlay flotte au-dessus de la zone de composition. Si on le laisse intercepter les clics, l'utilisateur ne peut plus écrire : chaque clic atterrit sur notre couche au lieu du champ éditable. La solution : le conteneur est en pointer-events:none (les clics le traversent comme s'il n'existait pas), et seul le logo cliquable repasse en pointer-events:all. Résultat : l'UI est parfaitement visible, mais elle ne vole jamais le focus du champ de saisie.

⚖️
Pourquoi pas une iframe ? Une iframe offre une isolation encore plus forte, mais elle est trop forte. Depuis une iframe, impossible d'accéder aux text ranges de la page hôte, donc impossible de lire le composer ou de positionner précisément un surlignage dans leur texte. Le Shadow DOM isole le style sans couper l'accès au DOM de la page. C'est exactement le compromis qu'il nous faut.

Pilier 2 bis : surligner du texte dans le champ de l'autre, le vrai cauchemar

C'est la partie de l'extension dont je suis le plus fier, et de loin la plus pénible à construire. Le produit doit surligner des mots et des phrases (une formulation à risque, une faute, un passage à reformuler) directement à l'endroit où l'utilisateur les a écrits, dans le composer de Gmail, de Slack ou de LinkedIn. L'idée naïve consiste à envelopper le texte fautif dans un <mark> ou un <span style="background">. C'est un piège, et il se referme vite.

Un champ contenteditable n'est pas un bout de HTML neutre : c'est la vue d'un modèle interne que l'éditeur tient à jour. Si vous injectez un nœud au milieu, vous corrompez ce modèle. Concrètement : vous coupez un nœud texte en deux, ce qui décale la position du curseur de l'utilisateur (il se met à taper au mauvais endroit) ; vous déclenchez un input ou un beforeinput que l'éditeur interprète comme une frappe (donc une entrée dans l'historique d'annulation, parfois un appel réseau de sauvegarde) ; et sur un éditeur comme le Quill de Slack, votre <span> étranger est purement et simplement supprimé au prochain cycle de rendu, parce qu'il ne fait pas partie du modèle que Quill connaît. On ne touche jamais au DOM du champ éditable. Jamais.

La solution est un overlay : on ne colore pas le texte, on dessine des rectangles translucides par-dessus les mots, dans le Shadow root, sans toucher d'un cheveu au texte de l'utilisateur. Le texte réel reste intact dans le champ ; nos rectangles flottent à la même position, comme un calque. Ce calque est produit par deux classes : un Ranger qui calcule se trouve chaque occurrence, et un Highlighter qui transforme ces positions en <div> absolus dans le shadow.

Du caractère au pixel : l'abstraction Ranger

Pour surligner « phrase à risque », il faut d'abord la localiser dans le DOM. Un champ riche n'est pas une chaîne plate : c'est un arbre de nœuds texte, entrecoupés de <span>, de <br>, d'emojis-images. Le Ranger parcourt ce sous-arbre avec un TreeWalker filtré sur SHOW_TEXT, collecte tous les nœuds texte, puis fait un indexOf du mot recherché pour le mapper sur un couple (nœud texte, offset de caractère). À partir de là, un Range natif (setStart / setEnd) matérialise la sélection, et range.getClientRects() me donne sa géométrie réelle à l'écran, telle que le navigateur l'a effectivement rendue.

Le piège le plus retors, c'est le retour à la ligne. Une même occurrence peut s'étaler sur deux lignes : « phrase à » en fin d'une ligne, « risque » au début de la suivante. Un seul rectangle engloberait tout le bloc, en débordant horriblement à droite et à gauche. La parade dans Ranger._createRangesFromIndexes : on avance mot par mot dans la sélection et on compare le bottom du rectangle courant à celui du mot suivant. Dès que les bottom diffèrent, c'est qu'on a changé de ligne : on coupe la plage à cet endroit et on en démarre une nouvelle. Une occurrence sur trois lignes produit donc trois plages, donc trois rectangles bien alignés sur chaque ligne.

// Ranger : un mot → une (ou plusieurs) plages, en suivant le wrapping réel
function rangesForWord(node, start, end, word) {
  const ranges = [];
  const range = new Range();
  range.setStart(node, start);
  range.setEnd(node, end);
  let rect = range.getClientRects().item(0);

  let lineStart = start, cursor = start;
  for (const piece of word.split(' ')) {
    const probe = new Range();
    probe.setStart(node, cursor);
    cursor += piece.length;
    probe.setEnd(node, cursor);
    const probeRect = probe.getClientRects().item(0);

    // un bottom différent = changement de ligne = on coupe ici
    if (rect && probeRect && rect.bottom !== probeRect.bottom) {
      ranges.push({ node, startOffset: lineStart, endOffset: cursor - piece.length });
      lineStart = cursor - piece.length;
      range.setStart(node, lineStart);
      rect = range.getClientRects().item(0);
    }
    cursor++; // l'espace
  }
  ranges.push({ node, startOffset: lineStart, endOffset: cursor });
  return ranges; // une plage par ligne visuelle
}

Du Range au calque : les rectangles dans le Shadow DOM

Une fois les plages connues, le Highlighter les peint. Pour chacune, il reconstruit un Range, lit son rectangle via getClientRects().item(0), puis crée un <div class="highlightedItem"> en position:absolute dans le content du Shadow root. Ces div portent un fond translucide (opacity:0.3), un border-radius et un mix-blend-mode:darken pour se fondre proprement sur n'importe quelle couleur de texte. Et c'est là que l'isolation du Shadow DOM, si confortable pour le style, complique la géométrie.

getClientRects() renvoie des coordonnées dans le repère du viewport, alors que mes div sont positionnés dans le repère du conteneur Shadow. Il faut donc convertir : pour chaque rectangle de plage, je soustrais le getBoundingClientRect() de l'élément éditable, ce qui me donne un top et un left relatifs au calque. Oublier cette soustraction (ou la faire par rapport au mauvais ancêtre), et tous les surlignages dérivent de quelques dizaines de pixels, pile assez pour rater le mot. C'est le bug le plus fréquent et le plus pénible à diagnostiquer de toute cette mécanique.

// Highlighter : une plage → un rectangle absolu dans le Shadow root
const hostRect = editable.getBoundingClientRect();   // ancre du repère

for (const r of ranges) {
  const range = document.createRange();
  range.setStart(r.node, r.startOffset);
  range.setEnd(r.node, r.endOffset);
  const rect = range.getClientRects().item(0);
  if (!rect) continue;

  const mark = document.createElement('div');
  mark.className = 'highlightedItem';
  mark.style.position      = 'absolute';
  // viewport → repère du calque : on soustrait l'ancre
  mark.style.top           = (rect.top  - hostRect.top)  + 'px';
  mark.style.left          = (rect.left - hostRect.left) + 'px';
  mark.style.height        = rect.height + 'px';
  mark.style.width         = rect.width  + 'px';
  mark.style.opacity       = '0.3';
  mark.style.borderRadius  = '5px';
  mark.style.mixBlendMode  = 'darken';
  mark.style.pointerEvents = 'none';   // jamais voler le clic au champ

  shadow.content.appendChild(mark);    // dans le Shadow root, pas dans le champ
}

Le pointer-events:none n'est pas un détail cosmétique : c'est ce qui permet à la couche de surlignage de flotter pile au-dessus du texte sans jamais intercepter un clic. L'utilisateur clique « à travers » nos rectangles et atterrit dans le champ, à l'endroit exact qu'il visait. Sans ça, surligner un mot reviendrait à rendre ce mot incliquable, ce qui est exactement l'inverse de ce qu'on veut.

Rester synchronisé pendant que l'utilisateur tape et scrolle

Un calque calculé une fois est faux la milliseconde suivante. L'utilisateur tape, le texte se reflow ; il scrolle dans le composer, et tous nos rectangles, ancrés au viewport, glissent hors position. Comme on ne vit pas dans le DOM de l'éditeur, aucun reflow ne déplace nos div automatiquement : il faut les recalculer nous-mêmes. La synchronisation combine donc trois signaux. Un heartbeat (un setInterval léger, autour de 250 ms sur les éditeurs nerveux comme Notion) qui relance le rendu ; un MutationObserver sur le champ pour réagir aux frappes, en prenant soin d'ignorer ses propres mutations (les nœuds <ext-overlay> qu'il ajoute, sinon il s'auto-déclenche en boucle) ; et un écouteur de scroll sur le composer qui efface puis redessine la couche.

À chaque cycle, la séquence est la même : on vide le content du Shadow root de tous ses anciens rectangles, on relance le Ranger sur le texte courant, on repeint. Effacer et redessiner intégralement est plus simple et plus robuste que de tenter de déplacer chaque rectangle individuellement, et comme tout se passe dans un Shadow root détaché du flux de la page, ce thrash n'invalide jamais la mise en page de l'hôte. Un debounce évite de tout recalculer à chaque caractère.

// Garder le calque synchronisé : effacer + redessiner sur 3 signaux
function repaint() {
  shadow.content.replaceChildren();          // on jette les anciens rectangles
  const ranges = ranger.getRanges(currentText());
  paintHighlights(ranges);                    // on recalcule depuis zéro
}

const beat = setInterval(repaint, 250);       // heartbeat : reflow implicite

const obs = new MutationObserver((muts) => {  // frappes de l'utilisateur
  // ignorer NOS propres nœuds, sinon boucle infinie
  if (muts[0]?.addedNodes[0]?.tagName === 'EXT-OVERLAY') return;
  debouncedRepaint();
});
obs.observe(editable, { childList: true, characterData: true, subtree: true });

editable.addEventListener('scroll', debouncedRepaint); // le scroll déplace tout
😈
Les pièges par éditeur : sur Gmail, les emojis sont des <img data-emoji> et non du texte ; un emoji composé (drapeau, famille) est plusieurs images sœurs qu'il faut recoller en mesurant chaque clientWidth pour étendre le rectangle. Sur Slack, même problème avec des <img data-id=":nom:">, plus la couche Quill qui éclate le texte dans son modèle interne. Sur LinkedIn, les mentions sont des span.ql-mention à traiter comme des blocs insécables plutôt que comme du texte parcourable. Chaque plateforme a donc son propre Ranger dérivé (GmailRanger, SlackRanger, LinkedInRanger) qui surcharge la localisation, pendant que le Highlighter, lui, reste rigoureusement identique partout.

Pilier 3, Heartbeat pour découvrir, MutationObserver pour réagir

Vient ensuite la question du quand. Sur Gmail, la zone de composition peut apparaître à tout moment quand l'utilisateur clique sur « Nouveau message ». Sur LinkedIn, elle est lazy-loadée plusieurs secondes après le chargement. Sur d'autres, elle est déjà là au démarrage. Il n'existe aucun événement fiable et commun pour dire « le composer est prêt ».

On utilise donc deux mécanismes distincts, pour deux problèmes distincts. D'abord un heartbeat : un timer qui, toutes les ~500 ms, appelle checkIfAnalyzable(), une méthode propre à chaque Channel qui tente de localiser le composer dans le DOM courant. C'est un polling assumé, parce qu'on cherche quelque chose qui peut apparaître à tout moment et qu'aucun événement ne nous préviendra.

Une fois, et seulement une fois, le composer trouvé, on arrête de poller dessus et on attache un MutationObserver ciblé sur ce nœud précis. Là, on n'est plus dans la découverte mais dans la réaction : à chaque frappe, l'observer se déclenche, et un debounce évite de relancer une analyse à chaque caractère. La règle que j'en retire est devenue un réflexe : on poll pour découvrir, on observe pour réagir.

// 1) DÉCOUVRIR : heartbeat, car aucun event ne signale l'apparition
const beat = setInterval(() => {
  const node = channel.checkIfAnalyzable();   // propre à chaque plateforme
  if (node) {
    clearInterval(beat);                      // trouvé → on arrête de poller
    attach(node);
  }
}, 500);

// 2) RÉAGIR : observer ciblé + debounce sur les frappes
function attach(node) {
  const obs = new MutationObserver(debounce(() => {
    pipeline.onTextChanged(channel.getText());
  }, 300));
  obs.observe(node, { childList: true, characterData: true, subtree: true });
}

Pilier 4, Le piège Manifest V3 : un service worker qui meurt

Manifest V3 a remplacé la page d'arrière-plan persistante par un service worker éphémère. Conséquence brutale : Chrome le tue après environ 5 minutes d'inactivité. Tout état qu'on y stocke, y compris une connexion socket ouverte, disparaît avec lui. Pour une extension qui veut suivre l'utilisateur en continu sur une plateforme, c'est un changement d'architecture imposé.

La conséquence concrète : pas de socket persistant côté background, et surtout, l'état du canal ne vit pas dans le service worker. Il vit dans le content script, qui lui reste en vie tant que l'onglet est ouvert. Le service worker redevient ce qu'il devrait être : un routeur stateless qui détecte l'URL et déclenche des messages, jamais une mémoire de session.

Le corollaire, c'est qu'il faut gérer proprement les changements d'onglet. Quand l'utilisateur passe de Gmail à Slack, le service worker peut très bien avoir été réveillé de zéro entre-temps. On ré-initialise donc le canal à chaque changement d'onglet pertinent, plutôt que de supposer qu'un état précédent a survécu. Idempotent par construction.

Pilier 5, Idempotence : reclics sans doublons

Dernier piège, plus subtil : l'utilisateur clique sur le bouton d'analyse, ne voit rien arriver dans la demi-seconde, et reclique. Sans protection, on lance deux analyses identiques, on double les appels API, et on risque d'afficher deux fois le résultat.

La solution est minimale : à chaque déclenchement, le client génère un instanceId (un nanoid) qui identifie cette analyse précise. Tant que la même instance est en vol, un reclic est ignoré ; le résultat est rattaché à son instanceId d'origine. C'est une idempotence côté client, pas côté serveur, suffisante ici, parce que la source du doublon, c'est le doigt de l'utilisateur, pas une retransmission réseau.

Les quirks par plateforme : là où le diable se cache

L'architecture rend le problème gérable, mais chaque Channel reste un petit chantier d'adaptation aux bizarreries locales. Quelques exemples vécus :

C'est exactement pour ça que le Strategy pattern paie : toute cette saleté est encapsulée dans getText() de chaque Channel. Le reste de l'application reçoit une chaîne propre et n'a pas la moindre idée du calvaire qui a permis de l'obtenir.

Ce que je retiens

Ce raisonnement, isoler la complexité derrière une interface stable, choisir le bon mécanisme pour le bon problème, épouser les contraintes plutôt que les combattre, c'est la même mentalité que j'applique aux systèmes temps réel. D'ailleurs, l'analyse déclenchée par ces composers devait remonter un résultat en moins de 200 ms pour rester fluide ; j'en parle en détail dans cet article sur le sentiment en temps réel sous 200 ms.

// architecture

Un produit complexe à architecturer ? Parlons-en.

Extension cross-plateforme, agent IA en production, système temps réel : si vous avez un produit où la complexité doit rester gérable sur la durée, c'est exactement le genre de problème que j'aime prendre en main.

Discutons-en
L
Luc Del Beato

Senior Lead Engineer, ~20 ans de web. Do-er passionné de résolution de problèmes, de belle architecture et d'automatisation ; les agents IA, c'est ma direction. Mon parcours →