Aller au contenu
learn

1. Node.js, npm et modules

Code : examples/l01_hello.js, les projets l01-esm, l01-cjs et l01-interop, et les snippets d’erreur dans errors/.

En .NET et en Java, un seul produit te donne le langage, le runtime et la bibliothèque standard. JavaScript les sépare en trois :

C# Java JavaScript
Le langage C#, compilé par Roslyn Java, compilé par javac ECMAScript, analysé par le moteur au chargement
Le runtime le CLR la JVM un moteur : V8, SpiderMonkey ou JavaScriptCore
Fichiers, réseau, processus la bibliothèque de classes de base le JDK l’hôte : Node.js, Deno, Bun, ou un navigateur

La spécification ECMAScript définit les valeurs, les objets, les fonctions, Array, Map, JSON et Promise, et rien qui touche au monde extérieur : pas de fichiers, pas de réseau, pas même console.log. C’est l’hôte qui les ajoute. Node.js embarque V8 et ajoute des modules comme node:fs, un objet process et une boucle d’événements ; un navigateur embarque son moteur et ajoute document et window.

flowchart LR
source["Tes fichiers .js"] --> engine["Moteur : V8<br/>analyse, compile, exécute ECMAScript"]
node["Hôte Node.js<br/>process, node:fs, node:http, boucle d'événements"] --> engine
browser["Hôte navigateur<br/>window, document, événements du DOM"] -.-> engine2["Moteur : V8, SpiderMonkey<br/>ou JavaScriptCore"]
both["Dans les deux hôtes<br/>console, setTimeout, fetch, URL"] --> engine
both -.-> engine2

Le même langage donne donc des globales différentes selon l’endroit où il s’exécute :

> node -p "typeof window"
undefined
> node -p "typeof process"
object
> node -p "process.versions.v8"
13.6.233.17-node.53

Ce cours exécute tout dans Node.js. Le navigateur est le sujet de la leçon 12, et le DOM appartient au cours React.

Node.js publie une version majeure tous les six mois. Les versions paires deviennent des versions à support long terme (LTS) en octobre, et chaque version LTS est supportée pendant environ 30 mois (calendrier des versions) :

Sortie LTS à partir de Fin de vie
Node.js 22 « Jod » avril 2024 octobre 2024 avril 2027
Node.js 24 « Krypton » mai 2025 octobre 2025 avril 2028
Node.js 26 mai 2026 octobre 2026 avril 2029

C’est proche de .NET, où chaque version paire est LTS pendant trois ans, et de Java, où une nouvelle version LTS sort tous les deux ans. Ce cours utilise Node.js 24.21.0, la dernière version 24 au 14 septembre 2026.

J’ai téléchargé l’archive ZIP, vérifié son SHA-256 avec la liste que publie Node.js, et je l’ai décompressée dans un dossier à part, en dehors du PATH global :

Fenêtre de terminal
Invoke-WebRequest https://nodejs.org/dist/v24.21.0/node-v24.21.0-win-x64.zip -OutFile node-v24.21.0-win-x64.zip
(Get-FileHash node-v24.21.0-win-x64.zip -Algorithm SHA256).Hash.ToLower()
(Invoke-RestMethod https://nodejs.org/dist/v24.21.0/SHASUMS256.txt) -split "`n" | Select-String 'win-x64.zip'
Expand-Archive node-v24.21.0-win-x64.zip -DestinationPath C:\tools # crée C:\tools\node-v24.21.0-win-x64
C:\tools\node-v24.21.0-win-x64\node.exe --version

Les deux empreintes doivent être identiques : sur ma machine, les deux valaient 158f7685…e541, et les quatre commandes ont pris 22 secondes. Ajoute le dossier à ton PATH pour appeler node et npm depuis n’importe quel terminal.

L’installeur est une autre option : winget install OpenJS.NodeJS.LTS (à vérifier : je ne l’ai pas exécuté). Le 14 septembre 2026, winget show OpenJS.NodeJS.LTS proposait encore 24.19.0, deux versions de retard.

Ensuite, sur n’importe quel OS :

> node --version
v24.21.0
> npm --version
11.19.0

npm est fourni avec Node.js : chaque version de Node.js embarque une version de npm.

examples/l01_hello.js
const names = process.argv.slice(2); // argv[0] est node, argv[1] est ce fichier
console.log('Hello, world!');
for (const name of names) {
console.log(`Hello, ${name}!`);
}
> node examples/l01_hello.js Ada Grace
Hello, world!
Hello, Ada!
Hello, Grace!
C# Java Node.js
Exécuter un fichier dotnet run app.cs (.NET 10) java Hello.java node hello.js
Évaluer une expression — — node -p "0.1 + 0.2"
Shell interactif C# Interactive dans Visual Studio jshell node, puis .exit
Relancer à chaque modification dotnet watch — node --watch hello.js
Arguments du programme args String[] args process.argv, à partir de l’indice 2

Il n’y a pas d’étape de compilation à lancer : Node.js lit le source, V8 le compile à la volée. console.log est le Console.WriteLine de JavaScript ; les template literals, entre backticks, interpolent comme $"…" en C#. La leçon 2 explique const.

Un dossier devient un projet quand il a un package.json. npm init -y en écrit un avec des valeurs par défaut :

> npm init -y
Wrote to C:\…\npmdemo\package.json:
{
"name": "npmdemo",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs"
}

npm 11.19 écrit "type": "commonjs" explicitement ; la section sur les modules plus bas explique pourquoi ce champ compte. Installer un package l’ajoute à dependencies, le télécharge dans node_modules, et écrit package-lock.json :

> npm install semver@7.7.2
added 1 package, and audited 2 packages in 782ms
found 0 vulnerabilities
"dependencies": {
"semver": "^7.7.2"
}
NuGet Maven npm
Manifeste .csproj, <PackageReference> pom.xml, <dependency> package.json, dependencies
Build ou tests seulement PrivateAssets="all" <scope>test</scope> devDependencies
Une version écrite 7.7.2 signifie 7.7.2 ou plus (gestion des versions NuGet) une exigence souple de 7.7.2 (référence du POM) exactement 7.7.2
Plage par défaut quand tu ajoutes un package la version, comme minimum la version ^7.7.2 : 7.7.2 ou plus, en dessous de 8.0.0 (semver)
Fichier de verrouillage packages.lock.json, sur option aucun package-lock.json, toujours
Où vont les packages un cache partagé, ~/.nuget/packages un cache partagé, ~/.m2/repository un dossier node_modules dans chaque projet, rempli depuis un cache
Restaurer exactement le fichier de verrouillage dotnet restore --locked-mode — npm ci
Lancer un outil d’un package dotnet tool run un goal de plugin npx <tool>

Commite package.json et package-lock.json, jamais node_modules. En CI, npm ci supprime node_modules et installe exactement ce que liste le fichier de verrouillage, ou échoue si le fichier de verrouillage ne correspond pas à package.json.

Les scripts de package.json sont des commandes nommées, ce qui se rapproche le plus des targets MSBuild ou des goals Maven. l01-esm/package.json en a deux :

{
"name": "l01-esm",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node main.js",
"greet": "node main.js Ada Grace"
}
}

npm run affiche le script avant de l’exécuter ; node --run, ajouté dans Node.js 22, l’exécute sans npm :

> npm run greet
> l01-esm@1.0.0 greet
> node main.js Ada Grace
Hello, Ada!
Hello, Grace!
Bye, Ada!
l01-esm: main.js, typeof require = undefined
> node --run greet
Hello, Ada!
Hello, Grace!
Bye, Ada!
l01-esm: main.js, typeof require = undefined

Les deux ajoutent node_modules/.bin au PATH, si bien qu’un script peut appeler par leur nom les outils des packages installés. node --run ignore volontairement les scripts pre et post que npm exécute autour d’un script (exercice 2).

Le package.json de ce site déclare "type": "module" et des scripts qui appellent Astro : npm run build exécute astro build, trouvé dans node_modules/.bin.

GuitarAlchemist/ga montre deux pièges d’un projet JavaScript maintenu par des développeurs C# :

  • Apps/ga-client a à la fois un package-lock.json et un pnpm-lock.yaml. Ce sont les fichiers de verrouillage de deux gestionnaires de packages, npm et pnpm, et rien ne les maintient d’accord : un développeur qui lance npm install et un autre qui lance pnpm install peuvent obtenir des versions différentes de la même dépendance.
  • ReactComponents/ga-react-components/package.json liste @esbuild/win32-x64 dans devDependencies. Ce package déclare "os": ["win32"], et npm refuse de l’installer ailleurs. La CI du cours l’installe sur les trois OS : ça marche sous Windows, et sous Linux npm s’arrête avec :
npm error code EBADPLATFORM
npm error notsup Unsupported platform for @esbuild/win32-x64@0.25.12: wanted {"os":"win32","cpu":"x64"} (current: {"os":"linux","cpu":"x64"})
npm error notsup Valid os: win32
npm error notsup Actual os: linux
npm error notsup Valid cpu: x64
npm error notsup Actual cpu: x64

macOS échoue de la même façon. Le dossier n’a qu’un pnpm-lock.yaml, et pnpm 10 a installé sans un mot un package pour un autre OS sur ma machine, donc le projet s’installe probablement avec pnpm sur tous les OS, mais pas avec npm (à vérifier sous Linux et macOS). La correction habituelle consiste à laisser de côté les packages de plateforme : esbuild les liste tous en optionalDependencies, et chaque gestionnaire de packages installe celui qui correspond.

Node.js a deux systèmes de modules. CommonJS est arrivé en premier, avec Node.js en 2009 : require charge un fichier, module.exports est ce qu’il exporte. Les modules ES sont arrivés avec le langage dans ECMAScript 2015, et c’est ce que comprennent les navigateurs : import et export.

CommonJS Modules ES
Import const { hello } = require('./greet') import { hello } from './greet.js'
Export module.exports = { hello } export function hello…
Extension de fichier dans le chemin optionnelle obligatoire
Chargement synchrone, quand require s’exécute les imports sont résolus avant que le module s’exécute
Mode strict (leçon 3) seulement avec 'use strict' toujours
await au niveau supérieur non oui
Le dossier du fichier courant __dirname import.meta.dirname

Dans les deux, un module est un fichier : il n’y a pas d’espace de noms qui s’étende sur plusieurs fichiers, et rien n’est visible des autres fichiers sans être exporté. Par rapport à C#, export est public, tout le reste est private au fichier, et import nomme le fichier là où un using nommerait un espace de noms.

Node.js choisit le système de modules fichier par fichier :

  1. un fichier .mjs est un module ES, un fichier .cjs est du CommonJS ;
  2. un fichier .js suit le champ "type" du package.json le plus proche, "module" ou "commonjs" ;
  3. un fichier .js sans "type" au-dessus de lui est analysé comme CommonJS, puis analysé à nouveau comme module ES s’il contient import ou export (détection de syntaxe).

Le troisième cas fonctionne, avec un avertissement. l01-interop/package.json n’a pas de "type", et detect.js contient un import :

> node detect.js
(node:PID) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of l01-interop/detect.js is not specified and it doesn't parse as CommonJS.
Reparsing as ES module because module syntax was detected. This incurs a performance overhead.
To eliminate this warning, add "type": "module" to l01-interop/package.json.
(Use `node --trace-warnings ...` to show where the warning was created)
8 undefined

La version module ES, dans un package avec "type": "module" :

l01-esm/greet.js
// Un module ES : ce qu'il exporte est déclaré avec export, tout le reste reste privé au fichier
const punctuation = '!';
export function hello(name) {
return `Hello, ${capitalize(name)}${punctuation}`;
}
export default function farewell(name) {
return `Bye, ${capitalize(name)}${punctuation}`;
}
function capitalize(text) {
return text.charAt(0).toUpperCase() + text.slice(1);
}
l01-esm/main.js
// "type": "module" dans package.json : les fichiers .js sont des modules ES
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import farewell, { hello } from './greet.js'; // l'extension est obligatoire
const names = process.argv.length > 2 ? process.argv.slice(2) : ['world'];
for (const name of names) {
console.log(hello(name));
}
console.log(farewell(names[0]));
// Pas de __dirname dans un module ES : import.meta donne l'emplacement du module lui-même
const manifest = JSON.parse(await readFile(path.join(import.meta.dirname, 'package.json'), 'utf8')); // await au niveau supérieur
console.log(`${manifest.name}: ${path.basename(import.meta.filename)}, typeof require = ${typeof require}`);
> node main.js
Hello, World!
Bye, World!
l01-esm: main.js, typeof require = undefined

Un module a un nombre quelconque d’exports nommés et au plus un export par défaut ; import farewell, { hello } prend celui par défaut sous un nom que tu choisis, et hello sous son propre nom. Les modules intégrés commencent par node:.

La version CommonJS, dans un package avec "type": "commonjs" :

l01-cjs/greet.js
// Un module CommonJS : ce qu'il exporte est ce qui se retrouve dans module.exports
const punctuation = '!';
function hello(name) {
return `Hello, ${capitalize(name)}${punctuation}`;
}
function capitalize(text) {
return text.charAt(0).toUpperCase() + text.slice(1);
}
module.exports = { hello };
l01-cjs/main.js
// "type": "commonjs" dans package.json : les fichiers .js sont des modules CommonJS
const fs = require('node:fs');
const path = require('node:path');
const { hello } = require('./greet'); // l'extension peut être omise
const names = process.argv.length > 2 ? process.argv.slice(2) : ['world'];
for (const name of names) {
console.log(hello(name));
}
const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
console.log(`${manifest.name}: ${path.basename(__filename)}, typeof require = ${typeof require}`);
> node main.js Ada
Hello, Ada!
l01-cjs: main.js, typeof require = function

require dans un module ES. Le package.json du dossier du cours indique "type": "module" :

errors/l01_require_in_esm.js
const fs = require('node:fs');
console.log(fs.existsSync('package.json'));
errors/l01_require_in_esm.js:2
const fs = require('node:fs');
^
ReferenceError: require is not defined in ES module scope, you can use import instead
This file is being treated as an ES module because it has a '.js' file extension and 'package.json' contains "type": "module". To treat it as a CommonJS script, rename it to use the '.cjs' file extension.
Node.js v24.21.0

import dans un fichier CommonJS :

errors/l01_import_in_cjs.cjs
import { readFileSync } from 'node:fs';
console.log(readFileSync);
(node:PID) Warning: Failed to load the ES module: errors/l01_import_in_cjs.cjs. Make sure to set "type": "module" in the nearest package.json file or use the .mjs extension.
(Use `node --trace-warnings ...` to show where the warning was created)
errors/l01_import_in_cjs.cjs:2
import { readFileSync } from 'node:fs';
^^^^^^
SyntaxError: Cannot use import statement outside a module
Node.js v24.21.0

L’erreur qui surprend le plus les développeurs C# et Java, et les développeurs TypeScript aussi : un import de module ES sans son extension.

errors/l01_missing_extension.js
import { show } from '../examples/show';
show('never', 'printed');

Exécuté depuis le dossier errors :

node:internal/modules/run_main:107
triggerUncaughtException(
^
Error [ERR_MODULE_NOT_FOUND]: Cannot find module 'examples/show' imported from errors/l01_missing_extension.js
Did you mean to import "../examples/show.js"?
{
code: 'ERR_MODULE_NOT_FOUND',
url: 'examples/show'
}
Node.js v24.21.0

Exécuté depuis le dossier du cours, avec node errors/l01_missing_extension.js, le même fichier donne la même erreur sans la ligne Did you mean to import "../examples/show.js"?. Pour trouver cette suggestion, Node.js demande au résolveur CommonJS où require aurait trouvé le fichier (resolve.js, lignes 888-895), mais il donne au résolveur un module parent sans nom de fichier, et pour un tel parent le résolveur cherche les chemins relatifs à partir du dossier courant (loader.js, lignes 1021-1028). La suggestion n’apparaît que si ../examples/show.js existe aussi relativement à l’endroit où tu as tapé la commande. L’erreur elle-même est juste dans les deux cas : ajoute l’extension.

Un module ES peut importer un module CommonJS. module.exports devient l’export par défaut, et Node.js trouve les exports nommés en lisant le source à la recherche d’affectations comme exports.half = … :

l01-interop/legacy.cjs
exports.half = (n) => n / 2;
exports.kind = 'CommonJS';
l01-interop/from-esm.mjs
import legacy, { half } from './legacy.cjs';
console.log('default import:', legacy);
console.log('named import: ', half(3));
default import: { half: [Function (anonymous)], kind: 'CommonJS' }
named import: 1.5

L’autre sens, require d’un module ES, était autrefois impossible. Il fonctionne sans option depuis Node.js 22.12, et la documentation de Node.js 24 ne le qualifie plus d’expérimental, tant que le module n’utilise pas de await au niveau supérieur :

l01-interop/modern.mjs
export const twice = (n) => n * 2;
export default 'ES module';
l01-interop/from-cjs.cjs
const modern = require('./modern.mjs');
console.log(modern);
console.log(modern.twice(21), modern.default);
[Module: null prototype] {
__esModule: true,
default: 'ES module',
twice: [Function: twice]
}
42 ES module

GuitarAlchemist/ga utilise les extensions comme prévu : ses front ends sont des packages "type": "module", et le seul fichier de configuration écrit avec module.exports s’appelle postcss.config.cjs : nommé .js, ce serait un module ES, où module n’existe pas. Ce site nomme ses scripts .mjs, comme scripts/sync-streeling.mjs, ce qui est redondant sous "type": "module" et reste correct si le champ est retiré.

Lequel écrire ? Les modules ES, pour du nouveau code : ce sont le standard, le format du navigateur, et ce que TypeScript et les bundlers produisent par défaut. Tu rencontres encore CommonJS dans des packages plus anciens et des fichiers de configuration.

  • ECMAScript est le langage ; V8 est le moteur ; Node.js est un hôte qui ajoute les fichiers, les processus et les modules. Un navigateur est un autre hôte.
  • Node.js 24 est la ligne LTS active jusqu’en octobre 2026 et reste supportée jusqu’en avril 2028 ; npm est fourni avec.
  • package.json est le manifeste et package-lock.json le fichier de verrouillage ; commite les deux, et installe en CI avec npm ci.
  • npm install écrit des plages ^ : toute version mineure ou corrective ultérieure est acceptée, et c’est le fichier de verrouillage qui fixe la version exacte.
  • Un fichier .js est un module ES ou un module CommonJS selon le "type" du package.json le plus proche ; .mjs et .cjs imposent le choix.
  • Les modules ES exigent l’extension du fichier dans les imports relatifs, sont toujours stricts, et permettent await au niveau supérieur.
  1. Renomme l01-cjs/greet.js en greet.mjs et réécris-le comme module ES, mais garde main.js comme module CommonJS. Que faut-il changer dans main.js ?
Solution

Seulement le chemin : require('./greet') ne trouve pas greet.mjs, car require n’essaie que les extensions .js, .json et .node. solutions/l01-ex1 :

main.js
const greet = require('./greet.mjs');
console.log(greet.hello('world'));
console.log('exports of greet.mjs:', Object.keys(greet));
Hello, World!
exports of greet.mjs: [ 'hello' ]

greet.mjs est chargé de façon synchrone parce qu’il n’a pas de await au niveau supérieur. Ajoutes-en un, et require lève ERR_REQUIRE_ASYNC_MODULE.

  1. Dans une copie de l01-esm, ajoute un script "pregreet": "node -e \"console.log('pregreet runs')\"". Lance npm run greet, puis node --run greet. Qu’est-ce qui diffère ?
Solution
> npm run greet
> l01-esm@1.0.0 pregreet
> node -e "console.log('pregreet runs')"
pregreet runs
> l01-esm@1.0.0 greet
> node main.js Ada Grace
Hello, Ada!
Hello, Grace!
Bye, Ada!
l01-esm: main.js, typeof require = undefined
> node --run greet
Hello, Ada!
Hello, Grace!
Bye, Ada!
l01-esm: main.js, typeof require = undefined

npm exécute pregreet avant greet et affiche chaque commande ; node --run n’exécute que greet, en silence. La documentation de Node.js présente cela comme une limitation délibérée. Un projet qui s’appuie sur des scripts pre, comme pretest pour builder avant de tester, doit garder npm run.

  1. Ajoute "type": "module" à l01-interop/package.json et relance node detect.js, puis node from-cjs.cjs. Qu’est-ce qui change, et pourquoi les fichiers .mjs et .cjs ne sont-ils pas concernés ?
Solution

node detect.js affiche 8 undefined sans l’avertissement : le fichier .js est maintenant un module ES par déclaration, et Node.js ne l’analyse pas deux fois. from-cjs.cjs, legacy.cjs et modern.mjs se comportent exactement comme avant, puisque leur extension décide avant que "type" soit lu. Ajouter "type": "module" à un package ne change que ses fichiers .js : c’est pourquoi un projet qui bascule renomme souvent quelques fichiers de configuration en .cjs, comme le postcss.config.cjs de GA.