Package NPM universel ou comment construire un package qu'on pourra utiliser partout ?

cover

Récemment dans le cadre de ma mission j'ai eu besoin de construire une librairie qu'on pourrait utiliser sous forme de package dans un projet frontend (donc s'éxécutant sur le navigateur), mais aussi en node, en TypeScript et JavaScript. Évidemment c'était trop simple, donc j'avais aussi besoin de créer un bundle qu'on pourrait directement utiliser via une balise script dans une page html, et supporter Internet Explorer 11…

Beaucoup d'éléments, je m'y suis cassé les dents avant d'arriver à quelque chose de totalement fonctionnel.

La cible

J'ai lu pas mal de doc, fait pas mal de test, et j'en suis arrivé à builder plusieurs fois ma librairie en plaçant chaque build dans un dossier différent.

Globalement ça donne ça en termes de hiérarchie :

lib/
  cjs/             => pour nodejs
  es5/             => pour IE
  esm/             => pour les autres navigateurs + node en es module
  types/           => pour l'utilisation avec TypeScript
  lib.js           => pour utiliser dans le browser et avoir du debug
  lib.min.js       => pour utiliser dans le browser en mode production
  lib.min.js.map   => pour avoir du debug quand on est en mode production

Mon but est aussi d'être le plus transparent possible à l'usage, ne rien avoir à configurer pour utiliser la librairie avec TypeScript, ne rien avoir à configurer quand on fait du nodejs, ne rien avoir à configurer quand on fait de l'ES Module. Pour IE ça me dérange pas de devoir un peu plus spécifier de chose, car c'est une configuration un peu obsolète (mais j'ai un cas particulier qui m'oblige à supporter IE).

Les builds via tsc

configurations partagées

Avant de parler des différentes configurations, je vous conseille de découper votre configuration comme je l'ai fait : avoir un tsconfig.base.json qui contient toutes les configurations communes (rootDir, baseUrl, etc.). Ce fichier, on le complétera avec des tsconfig plus spécialisés.

En particulier dans mon tsconfig.base.json j'ai ceci :

{
    "compilerOptions": {
        "esModuleInterop": true,
        "declaration": true,
        "declarationMap": false,
        "declarationDir": "./lib/types",
        "sourceMap": false,
        "importHelpers": true,
        "moduleResolution": "node",
    },
    "exclude": [
        "node_modules",
        "lib",
        "scripts",
        "tests",
    ],
    "include": [
        "src/"
    ],
}

importHelpers définie à true n'est pas du tout obligatoire, mais ça permet d'avoir un bundle un peu plus petit, sans vraiment d'inconvénient à part installer tslib, donc autant l'ajouter

configurations ES Module

Pour le cas des ES Module, j'ai écrit un fichier tsconfig.esm.json :

{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "target": "es2018",
    "outDir": "lib/esm",
    "module": "esnext",
  }
}

configurations Common Module

Pour le cas de nodejs sans ES Module, j'ai écrit un fichier tsconfig.cjs.json :

{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "target": "es2018",
    "outDir": "lib/cjs",
    "module": "commonjs",
  }
}

À noter que pour les deux précédents fichiers, j'ai choisi es2018 comme target pour avoir un peu de marge sur le support navigateur et en termes de version de nodejs, mais on pourrait mettre es2020 je pense dans beaucoup de cas.

configurations pour IE

Pour IE, le fichier tsconfig.es5.json est un peu plus compliqué, car il faut spécifier quelques lib à embarquer (attention à bien regarder ce dont vous avez besoin spécifiquement, dans mon cas ce que j'ai indiqué m'a suffi) et demander un peu de réécriture de code :

{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "target": "es5",
    "outDir": "lib/es5",
    "module": "commonjs",
    "downlevelIteration": true,
    "lib": [
      "dom",
      "es5",
      "scripthost",
      "es2015"
    ]
  }
}

configurations pour VSCode / jest

Comme j'utilise VSCode, j'ai aussi besoin d'un tsconfig.json pour avoir de l'aide directement dans l'éditeur. J'ai repris le contenu du fichier tsconfig.cjs.json. On pourrait se passer de ce fichier et faire un peu de configuration VSCode, mais je trouve ça plus intuitif de ne rien avoir à configurer quand on commence à travailler sur un projet.

J'ai aussi ajouté un fichier tsconfig.jest.json. Ici c'est un fichier qui ne dépend pas du fichier tsconfig.base.json car ts-jest semblait ne pas tirer la configuration complète.

Quelques script dans le package.json

Côté package.json, j'ai ajouté quelques éléments pour que tout fonctionne sur un simple npm run build.

Déjà on aura besoin d'installer typescript (pour avoir le compilateur tsc) et rimraf (pour nettoyer le dossier lib avant de commencer à construire une nouvelle version).

Ensuite j'ai ajouté quelques scripts :

{
    "scripts": {
        "build:clean": "rimraf ./lib",
        "build:esm": "tsc -p ./tsconfig.esm.json",
        "build:cjs": "tsc -p ./tsconfig.cjs.json",
        "build:es5": "tsc -p ./tsconfig.es5.json",
        "build": "npm run build:clean && npm run build:esm && npm run build:cjs && npm run build:es5",
    }
}

La manière dont j'ai construit les scripts permet de lancer chaque étape individuellement, en plus de tout lancer d'un coup. C'est très pratique pour du débug de configuration.

À cette étape, on peut lancer la commande npm run build et avoir directement des builds spécialisés.

Comment choisir le bon build automatiquement ?

Ici c'est une étape très simple mais qui ne se devine pas. On peut donner des informations sur notre package pour indiquer à nodejs comment il doit résoudre nos packages. On peut aussi donner des indications au compilateur TypeScript pour lui dire où aller chercher nos types.

Pour ça il faut ajouter 3 lignes dans le package.json :

{
    "main": "./lib/cjs/index.js",
    "module": "./lib/esm/index.js",
    "types": "./lib/types/index.d.ts",
}

Ces 3 lignes vont permettre à nodejs et tsc de résoudre les bons fichiers en fonction du contexte d'utilisation de notre package. C'est relativement magique, donc autant en profiter.

Vous noterez par contre qu'il n'y a aucune référence à la version es5, pour cette version, il faudra faire l'import sous la forme import { thing } from 'mylib/lib/es5'; à la place de juste import { thing } from 'mylib'; dans les autres cas, on ne peut avoir de la magie sur tout non plus 😉

À cette étape vous avez tout ce qu'il vous faut pour publier votre librairie sur npm !

Faire des bundles prêt à l'emploi

Dans mon cas j'avais besoin d'un bundle prêt à l'emploi que je pourrais juste récupérer via une balise script. J'ai donc dégainé mon ami Webpack.

Les packages

Dans mon cas je n'ai pas besoin de grand-chose comme package :

  • webpack : le package de base pour webpack (en version 5.x dans mon cas)
  • webpack-cli : le wrapper CLI de webpack
  • ts-loader : pour charger des sources en TypeScript
  • source-map-loader : pour avoir des sources map, et pouvoir avoir le mapping avec les fichiers sources pour du debug

La configuration Webpack

Ensuite dans un fichier webpack.config.js :

const path = require('path');

function baseConfig({ devtool, filename }) {
  return {
    entry: './src/polyfill.ts',
    target: ['web', 'es5'],
    module: {
      rules: [
        {
          test: /\.tsx?$/,
          exclude: /node_modules/,
          use: [
            {
              loader: 'ts-loader',
              options: {
                configFile: 'tsconfig.es5.json',
              },
            },
          ],
        },
      ],
    },
    devtool,
    resolve: {
      extensions: ['.tsx', '.ts', '.js'],
    },
    output: {
      path: path.resolve(__dirname, 'lib'),
      filename,
      globalObject: 'window',
      library: {
        name: 'my_lib',
        type: 'umd',
        umdNamedDefine: true,
      },
      libraryExport: 'default',
    },
  };
}

module.exports = [
  baseConfig({ devtool: 'inline-source-map', filename: 'lib.js' }),
  baseConfig({ devtool: 'source-map', filename: 'lib.min.js' }),
];

Je génère 2 bundles : 1 lib.js qui all-in-one avec toutes les sources map (ce qui est pratique dans certains cas mais super lourd pour aller en production), un bundle lib.min.js qui lui va contenir uniquement le code js et les sources map sont déportés dans un second fichier (lib.min.js.map). J'ai fait le choix de faire un bundle en ciblant directement la compatibilité maximale avec une target es5. Mais on pourrait imaginer avoir plusieurs bundle ciblant des navigateurs beaucoup plus récents ce qui allégerait le bundle très fortement !

La configuration Webpack est assez basique, mais quelques points à noter :

  • l'entrypoint n'est pas le même point d'entrée que pour la compilation via tsc, en effet j'ai pris le parti d'inclure des polyfill (via core-js) dans le bundle, et la manière la plus simple que j'ai trouvée pour gérer ça, c'est de ré-exporter tout ce qu'exporte index.ts en important les polyfills avant cet export. Ce n'est pas parfait mais ça fonctionne très bien !
  • quand on utilise un de ces bundles, la lib est disponible dans un objet global my_lib, donc pour retrouver une function exporter dans notre index.ts il faut l'appeler comme ça : my_lib.myFunction()
  • sauf erreur, on a aucun moyen d'avoir de typing avec ces manières de faire l'import

Conclusion

Ça fait quelque temps que je fonctionne avec ce système de package et tout ce passe bien. Aussi bien dans des projets TypeScript que JavaScript, avec ou sans bundler/compilateur. Donc je pense que je peux dire que tout fonctionne. Je vous mets le lien des sources de la lib que j'ai écrit dans les sources, comme ça vous aurez accès à une version potentiellement actualisée dans le futur !

Sources :