Aller au contenu

Vendoriser plutôt qu'importer : un comparateur Leaflet sans dépendance

🔓 Besoin d'un comparateur « avant/après » en Leaflet ? leaflet-side-by-side tire underscore, postcss et 17 vulnérabilités pour rogner deux images. Downgrader, forker en v0.0.7 ou vendoriser : l'arbre de décision, et comment 90 lignes de clip-path ont tout réglé.

Illustration d'un arbre de dépendances en alerte rouge « 17 vulnérabilités » face à un fichier unique de 90 lignes sans dépendance, coché vert, à côté d'un comparateur de carte à rideau.
Vendoriser plutôt qu'importer un comparateur Leaflet

Deux époques d'une même ville, séparées par un rideau que l'on fait glisser, et un npm audit qui passe au rouge pour ça.

J'avais besoin d'un classique de la dataviz cartographique : un comparateur « avant / après », ce rideau vertical que l'on glisse pour révéler deux couches côte à côte. Sur ChronoMEL, ma visionneuse d'orthophotos de la métropole lilloise, il sert à confronter deux millésimes : 1930 à gauche, 2025 à droite, au même endroit, dans le même onglet.

Le réflexe, c'est npm install. Il existe un plugin pour ça : leaflet-side-by-side. Je l'installe. Et npm audit s'allume comme un sapin de Noël. Voici l'enquête sur le coût réel d'une micro-dépendance, et pourquoi j'ai fini par tout réécrire à la main, pour un meilleur résultat.

Le déclencheur

found 17 vulnerabilities (3 moderate, 9 high, 5 critical)

Dix-sept vulnérabilités pour un composant qui, au fond, superpose deux images et les rogne. Le chiffre paraît absurde, il l'est. Mais il ne sort pas de nulle part : il est la somme d'un arbre de dépendances dont je n'avais jamais vu les branches. Et c'est là tout le sujet : on installe un arbre, on hérite d'une forêt.

L'anatomie de l'arbre

leaflet-side-by-side, c'est environ 200 lignes utiles. Sauf qu'il dépend de cssify et css-img-datauri-stream, de l'outillage de l'ère browserify, dont le seul rôle est d'inliner deux fichiers CSS au moment du build. Et ces deux-là tirent à leur tour :

  • underscore (dans une version porteuse d'une faille critique) ;
  • postcss (une branche ancienne, plusieurs CVE) ;
  • mime, elliptic, et quelques autres reliques transitives.
leaflet-side-by-side
└─ cssify
   └─ css-img-datauri-stream
      ├─ underscore         ← critique
      ├─ postcss@5          ← ancien
      └─ mime, elliptic…

Toute cette dette de sécurité ne sert qu'à transformer deux .css en chaînes de caractères. Pour un composant dont le cœur fonctionnel, la logique du rideau, ne dépend de rien d'autre que de Leaflet lui-même. Le ratio valeur / surface d'attaque est catastrophique : on importe une dizaine de paquets transitifs pour économiser… une dizaine de lignes de CSS. Quand on ne peut pas couper une branche comme celle-ci (parce qu'elle appartient à une vraie dépendance qu'on garde), le champ overrides de npm permet de forcer la version d'un paquet transitif sans attendre que l'amont publie le correctif.

L'arbre de décision

Devant un npm audit rouge, on a rarement une seule porte de sortie. J'en avais trois, par ordre d'effort croissant :

Trois issues face à un npm audit rouge : downgrader (API 0.7 abandonnée), changer de fork (faux ami en v0.0.7), vendoriser (0 dépendance, 0 vuln)
Trois issues face à un npm audit rouge
  1. Downgrader vers leaflet-side-by-side@1.1.0, antérieur à cssify. Échec : cette version vise l'API Leaflet 0.7, abandonnée depuis des années. On troquerait une faille de sécurité contre une incompatibilité d'API.
  2. Changer de fork pour une version « maintenue » : leaflet-swipe-mode, qui ne dépend que de leaflet. Sur le papier, l'option idéale.
  3. Vendoriser : réécrire le composant soi-même, dans le dépôt.

L'option 2 semblait la plus raisonnable : pourquoi réinventer la roue ? C'était un piège.

Le faux ami

J'ai donc tenté le fork « propre » d'abord. Mauvaise surprise sur toute la ligne. Le readme annonce une API L.control.sideBySide, mais le code expose en réalité L.Control.swipeMode : une heure perdue à chercher pourquoi rien ne s'affichait. Le paquet dépend aussi implicitement de window.L, ce qui devient fragile dès qu'on passe par un bundler ESM où Leaflet n'est pas un global ; il a fallu écrire un shim pour le réexposer. Et la version affichée, 0.0.7, avec une faible adoption et un dernier commit lointain, correspond exactement au profil de paquet qui finira abandonné à son tour.

Je me suis alors demandé si remplacer une micro-dépendance abandonnée par une autre micro-dépendance en v0.0.7 était vraiment un gain de robustesse, ou juste un déplacement du risque d'un cran, en attendant la prochaine alerte. Pour un composant aussi central à l'application, je voulais une réponse durable.

La décision : vendoriser

J'ai écrit le composant de zéro avec une IA, sans repartir des plugins existants. Le cœur du sujet (superposer deux couches, les rogner, suivre le séparateur) tient en ~90 lignes, sans aucune dépendance hors Leaflet. À ce niveau de simplicité, l'écrire soi-même devient plus sûr que d'importer. Le calcul a un coût : ces ~90 lignes, c'est désormais à moi de les maintenir, de les tester, de suivre les évolutions de l'API Leaflet. Il ne tient que parce que le composant est petit, stable et bien compris. Voyons comment.

Comprendre clip-path (le ressort du composant)

Tout repose sur clip-path. Elle rogne la zone visible d'un élément selon une forme, ici un simple rectangle exprimé comme un polygone à quatre points :

.couche { clip-path: polygon(x1 y1, x2 y2, x3 y3, x4 y4); }

Tout ce qui sort du polygone devient invisible, mais l'élément, lui, garde sa taille et sa position. C'est exactement ce qu'il faut ici : afficher une moitié d'une couche de carte sans la déplacer ni la redimensionner. On applique deux clip-path complémentaires (gauche et droite) qui se rejoignent pile sur le séparateur. C'est simple, et surtout performant : le navigateur ne recompose pas la géométrie, il masque.

Deux couches carte superposées, rognées par deux clip-path: polygon() complémentaires qui se rejoignent sur le séparateur vertical à la position X
Le rognage des deux couches par clip-path: polygon()

clip-path remplace l'ancienne propriété clip (dépréciée, retirée des spécifications modernes, et qui n'acceptait qu'un rectangle sur un élément positionné en absolu). clip-path gère des formes arbitraires : cercles, polygones, et un équivalent rectangulaire dédié, inset(). Pour un rectangle aligné sur les axes de l'écran, deux syntaxes sont possibles. Elles ne mesurent pas leur rectangle de la même façon : polygon() prend des positions absolues pour chaque coin, alors qu'inset() prend des distances de retrait depuis chacun des bords opposés. Comme containerPointToLayerPoint (plus bas) fournit justement des positions absolues, polygon() les consomme directement, alors qu'inset() demanderait de les convertir en soustrayant à la largeur et à la hauteur du conteneur, à chaque déplacement. polygon() reste donc l'écriture la plus directe ici, tout en restant l'outil moderne et parfaitement supporté par les navigateurs.

Le principe : clip-path + containerPointToLayerPoint

Pour rester aligné pendant les déplacements et les zooms, on convertit les coins de la fenêtre en layer points (le repère interne de Leaflet, qui suit la carte), puis on recalcule les deux polygones à chaque mouvement :

_update() {
  const nw = this._map.containerPointToLayerPoint([0, 0]);
  const se = this._map.containerPointToLayerPoint(this._map.getSize());
  const clipX = nw.x + this._dividerX();
  this._divider.style.left = `${this._dividerX()}px`;
  this._left.getContainer().style.clipPath  = `polygon(${nw.x}px ${nw.y}px, ${clipX}px ${nw.y}px, ${clipX}px ${se.y}px, ${nw.x}px ${se.y}px)`;
  this._right.getContainer().style.clipPath = `polygon(${clipX}px ${nw.y}px, ${se.x}px ${nw.y}px, ${se.x}px ${se.y}px, ${clipX}px ${se.y}px)`;
}

Le séparateur déplaçable

Astuce qui évite de réinventer le drag : un <input type="range"> plein écran, piste transparente, seule la poignée reste visible. On écoute l'événement input pour redessiner, et (détail crucial) on désactive le drag de la carte pendant la manipulation, sinon glisser le rideau déplace aussi le fond :

this._range.addEventListener('mousedown',  () => this._map.dragging.disable());
this._range.addEventListener('touchstart', () => this._map.dragging.disable());
document.addEventListener('mouseup',  () => this._map.dragging.enable());

Le CSS de la poignée

La poignée circulaire et ses chevrons sont injectés en data:image/svg+xml : zéro asset externe, donc rien à inliner, donc plus aucune raison d'avoir cssify dans l'arbre. La cause même des 17 vulnérabilités disparaît. Penser à styliser ::-webkit-slider-thumb et ::-moz-range-thumb : les deux moteurs ne partagent pas le même pseudo-élément.

Brancher / débrancher proprement

Un composant Leaflet propre se nettoie après lui :

  • sur l'événement move de la carte → appeler _update() ;
  • dans remove() → annuler les clip-path, retirer les écouteurs et le DOM ajouté.

Sans ce ménage, on laisse des écouteurs actifs à chaque branchement/débranchement de la carte, le genre de fuite invisible qui finit par ramer après quelques navigations. Sur ChronoMEL, ce composant se branche via le hook onReady de leaflet-atlas, la carto pilotée par la config qui porte le reste de l'application.

Les pièges à connaître

  • Même couche des deux côtés : le second clip-path écrase le premier, on ne voit qu'une moitié. Il faut bien deux objets de couche distincts.
  • z-index et panes : le rideau doit passer au-dessus des tuiles, mais sous les contrôles Leaflet, sinon on masque les boutons de zoom.
  • Ordre des points de polygon() : les quatre coins doivent être donnés dans le même sens de parcours (ici horaire, en partant du coin haut-gauche). Un point inversé et le polygone se croise au lieu de dessiner un rectangle net.

Conclusion

Une balance : plateau gauche, un fichier unique et deux compteurs à zéro sous le label vendoriser ; plateau droit, trois caisses de vraies dépendances sous le label installer.
Vendoriser ou installer : un arbitrage composant par composant

Le vrai problème, c'est l'arbre que leaflet-side-by-side traîne avec lui : une dépendance embarque tout ce qu'elle tire derrière, au-delà de son seul index.js. Vendoriser reste un choix ponctuel, à juger composant par composant plutôt qu'à appliquer en principe général. Pour une vraie dépendance (un parseur de projections, un moteur de tuiles vectorielles), recopier serait de l'inconscience : on hériterait de la complexité sans l'expertise ni les correctifs de l'amont. Pour un composant minuscule, stable, qu'on comprend entièrement, la question à se poser est simple : suis-je capable de tenir ce code seul ? Si oui, recopier bat souvent installer, pour ~90 lignes qui deviennent 0 dépendance, 0 vulnérabilité.

Rien n'oblige non plus à trancher dès le premier npm install. On peut très bien commencer par la dépendance, et ne vendoriser que le jour où le besoin se fait sentir, comme ici avec npm audit qui s'allume. Vendoriser n'est pas une décision qu'on prend au premier commit : c'est une option qui reste disponible tant que le composant reste petit.

Le reste de l'arbre garde ses règles habituelles : lire le résultat de npm audit au lieu de le subir, committer le lockfile, grouper les mises à jour Dependabot pour ne pas se noyer sous les PR. Une autre histoire de CVE héritée, celle de github-slug-action, pour une prochaine fois.

La suite revient à la carte elle-même : donner une date à chaque objet pour qu'il n'apparaisse qu'à partir de son millésime.

Dernier