Package NPM universel ou comment construire un package qu'on pourra utiliser partout ?
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 webpackts-loader
: pour charger des sources en TypeScriptsource-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'exporteindex.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 notreindex.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 :
- https://webpack.js.org/configuration/devtool/
- https://webpack.js.org/configuration/output/#outputlibrary
- https://webpack.js.org/configuration/target/
- les sources du package que j'ai construit : https://github.com/gladiaio/gladia-sdk-js