Création d'une bibliothèque NPM TypeScript hybride

Magazine
Marque
GNU/Linux Magazine
Numéro
264
Mois de parution
juillet 2023
Spécialité(s)


Résumé

Rassembler dans un même livrable NPM du code serveur bigoût (parfums CommonJS require et ESM import), plus une version minifiée pour le browser, et des déclarations de types TypeScript, c'est possible. Objectifs : centraliser le développement et unifier le cycle des releases. Guide pratique...


Body

Node.js (en v19 à l’écriture de cet article) dispose de deux systèmes de chargement de modules, chacun ayant une syntaxe spécifique. Un programme JavaScript doit choisir entre le CommonJS (CJS) historique ou le moderne ECMAScript Modules (ESM), et ses dépendances devront également suivre le standard élu.

Lorsqu'on produit une bibliothèque à incorporer dans d'autres programmes, il peut être judicieux d'offrir les deux formats afin d'assurer la compatibilité avec le plus grand nombre de projets.

En outre, certaines bibliothèques, comme un module de manipulation de dates ou de fonctions cryptographiques, ciblent indifféremment le côté serveur et le navigateur ; les exemples et cas d'usage ne manquent pas. Pour une page web dans un navigateur, si ESM est devenu une possibilité depuis quelques années, bien souvent l'option du fichier unique, minifié, lui est préférée pour diverses raisons (notamment la maîtrise du nombre de requêtes HTTP).

Pour éliminer la duplication de code et faciliter l'importation dans tous les milieux, nous verrons dans cet article comment générer un module qui expose les trois saveurs.

Épargnons tout de suite au lecteur impatient un suspense insoutenable : il existe des outils pour obtenir le résultat facilement et avec très peu de paramétrage, comme notamment l'excellent programme tsup [1]. Cet article a pour ambition d'expliquer, pour mieux les comprendre, les mécanismes qui sont à l'œuvre.

Mais d'abord, un bref rappel chronologique s'impose.

1. Histoire

1.1 Il était une fois : ECMAScript

Le langage JavaScript naquit en 1995 comme solution ad hoc de l'éditeur Netscape pour un usage précis : ajouter un peu d'interactivité après affichage d'un document HTML dans le logiciel Navigator. Cette idée originale de Brendan Eich (écrite en dix jours [2]...) s'est propagée chez les éditeurs de butineurs concurrents. Chacun apporta son lot d'évolutions syntaxiques et de fonctionnalités particulières (généralement délibérément différentes ; après tout, pourquoi choisir le même nom que le concurrent pour la propriété qui indique la touche frappée lors d'un événement clavier...).

Il en a résulté une pénible segmentation des interpréteurs de ce langage et un accroissement important de la complexité à élaborer des programmes compatibles d'une plateforme à l'autre.

Pendant que le consortium W3C [3], dirigé par Tim Berners-Lee, s’attelle à l'uniformisation du modèle HTML dynamique (DOM et API) et des aspects visuels (CSS), le groupe ECMA [4] (European Computer Manufacturers Association) se fixe pour mission d'établir une standardisation pour le JavaScript, appelée ECMAScript, qui n'a cessé d'évoluer (ajout des Promises, programmation fonctionnelle, déstructuration et spread, etc.).

1.2 Il était deux fois : Node.js

Les choses se sont encore compliquées lorsqu'en 2009 Ryan Dahl a présenté [5] Node.js (c'est le terme officiel, prononcé "naudjéyesse", mais dans la suite j'écrirai aussi Node ou node). Il s'agit d'un framework pour exécuter côté serveur des programmes event driven avec le moteur JavaScript V8 de Google, en dehors de tout navigateur HTML.

L'argumentaire de popularisation vantait qu'avec un seul et même langage, les développeurs pourraient plus facilement s'occuper des deux côtés front et back ends, ou, à défaut, communiqueraient plus aisément entre eux.

Or, des différences fondamentales séparent ces deux paradigmes. Le serveur effectue des traitements parallèles, écoute des ports réseau, communique avec des bases de données, accède au matériel et dispose localement des fichiers de programme à exécuter. De son côté, le navigateur est dans un sandbox, déroule un programme à vocation visuelle pour le compte d'un utilisateur humain isolé, à partir de scripts qui lui proviennent à distance par HTTP.

Ceci a poussé les deux écosystèmes à suivre leurs propres évolutions [6], Node étant souvent tardif dans l'adoption ECMA par rapport aux navigateurs. Une des principales dissidences concerne la modularisation des programmes et les systèmes de chargement de dépendances.

2. Systèmes de chargement

Lorsque la taille d'un programme devient supérieure à quelques dizaines de lignes, ou quand il a besoin de réutiliser des travaux tiers, il est nécessaire de le découper en petites unités plus ou moins hermétiques. La terminologie varie selon les langages, on parlera de modules, fichiers ou autres packages, que l'on imbriquera ici par un #include, là avec un import, etc. À son tour, une unité de code sera potentiellement amenée à exporter des éléments utilisables par d'autres unités, ou à publier des points d'entrée directement déclenchables par l'environnement extérieur.

Avant Node et du temps où le standard JavaScript n'avait pas encore prévu le coup, les développeurs ont dû se montrer inventifs pour mettre en œuvre un mécanisme d'inclusion de fichiers et d'encapsulation de modules sur une page web. Dans le navigateur, où le contrôle du chargement des <script> se trouve intégralement confié au document HTML, un module ne pouvait pas de lui-même se déclarer dépendant d'un autre. C'était au code HTML de prendre soin de lister la succession de scripts dans le bon ordre, pour que le suivant puisse s'exécuter après le chargement du précédent.

<script src="https://cdn.domain.com/premiere-dependance.js"></script>
<script src="https://static.ailleurs.com/deuxieme-dependance.js"></script>
<script src="./page/mon-programe.js"></script>

Entre-temps, les travaux du W3C et des éditeurs de navigateurs ont rendu possible le chargement de nouveaux scripts au cours de l’exécution du script « dur » initial. Mais les problématiques d’interdépendances demeurent. Plusieurs approches ont émergé. Sans entrer en profondeur, nous allons mentionner les plus courantes.

2.1 Asynchronous Module Definition

Si tous les programmes de la page sont de mon cru, je peux encore me débrouiller. Mais si je charge un module tiers, qui s'appuie lui aussi sur diverses dépendances ordonnées, il me faudra laborieusement maintenir un code HTML rendu excessivement complexe et verbeux pour de mauvaises raisons.

Si bien qu'un outil nommé AMD [7] a vu le jour : un microchargeur (define) grâce auquel on diffère l'exécution d'un script, dont l'enveloppe stipule le nom de ses dépendances et autodéclare son propre nom. Une fois tous les scripts chargés, le loader résout l'arbre des dépendances et exécute les modules dans le bon ordre.

Dans l'exemple suivant, nous déclarons un module my-module, qui dépend de deux autres (dep1 et dep2), et qui exporte deux symboles myVal et myFunc.

define('my-module', ['dep1', 'dep2'], function (dep1, dep2) {
  return {
    myFunc: () => {
      // code utilisant les exports de dep1 et dep2
    },
    myVal: 'someValue',
  };
});

En rencontrant ce code, même si les modules dep1 ou dep2 ne sont pas encore chargés, rien ne se produit, si ce n'est que le système AMD prend note que ce module my-module en dépend. La factory sera exécutée dès que les deux dépendances deviendront disponibles (c'est-à-dire, lorsque la page HTML les aura chargées).

Lorsque cela se produit, my-module peut être résolu, et tout autre module qui s'était déclaré dépendre de lui verra sa factory s'exécuter automatiquement.

D'autres implémentations de ce concept existent, les plus connues étant RequireJS et Dojo (2005).

2.2 CommonJS

L'approche RequireJS fait le travail pour l'écosystème du navigateur, mais elle accroît artificiellement la complexité du code. Node.js quant à lui, ne souffre pas de la limitation des balises <script> du HTML, et les fichiers sources lui sont directement disponibles localement (par opposition au chargement distant via HTTP). Aussi a-t-on opté dès sa création pour une notation connue dans l'écosystème des navigateurs sous le nom de CommonJS, un projet de l'ingénieur Kevin Dangoor chez Mozilla en 2009, c'est-à-dire l'année de sortie de Node.

La syntaxe se présente comme ceci :

// importation d'un module :
const dep1 = require("dep1");
 
// exportation de symboles à utiliser
// par d'autres modules :
module.exports = {
  myVal: dep1.someValue * 12,
  myFunc: () => {
    // ...
  }
};

Pour Node, ça fonctionne tel quel. Pour un navigateur, la syntaxe s'y prend différemment d'AMD, mais l'esprit reste le même : l'environnement d'exécution (le navigateur) a besoin d'un chargeur qui apporte les primitives require et module.exports.

Plusieurs implémentations existent, plus ou moins populaires, mais la segmentation n'en a pas été résolue pour autant. L'écosystème du client web s'est alors tourné vers une autre famille d'outils : les bundlers.

2.3 Bundlers

Il s'agit d'un préprocesseur qui prend les fichiers sources JavaScript, séparés et modularisés selon une approche ou une autre, pour les réunir en un seul fichier contenant toutes les dépendances.

Parmi les plus célèbres, on peut citer Browserify [8], Wepback et Rollup.

Le bundler prend en entrée un ensemble de fichiers tel que celui vu plus haut, et les transforme en un unique fichier JavaScript résultant, dans lequel ne figure plus de pseudodirectives require, exports ou define. Ce code unifié, appelé bundle, généralement encapsulé dans une fonction anonyme où toutes les dépendances sont résolues et incorporées à la closure. Il ne reste plus qu'à charger ce bundle dans une page web avec une balise <script> unique.

IIFE : Immediately Invoked Function Expression

En JavaScript, on utilise couramment le principe des fonctions anonymes immédiatement invoquées (IIFE) : il s'agit de déclarer, puis d’aussitôt exécuter une fonction qui se verra donc recevoir un scope encapsulé. C'est un moyen de construire un bloc de code avec des variables locales, des fonctions imbriquées, classes et autres symboles qui resteront privés et ne pollueront pas le scope appelant.

La syntaxe est de la forme : (function( ) { /* bloc de code étanche */ })()

2.4 ECMAScript Modules

En 2015, face à la divergence des approches et aux difficultés subies par les applications dans les navigateurs, ECMA a fixé le standard des ECMAScript Modules (ESM). Aujourd'hui, la prise en charge par les navigateurs est assez générale.

La syntaxe adoptée est la suivante :

// importation de symboles provenant d'un module :
import { symbole1, symbole2 } from "dep1";
 
// exportation de symboles à utiliser
// par d'autres modules :
export const myVal = 'someValue';
export function myFunc() {
  // ...
};

Cela évoque CommonJS avec juste une autre notation, mais les différences ne sont pas que syntaxiques : notamment, le modèle CommonJS présente des faiblesses de sécurité [9] que la norme ESM rectifie. Les détails sortiraient du cadre de cet article, mais disons en quelques mots qu'il est possible, depuis n'importe où dans le programme, d'altérer un autre module programmatiquement, ce qui représente un danger potentiel, surtout pour des modules sensibles auxquels on confie des mots de passe, par exemple.

Le chargement dans une page HTML se fait comme ceci :

<script type="module" src="fichier.js"></script>

Dès lors, le browser interprétera correctement les instructions d'import et d'export, et chargera les modules référencés dans le fichier, récursivement. Si la clause import ne stipule pas un chemin relatif (c'est-à-dire qu'on importe donc un autre module externe), il faut également fournir une Import Map [10] dans le document HTML, mais ceci dépasse le sujet présent.

Les navigateurs ont adopté ESM dès 2017 [11] (beaucoup plus tard sur mobile) ; mais cette magie n'arrange pas obligatoirement nos affaires, au vu du nombre (parfois très élevé) de requêtes HTTP que le navigateur doit faire pour charger tous les fichiers séparément. On peut prédire sans risque que l'ESM dans le navigateur conservera encore un certain temps un simple statut de curiosité. En fin de compte, le bundler reste encore la solution la plus répandue.

Côté serveur, ce n'est que dans Node 13, en 2019, que l'ESM est intégré sans nécessiter de feature-flags expérimentaux. Tout se joue dans le fichier package.json. C'est dans ce manifeste, qui rassemble les métadonnées du programme à exécuter (ou du module à distribuer), que l'on précise le standard de chargement dans la propriété "type" : sans surprise, ce sera soit "commonjs" (par défaut) soit "module" si l'on souhaite de l'ESM. Néanmoins, compte tenu de la quantité faramineuse de paquets distribués sur la plateforme npmjs.org, de très loin le registre le plus vaste par rapport à tous les autres langages (voir Figure 1), l'adoption ne sera jamais totale.

gabrielzebib npm-module-hybride figure 01-s

Fig. 1 : Évolution du nombre de paquets sur NPMJS, et comparaison avec les registres publics des autres langages (source : www.modulecounts.com).

3. TypeScript

Pour qu'il soit complet, il faut ajouter TypeScript à notre tour d'horizon.

Est-il besoin de le présenter ? Cet additif au langage JavaScript qui apporte typage et confiance est presque devenu indispensable au développement de services web autant que d'applications HTML clientes. Il nous faut donc prévoir une étape de transpilation dans notre chaîne de build.

Transpilation

La transpilation, ou transcompilation, est en quelque sorte une compilation source vers source. C'est l'opération qui permet de transformer du code d'un certain langage vers un autre code source dans un autre langage.

Ni Node.js ni les navigateurs ne sont capables de comprendre le TypeScript, et l’on a donc besoin de le transpiler d'abord.

La transpilation diffère de la compilation, qui produit théoriquement du code binaire directement exécutable par la machine (qu'elle soit matérielle ou virtuelle). Le résultat de la transpilation est supposé subir encore d'autres transformations sur son chemin, avant (ou au moment) d'être exécuté.

On retrouve de la transpilation par exemple avec les outils qui automatisent la traduction de programmes Python 2 vers Python 3 (ou le contraire), ou bien depuis Objective-C vers Swift, etc.

Lorsqu'on publie un package NPM (Node Package Manager), c'est-à-dire un module à incorporer comme dépendance dans un projet hôte, c'est du code JavaScript que l'on distribue, même si on l'a rédigé en TypeScript à l'origine. Mais l'un des intérêts de ce langage est de pouvoir embarquer avec le module les définitions de types qui seront indispensables au développeur utilisant la dépendance dans un projet TypeScript.

Le compilateur tsc est capable de se référer aux définitions de types d'une dépendance si son package.json stipule un champ "types" qui pointe vers le fichier, au sein du module, contenant les déclarations.

4. Mise en pratique

Tous ces rappels étant faits (un grand merci pour votre patience !), nous pouvons maintenant entrer dans l'expérimentation.

Nous allons construire un package NPM qui conviendra aussi bien à un projet CommonJS qu'ESM, et qui embarquera un bundle unique et minifié chargeable dans un navigateur. Bien entendu, le module est écrit en TypeScript et nous empaquetons les définitions de types pour un usage propre et professionnel par des développeurs tiers.

On appelle parfois ce type de livrable un module hybride (CJS, ESM, bundled).

L'intérêt est de pouvoir se concentrer sur le code métier, sans se soucier des spécificités des différentes cibles.

4.1 Forme du projet

Pour la démonstration, supposons que nous fabriquons une bibliothèque appelée @ed-diamond/glmf-hybrid composée des fichiers src/file1.ts et src/file2.ts, l'un important l'autre (ou l'inverse, c'est vous qui voyez).

.
├── package.json
└── src
    ├── file1.ts
    ├── file2.ts
    └── index.ts

Le manifeste package.json commence comme ceci :

{
  "name": "@ed-diamond/glmf-hybrid",
  "version": "1.0.0",
  ...
}

Nous rédigeons le projet à la mode ESM (l'avenir), c'est-à-dire en utilisant la syntaxe import et export ; mais le fruit de notre travail conviendra aussi bien à un projet CommonJS, comme nous allons le voir.

Le fichier index.ts réexporte les symboles publics de la bibliothèque, de sorte qu'un projet consommateur se contentera de les importer depuis la racine du module :

// index.ts
import { Class1, func1 } from './file1';
import { Class2, func2 } from './file2';
 
export {
  Class1,
  Class2,
  func1,
  func2,
};

Un projet hôte ESM pourra écrire ceci :

import { Class2, func1 } from "@ed-diamond/glmf-hybrid";

tandis qu'un projet CJS fera :

const { Class2, func1 } = require("@ed-diamond/glmf-hybrid");

4.2 Configuration

Occupons-nous dans un premier temps des deux saveurs pour Node.js. Nous les générerons dans deux sous-répertoires du dossier dist.

Nous utilisons tsc pour transpiler nos sources ESM TypeScript en deux résultantes JavaScript : ESM et CJS.

Dans le fichier package.json, nous allons déclarer une cible pour chaque build, et une cible qui fait les deux (voir 4.3). Chaque tour de compilation nécessite une configuration différente, et nous allons donc définir deux fichiers, qui étendent une racine commune. Nous placerons ces trois fichiers dans un répertoire config (nous verrons le quatrième au 4.6).

.
└── config
    ├── rollup.config.js
    ├── tsconfig.cjs.json
    ├── tsconfig.esm.json
    └── tsconfig.json

Le fichier parent tsconfig.json comporte les directives suivantes (en plus de celles à votre goût et suivant les besoins de votre projet) :

{
  "compilerOptions": {
    "baseUrl": "../src",
    "esModuleInterop": true,
    "rootDir": "../src",
    "target": "ESNext",
    "types": ["node", "jest"]
  },
  "exclude": [
    "../node_modules", "*/**/*.test.ts"],
  "files": [
    "../src/index.ts"
  ]
}

La documentation de TypeScript précise que esModuleInterop résout certains problèmes (subtils), dont celui sur la sécurité mentionné en 2.4.

Le fichier config/tsconfig.esm.json pour la transpilation ESM contient :

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "module": "ESNext",
    "outDir": "../dist/esm"
  }
}

Nous y précisons la configuration parente avec extends, le type de système de modules : "module": "ESNext" (pour obtenir de l'ESM) et le répertoire outDir où générer les fichiers transpilés à la norme ESM.

Voyons à présent le contenu de config/tsconfig.cjs.json pour produire du CJS :

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "declaration": true,
    "declarationDir": "../dist/types",
    "declarationMap": false,
    "module": "CommonJS",
    "outDir": "../dist/cjs"
  }
}

Dans celui-ci, nous précisons un système "module": "CommonJS", un outDir différent, et nous décidons (arbitrairement) que ce sera ce build qui servira d'occasion à produire les définitions de types (voir 3).

On aurait pu choisir le build ESM pour prendre en charge cette tâche, voire la confier à une troisième cible qui n'aurait fait que ça (avec emitDeclarationOnly). Mais quoi qu'il en soit, il est inutile de le faire deux fois : les définitions de types sont les mêmes, et sont insensibles au système de modules choisi, car les d.ts TypeScript utilisent toujours la notation ESM.

4.3 package.json

Voici les cibles que nous pouvons préciser dans la rubrique scripts de notre package.json :

"scripts": {
  "build": "npm run build:esm && npm run build:cjs",
  "build:cjs": "tsc -p config/tsconfig.cjs.json",
  "build:esm": "tsc -p config/tsconfig.esm.json",
},

La commande npm run build aura pour effet de dérouler le build ESM suivi du build CJS, chacun par son propre fichier de configuration TypeScript, et le dernier produira également les définitions de types.

La résultante se retrouve sur le disque comme ceci :

.
└── dist
    ├── cjs
    │   ├── file1.js
    │   ├── file2.js
    │   └── index.js
    ├── esm
    │   ├── file1.js
    │   ├── file2.js
    │   └── index.js
    └── types
        ├── file1.d.ts
        ├── file2.d.ts
        └── index.d.ts

Les fichiers sous dist/cjs utilisent le require, les fichiers sous dist/ems utilisent l'import, et les types .d.ts sont présents.

4.4 Préparation des modes

Mais ce n'est pas encore suffisant pour permettre à un programme d'importer notre bibliothèque. En effet, il nous faut ajouter quelques méta-informations dans le manifeste du projet, et préciser dans chaque répertoire le format correspondant au moyen d'un autre petit fichier package.json minimaliste.

Commençons par eux. Dans dist/cjs/package.json :

{"type": "commonjs"}

et dans dist/esm/package.json :

{"type": "module"}

Et maintenant, ajoutons ceci dans le package.json principal de notre projet :

"types": "dist/types/index.d.ts",
"exports": {
  "node": {
    "import": "./dist/esm/index.js",
    "require": "./dist/cjs/index.js"
  }
},

Ces champs servent à préciser à un importateur où se trouvent les définitions de type TypeScript, et où est le code que Node doit considérer [12] pour chacun des deux modes (import (ESM) et require (CommonJS)).

Pour ce qui est de la création des deux petits manifestes pour ESM et CJS dans les sous-dossiers de dist, nous pouvons automatiser en ajoutant un postbuild pour chaque cible dans la rubrique scripts (un script dont le préfixe est post, comme dans postquelqueChose, est automatiquement exécuté à la suite du lancement de script correspondant quelqueChose) :

"scripts": {
  ...
  "postbuild:cjs": "npm run fix-package:cjs",
  "postbuild:esm": "npm run fix-package:esm",
  "fix-package:cjs": "echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json",
  "fix-package:esm": "echo '{\"type\": \"module\"}' > dist/esm/package.json"
},

Lorsqu'un projet incorpore notre dépendance, npm télécharge notre paquet qui contient tout. Puis, à l'exécution, selon que le type du projet (dans son propre package.json) est ESM ou CJS, Node consultera nos package.json pour décider de charger telle ou telle saveur (nous verrons en 4.8 que celui du ESM pourrait être remplacé par l’utilisation de l’extension .mjs, mais j’ai choisi ici de conserver la symétrie à des fins didactiques).

4.5 Correction des extensions pour ESM

Il reste une dernière étape pour débloquer l'utilisation des sources en mode ESM, à cause d'une exigence du standard concernant les extensions de fichiers [13]. La spécification précise qu'en mode ESM, les chemins relatifs des imports doivent obligatoirement indiquer une extension de fichier. Or, nous avons développé le module en TypeScript (fichiers .ts), et nous avons omis l'extension dans nos sources (voir index.ts en 4.1), de façon à laisser TS et JS être beaux joueurs ensemble (au vu d'un chemin relatif, le compilateur TypeScript devine qu'on charge un ts, et Node (CJS) devine qu'on charge un js ; l'extension peut être sous-entendue).

Mais en mode ESM, ça ne fonctionne plus : le runtime exige que les imports stipulent l'extension. Nous ne pouvons pas la préciser dans nos sources, car ce serait TypeScript qui coincerait.

Nous allons devoir modifier les fichiers JavaScript après transpilation, pour ajouter l'extension .js au bout des imports de fichiers relatifs (import ... from "./...";). Les commandes grep et sed feront l'affaire dans un simple script shell que je place dans config/fix-esm-ext.sh, mais toute autre approche est possible :

project_dir=$(dirname $(dirname $(realpath "$0")))
 
grep -Rl 'from "./' "${project_dir}/dist/esm" \
  | xargs sed -i'.bak' -e '/from ['"]\.\// s#from ['"]./\(.*\)['"];?#from "./\1.js";#'
 
find "${project_dir}/dist/esm" -name "*.bak" -delete

Ce programme est à exécuter après la transpilation ESM. Nous pouvons modifier la cible postbuild:esm du package.json :

"scripts": {
  ...
  "fix-ext:esm": "config/fix-esm-ext.sh",
  "postbuild:esm": "npm run fix-package:esm && npm run fix-ext:esm"
}

4.6 Le navigateur et son bundle

Que se passe-t-il dans une page HTML ?

Nous l'avons déjà dit, le document peut opter pour charger un <script> au standard type="module". La mécanique sera alors similaire à celle évoquée ci-dessus.

Si nous optons pour un chargement ficelé (bundle), nous produirons un livrable sous la forme d'un fichier JavaScript unique, par exemple dans dist/cdn/lib.min.js, qui contient tout (aussi bien nos fichiers sources que les dépendances sur lesquelles notre bibliothèque s'appuie).

Je propose l'utilisation du bundler rollup, simple et répandu, qui se configure de la façon suivante au moyen du fichier config/rollup.config.js :

const typescript = require("@rollup/plugin-typescript");
const terser = require("@rollup/plugin-terser");
const iifeNS = require("rollup-plugin-iife-namespace");
 
module.exports = {
  input: "src/index.ts",
  output: {
    file: "dist/cdn/lib.min.js",
    format: "iife",
    name: "@ed-diamond/glmf-hybrid",
  },
  plugins: [
    typescript({ module: "esnext", declaration: false }),
    iifeNS(),
    terser(),
  ],
};

Quelques explications :

  • on utilise le plugin @rollup/plugin-typescript pour indiquer au bundler de commencer par la transpilation ;
  • on retrouve le chemin du résultat à produire : file: "dist/cdn/lib.min.js" ;
  • on choisit le schéma IIFE (voir encadré) qui enregistrera tous les symboles exportés dans le namespace window["@ed-diamond/glmf-hybrid"] ;
  • et l’on obscurcit et minifie le fichier JS final, grâce au plugin Terser.

Si l'on héberge les fichiers du module sur un serveur HTTP, l'intégration dans la page HTML devient :

<script src="https://mydomain.com/static-files/@ed-diamond/glmf-hybrid/dist/cdn/lib.min.js"></script>
 
<script>
    const { Class1, Class2, func1, func2 } = window["@ed-diamond/glmf-hybrid"];
</script>

Nous pouvons ajouter une cible build:browser dans le package.json pour déclencher la construction du bundle.

"sripts": {
  ...
  "build": "npm run build:esm && npm run build:cjs && npm run build:browser",
  "build:browser": "rollup -c config/rollup.config.js"
}

Le répertoire dist après la commande npm run build aura maintenant cette allure :

.
└── dist
    ├── cdn
    │   └── lib.min.js
    ├── cjs
    │   ├── file1.js
    │   ├── file2.js
    │   └── index.js
    ├── esm
    │   ├── file1.js
    │   ├── file2.js
    │   └── index.js
    └── types
        ├── file1.d.ts
        ├── file2.d.ts
        └── index.d.ts

4.7 Pack

Notre module est prêt à être distribué. La commande qui se charge de la préparation du paquetage est npm pack. Elle génère une archive GZIP à partir des fichiers stipulés dans la propriété files du package.json. Dans notre cas, il s'agit de tout le répertoire dist :

"files": [
    "dist/**/*"
  ]

Voici sa sortie :

$ npm pack Mar 26, 09:12:41
 
npm notice
npm notice 📦 @ed-diamond/glmf-hybrid@1.0.0
npm notice === Tarball Contents ===
npm notice 22.1kB dist/cdn/lib.min.js
npm notice 2.9kB  dist/cjs/file1.js
npm notice 373B   dist/cjs/file2.js
npm notice 2.1kB  dist/cjs/index.js
npm notice 21B    dist/cjs/package.json
npm notice 2.5kB  dist/esm/file1.js
npm notice 235B   dist/esm/file2.js
npm notice 495B   dist/esm/index.js
npm notice 19B    dist/esm/package.json
npm notice 371B   dist/types/file1.d.ts
npm notice 73B    dist/types/file2.d.ts
npm notice 535B   dist/types/index.d.ts
npm notice 2.0kB  package.json
npm notice === Tarball Details ===
npm notice name:          @ed-diamond/glmf-hybrid
npm notice version:       1.0.0
npm notice filename:      @ed-diamond/glmf-hybrid-1.0.0.tgz
npm notice package size:  23.7 kB
npm notice unpacked size: 133.6 kB
npm notice shasum:        97d896c096831366e104ea3a2c322461fe1cdc83
npm notice integrity:     sha512-Tec2g/lZx4LSW[...]bH/wpKalXhBRw==
npm notice total files:   13
npm notice
glmf-hybrid-1.0.0.tgz

À ce stade, on pourrait publier le paquet sur la registry npmjs.org, avec la commande npm publish.

Mais avant, écrivons un petit smoke test pour nous assurer que l'archive fonctionne bien comme on le croit.

4.8 Smoke test

L'exercice consiste à fabriquer un projet temporaire consommateur, qui ait comme dépendance notre petit module sans le faire venir de la registry, mais directement depuis notre archive locale. C'est possible avec la commande npm install en précisant un chemin de fichier GZIP.

Préparons dans notre projet des fichiers de test comme ceci :

.
└── test
    └── smoke
        ├── browser.js
        ├── index.js
        └── index.mjs

Notez l'extension .mjs. Par défaut, Node exécute un fichier js en mode CommonJS, et un fichier mjs en mode ECMAScript Modules.

Voyons d'abord quoi mettre dans le fichier index.js de test pour CJS :

const { Class1, func2 } = require("@ed-diamond/glmf-hybrid");
// Faire des tests simples avec nos symboles
console.log("OK pour CJS"); // ou Erreur ...

Le fichier index.mjs pour ESM lui ressemble, mais bien sûr, il utilise l'import :

import { Class1, func2 } from "@ed-diamond/glmf-hybrid";
// Faire des tests simples avec nos symboles
console.log("OK pour ESM"); // ou Erreur ...

Quant au fichier browser.js, nous nous plaçons au plus près de ce que ferait le tag <script src...> en HTML qui exécute la IIFE préparée par Rollup, et qui intègre nos symboles dans l'objet global window["@ed-diamond/glmf-hybrid"] :

const { Class1, func2 } = window["@ed-diamond/glmf-hybrid"];
// Faire des tests simples avec nos symboles
console.log("OK pour le browser"); // ou Erreur ...

Les lignes de tests en elles-mêmes peuvent être identiques, vous saurez trouver un moyen de mutualiser.

À présent, dans un répertoire temporaire, installons notre pack :

~/projet/glmf-hybrid $ cd /tmp
/tmp $ npm install ~/projet/glmf-hybrid/glmf-hybrid-1.0.0.tgz
 
added 1 package in 141ms

La commande a créé un package.json sur place, et a déballé l'archive dans node_modules. Maintenant, on copie le contenu du répertoire test/smoke au même endroit.

Nous allons exécuter node dans trois modes différents, pour lui faire goûter alternativement toutes nos saveurs.

Dans la pratique, vous voudrez certainement arranger ces opérations dans un script shell. L'idée n'est pas seulement de vérifier cette seule fois que les choses sont en ordre, mais de se prémunir des régressions à mesure que le projet avance, car une mauvaise configuration accidentelle dans une des nombreuses options est vite arrivée.

Pour CJS et ESM, lancer les tests est aussi simple que :

/tmp $ node index.js
OK pour CJS
/tmp $ node index.mjs
OK pour ESM

Enfin, concernant la validation du bundle minifié, nous devons prévoir dans le scope global un objet window, qui n'est pas défini dans Node (mais qui est fourni par les navigateurs). Nous voulons ensuite exécuter directement le fichier minifié (lib.min.js), sans passer par un mécanisme d'importation, et enfin nous jouons les instructions de test de browser.js.

Tout ceci se fait en alimentant l'entrée standard de la commande node :

/tmp $ cat <(echo 'const window = {};') \
       node_modules/@ed-diamond/glmf-hybrid/dist/cdn/lib.min.js \
       browser.js \
       | node
OK pour le browser

Victoire, on peut effectuer le npm publish pour déployer le package dans la registry.

4.9 Cadeau bonus : le CDN

Dans le snippet HTML vu plus haut pour illustrer le chargement du module IIFE avec une balise <script>, j'avais indiqué le chemin complet du fichier minifié https://.../dist/cdn/lib.min.js.

Il y a plus élégant. Si nous déployons une bibliothèque publique sur npmjs.org, nous bénéficions gracieusement d'un hébergement web statique pour le module, via le service https://www.jsdelivr.com/.

Dans le package.json de @ed-diamond/glmf-hybrid, on peut ajouter un champ "browser" [14] qui stipule le fichier du bundle. Dans notre cas, il s'agit de "browser": "dist/cdn/lib.min/js".

La section complète des points d'entrée devient maintenant :

"browser": "dist/cdn/lib.min/js",
"types": "dist/types/index.d.ts",
"exports": {
  "node": {
    "import": "./dist/esm/index.js",
    "require": "./dist/cjs/index.js"
  }
},

Par la suite, après publication, l'adresse du CDN automatique a la forme : 
https://cdn.jsdelivr.net/npm/@ed-diamond/glmf-hybrid@1.0.0

Cette URL fournit directement le fichier JavaScript minifié, généré par notre bundler, et prêt à être incorporé dans le HTML.

<script src="https://cdn.jsdelivr.net/npm/@ed-diamond/glmf-hybrid@1.0.0"></script>

gabrielzebib npm-module-hybride jwt decode-s

Fig. 2 : un bundle minifié, servi par le CDN JsDelivr automatiquement après publication d'un paquet NPM. Cette IIFE est prête à l'emploi dans une page web.

Conclusion

Le lecteur doit se sentir maintenant un peu mieux éclairé, en principe, sur les tenants et aboutissants des systèmes de chargement des programmes JavaScript.

Comme indiqué dès le début, la distribution d'un module hybride qui prend en charge la norme ECMAScript Modules, la compatibilité CommonJS, et qui s'accompagne d'un IIFE minifié peut se faire très simplement au moyen des bons outils. En plus de celui évoqué en introduction, citons le projet modulestrap [15] qui fournit un point de départ confortable.

Références

[1] L'outil d'empaquetage de modules hybrides TSUP : https://tsup.egoist.dev

[2] JavaScript créé en 10 jours :
https://thenewstack.io/brendan-eich-on-creating-javascript-in-10-days-and-what-hed-do-differently-today/

[3] Le World Wild Web Consortium : https://www.w3.org/

[4] ECMAScript : https://262.ecma-international.org

[5] Naissance de Node.js :
https://www.protechtraining.com/blog/post/introduction-to-nodejs-with-ryan-dahl-182

[6] Les versions de JavaScript : https://www.w3schools.com/js/js_versions.asp

[7] Asynchronous Module Definition : https://requirejs.org/docs/whyamd.html

[8] Le bundler Browserify pour CommonJS dans le navigateur : https://github.com/browserify/browserify

[9] Billet de blog d'Andrea Giammarchi sur la sécurité dans CommonJS :
https://webreflection.medium.com/cjs-vs-esm-5f8b90a4511a

[10] Déclaration d'une Import Map dans une page HTML :
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules#importing_modules_using_import_maps

[11] Can I Use ESM : https://caniuse.com/?search=ESM

[12] Documentation package.json, Conditional Exports :
https://nodejs.org/api/packages.html#conditional-exports

[13] La spécification du Loader ESM : https://nodejs.org/api/packages.html#modules-loaders

[14] Le fichier par défaut pour le CDN JsDelivr :
https://www.jsdelivr.com/documentation#id-configuring-a-default-file-in-packagejson

[15] Le kit de démarrage Modulestrap : https://github.com/WebReflection/modulestrap



Article rédigé par

Par le(s) même(s) auteur(s)

Jouons avec les Linux Pluggable Authentication Modules

Magazine
Marque
GNU/Linux Magazine
Numéro
259
Mois de parution
septembre 2022
Spécialité(s)
Résumé

Au cœur de la gestion des utilisateurs et de leurs permissions, le système GNU/Linux recèle un mécanisme modulaire et extensible pour faire face à tous les usages actuels et futurs, liés à la preuve d'identité. Intéressons-nous, à travers un cas pratique, à ces modules interchangeables d'authentification, utiles tant aux applicatifs qu'au système lui-même.

DaC ou pas DaC : comment vont vos diagrammes ?

Magazine
Marque
GNU/Linux Magazine
Numéro
258
Mois de parution
juillet 2022
Spécialité(s)
Résumé

La documentation de code et d'API est désormais passée du côté des fichiers sources. Des outils permettent d'en extraire les blocs documentaires, afin de maintenir toujours en phase le manuel et le code sans dupliquer l'effort. Or, un bon dessin valant mieux qu'un long discours, il devient plus nécessaire que jamais de pratiquer le Diagram as Code (DaC) pour incorporer des illustrations techniques directement dans les sources.

Contrôle automatisé de machines virtuelles par QEMU Monitor

Magazine
Marque
Linux Pratique
Numéro
129
Mois de parution
janvier 2022
Spécialité(s)
Résumé

Le projet QEMU, qui offre une approche légère et peu intrusive pour l’émulation et la virtualisation, présente l’avantage d’être entièrement configurable en ligne de commandes. Nous verrons dans ces pages comment scripter les modifications de certaines caractéristiques d’une VM pendant son exécution en arrière-plan.

Les derniers articles Premiums

Les derniers articles Premium

Bénéficiez de statistiques de fréquentations web légères et respectueuses avec Plausible Analytics

Magazine
Marque
Contenu Premium
Spécialité(s)
Résumé

Pour être visible sur le Web, un site est indispensable, cela va de soi. Mais il est impossible d’en évaluer le succès, ni celui de ses améliorations, sans établir de statistiques de fréquentation : combien de visiteurs ? Combien de pages consultées ? Quel temps passé ? Comment savoir si le nouveau design plaît réellement ? Autant de questions auxquelles Plausible se propose de répondre.

Quarkus : applications Java pour conteneurs

Magazine
Marque
Contenu Premium
Spécialité(s)
Résumé

Initié par Red Hat, il y a quelques années le projet Quarkus a pris son envol et en est désormais à sa troisième version majeure. Il propose un cadre d’exécution pour une application de Java radicalement différente, où son exécution ultra optimisée en fait un parfait candidat pour le déploiement sur des conteneurs tels que ceux de Docker ou Podman. Quarkus va même encore plus loin, en permettant de transformer l’application Java en un exécutable natif ! Voici une rapide introduction, par la pratique, à cet incroyable framework, qui nous offrira l’opportunité d’illustrer également sa facilité de prise en main.

Les listes de lecture

9 article(s) - ajoutée le 01/07/2020
Vous désirez apprendre le langage Python, mais ne savez pas trop par où commencer ? Cette liste de lecture vous permettra de faire vos premiers pas en découvrant l'écosystème de Python et en écrivant de petits scripts.
11 article(s) - ajoutée le 01/07/2020
La base de tout programme effectuant une tâche un tant soit peu complexe est un algorithme, une méthode permettant de manipuler des données pour obtenir un résultat attendu. Dans cette liste, vous pourrez découvrir quelques spécimens d'algorithmes.
10 article(s) - ajoutée le 01/07/2020
À quoi bon se targuer de posséder des pétaoctets de données si l'on est incapable d'analyser ces dernières ? Cette liste vous aidera à "faire parler" vos données.
Voir les 64 listes de lecture

Abonnez-vous maintenant

et profitez de tous les contenus en illimité

Je découvre les offres

Déjà abonné ? Connectez-vous