package-lock : les mauvaises pratiques à bannir

javascript

Si vous avez déjà travaillé sur un projet en Node.js, JavaScript ou TypeScript, il y a de fortes chances que vous ayez installé des dépendances tierces avec npm. Vous savez probablement déjà comment fonctionne le fichier package.json, mais connaissez-vous tous les secrets du package-lock.json, ce fichier généré automatiquement par npm dès qu'on lance un npm install ?

Ce fichier contient des infos sur les versions exactes des packages et de leurs dépendances qui ont été installées, ce qui vous permet de garantir la stabilité de votre application en évitant les problèmes de version. Mais attention, il faut savoir comment utiliser correctement ce fichier et éviter certaines erreurs courantes pour en profiter pleinement.

Y’a quoi dans le package-lock ?

Le package-lock.json (ou package-lock) est un fichier contenant une représentation à un instant t de l’arbre de dépendances d’un projet JavaScript. Il est généré automatiquement par npm, et contient les informations suivantes pour chaque dépendance et sous-dépendance (de façon recursive):

  • Version exacte
  • URL de téléchargement
  • Hash d’intégrité
  • D’autres trucs que je ne vais pas expliquer dans cet article pour éviter de sortir du sujet.

L’intérêt du package-lock est le suivant: avoir une représentation déterministe du dossier node_modules sans avoir à commiter le node_modules

Donc oui, si certains se posaient encore la question, il faut commiter package-lock.json, puisque c'est ce fichier qui va permettre de reconstituer exactement le même dossier node_modules à chaque fois que la commande npm install est lancée.

Ce fichier est complémentaire au package.json, puisque les deux contiennent des versions des dépendances à installer. Ce qui soulève la question suivante:

Comment npm fait-il pour gérer à la fois un package.json et un package-lock.json ?

Comme dit précédemment, le package-lock.json contient des versions exactes de toutes les dépendances, et de leurs sous-dépendances, et ainsi de suite.

Et le package.json alors ? Il ne contient pas vraiment des versions, mais plutôt des "fourchettes de versions", et ce uniquement pour les dépendances directes. En gros, il permet de spécifier des choses du genre "je veux une version de prettier qui commence par 2., et toutes les sous-dépendances associées".

💡 Rappel syntaxique pour les fourchettes de versions:

1.2.3 → uniquement la version 1.2.3

~1.2.3 → toutes les versions 1.2.X ≥ 1.2.3. Exemples: 1.2.6, 1.2.99

^1.2.3 (par défaut lors du npm install <truc>) → toutes les versions 1.X.Y ≥ 1.2.3, Exemples: 1.2.8, 1.8.99 ,1.33.520

Pour savoir quelle version de quelle dépendance directe installer lors d’un npm install, npm va lire à la fois le package.json et le package-lock.json puis appliquera un comportement qui varie selon les cas.

Je vais vous montrer deux petits exemples qui contiennent à la fois un package.json et un package-lock.json, et vous verrez quel sera le comportement de npm lors d'un npm install. Si vous êtes joueurs, vous pouvez essayer de deviner 😉

Cas n°1 (facile): Les versions des deux fichiers correspondent

//  package.json{  "dependencies": {    "prettier": "^2.2.1"  }}// package-lock.json{  "requires": true,  "lockfileVersion": 1,  "dependencies": {    "prettier": {      "version": "2.5.0",      "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.5.0.tgz",      "integrity": "sha512-FM/zAKgWTxj40rH03VxzIPdXmj39SwSjwG0heUcNFwI+EMZJnY93yAiKXM3dObIKAM5TA88werc8T/EwhB45eg=="    }  }}

Que fait npm install ?

Réponse: npm installe la version 2.5.0, et c'est tout. Le package-lock est respecté à la lettre, car cette version rentre bien dans la fourchette définie par ^2.2.1.

Cas n°2 (plus compliqué): Les versions des deux fichiers ne correspondent pas

//  package.json{  "dependencies": {    "prettier": "^2.1.0"  }}// package-lock.json{  "requires": true,  "lockfileVersion": 1,  "dependencies": {    "prettier": {      "version": "1.19.1",      "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz",      "integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew=="    }  }}

Que fait npm install ?

Réponse: npm installe la 2.8.1 (la version la plus récente qui correspond à la fourchette ^2.1.0!), puis remplace le package-lock.json par:

    {
      "requires": true,
      "lockfileVersion": 1,
      "dependencies": {
        "prettier": {
          "version": "2.8.1",
          "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.1.tgz",
          "integrity": "sha512-PkUpF+qoXTqhOeWL9fu7As8LXsIUZ1WYaJiY/a7McAQzxjk82OF0tibkFXVCDImZtWxbvojFjerkiLb0/q8mew=="
        }
      }
    }

Et oui, l'information contenue dans le package-lock d'origine est complètement ignorée, et quand npm voit que les versions ne correspondent pas, il lance un npm install prettier@^2.1.0, qui va pointer vers la dernière version existante commençant par un 2..

Ce npm install va écraser le package-lock existant car une nouvelle dépendance a été installée.

Variantes:

  • Si le package-lock n'est pas présent dans le package-lock (par exemple si c'est une nouvelle dépendance), le comportement sera exactement le même, et des lignes seront ajoutées au package-lock existant.
  • Si le package-lock est absent de votre machine ou est illisible, le comportement sera encore une fois le même, et un nouveau package-lock sera généré par npm.

Cet exemple illustre parfaitement une erreur très courante dans la gestion des dépendances

En voulant mettre à jour une dépendance, on modifie le numéro de version dans le package.json sans réfléchir à la version qui sera réellement installée.

Dans notre exemple, il pourrait s'agit d'un développeur qui remplacerait ^1.19.0 par ^2.1.0 dans le package.json en ignorant que c'est en réalité la 2.8.1 qui sera installée.

Le cas du npm ci

Ce paragraphe sort un peu du sujet, mais je trouve qu'il est intéressant de parler d'une commande quasi similaire au npm install: le npm ci.

💡 Fun fact: npm ci est une commande recommandée pour les scripts d’intégration continue, mais le "ci" est un diminutif pour npm clean-install et non pas "continuous integration"

npm ci fait à peu près la même chose que npm install, à quelques exceptions près:

  • Si les version du package.json et du package-lock ne correspondent pas (ou version manquante): le script échoue.
  • Ne modifie jamais le package-lock
  • Vide le dossier node_modules avant l’exécution

Si vous utilisez encore npm install dans vos scripts d'intégration continue, le remplacer par un npm ci permettra de détecter de potentielles mauvaises pratiques (par exemple un développeur qui aurait modifé son package.json sans lancer npm install dans la foulée).

Les mauvaises pratiques à bannir pour un projet stable

Maintenant que vous connaissez un peu mieux le comportement de npm dans les différents cas, voici quelques cas pratiques inspirés de situations réelles.

Dans chacun des cas, la réflexion initiale (marquée par un ⛔) n'est pas la bonne, et vient généralement d'une méconnaissance du fonctionnement de npm.

Mise en situation #1: conflits dans le package-lock

J’ai plein de conflits sur mon package-lock depuis que j’ai mis des dépendances à jour dans ma branche 😢

OK pas de panique j’ai une super idée pour repartir d’une base propre !

⛔ Je supprime le package-lock et je relance npm install. Problème réglé, j'ai un fichier tout propre et sans conflits !

En faisant un npm install sans package-lock, ça met à jour toutes les dépendances du projet, en sautant potentiellement plusieurs versions mineures.

Problems coming soon !

A la place, je peux juste repartir d’un package-lock potentiellement un peu vieux mais qui reflète vraiment les packages qu’on avait à un instant t

Par exemple, si je suis sur une branche qui part de master, je peux récupérer le dernier package-lock de master: git checkout origin/master package-lock.json

Ensuite, je peux lancer un npm install sereinement. Seuls les nouveaux modules vont être rajoutés au package-lock.

Mise en situation #2: mise à jour de dépendance inutile

Oh mince y’a écrit dans le package.json qu’on utilise awesome-package ^2.3.4 mais j'ai lu sur internet qu'il y a une grosse faille de sécurité dans cette version il nous faut au moins la 2.5.0 !

⛔ Pas de problème, j’écris ^2.5.0 à la place puis je lance npm install, ça va mettre le module à jour et régler le problème

En regardant dans le package-lock, j’aurais pu voir que j’avais déjà awesome-package@2.6.0 et que le problème ne se posait donc pas.

D’ailleurs, en faisant npm install, mon lock n’a pas changé, la preuve que ma modification n’a absolument rien changé dans mes dépendances.

Pro tip: npm ls <nom-du-package> permet de voir la ou les versions installées d'une dépendance en particulier

npm ls exemple

Mise en situation #3: mise à jour de dépendance non maitrisée

Tiens, je vois qu'on utilise la version 1.3.0 de some-util-library, mais d'après ce que j'ai lu sur GitHub y'a un patch correctif sur le bug qu'on a remarqué la semaine dernière, il faut juste passer à la version 1.3.2

⛔ Facile, j'ouvre le ficher package.json et je remplace ^1.3.0 par ^1.3.2, puis je lance npm install, ça va mettre la librairie à jour et régler le problème !

En gardant la syntaxe ^1.y.z, j'ai demandé à npm d'installer la dernière version de some-util-library commençant par un 1.

Peut-être que je viens d'installer sans faire exprès la version 1.18.3, qui n'est pas du tout retro-compatible avec mon projet (et oui, les maintainers de modules npm ne respectent pas toujours les règles du semantic versioning !)

Si j'avais voulu installer spécifiquement la version 1.3.2, j'aurais pu enlever le ^ devant le numéro de version, voire le remplacer par un ~ pour autoriser uniquement les patchs correctifs.

Conclusion

Voilà, j'espère que vous avez appris des trucs intéressants, et que vous ne ferez plus jamais un npm install sans comprendre ce que cela implique.

Je conclus cet article avec quelques bonnes pratiques générales, n'hésitez pas à commenter ci-dessous si vous voyez des points à ajouter:

  1. Ne jamais faire un npm install sans package-lock.json ou avec un fichier illisible (avec un marqueur de conflit par exemple)
  2. Prendre l'habitude d'utiliser la commande npm ls plutôt que d'aller lire le contenu du  package.json et du package-lock.json
  3. Toujours se questionner si votre pull request ou celle d'un collègue comporte un diff de package-lock:
    1. Si le package.json n’a pas changé, ça sent la mauvaise pratique dans 99% des cas (le 1% restant correspond à un npm audit fix). Vous devriez faire un revert de ce fichier.
    2. Si une dépendance a été rajoutée ou mise à jour, le diff doit faire quelques dizaines de lignes maximum ! Si vous voyez des centaines de différences, ça sent encore une fois la mauvaise pratique.