Une formation Delicious Insights assurée par Christophe Porteneuve
const christophe = {
family: { wife: 'Élodie', sons: ['Maxence', 'Elliott'] },
city: 'Paris, FR',
company: 'Delicious Insights',
trainings: [
'Node.js', 'React PWA', 'TypeScript', 'React Avancé', 'TypeScript Avancé',
'ES Total', 'Git Total'
],
jsSince: 1995,
nodeSince: 2009,
claimsToFame: [
'dotJS',
'Prototype.js',
'script.aculo.us',
'Bien Développer pour le Web 2.0',
'NodeSchool Paris',
],
}
| nov. 2009 | Annonce officielle (Ryan Dahl @ JSConf.EU) |
| juin 2010 | Nodejitsu |
| nov. 2010 | Express 1.0 |
| mai 2011 | npm 1.0 |
| déc. 2011 | Windows Azure supporte Node.js |
| 2013–2015 | Déferlante de gros usages (eBay, PayPal, LinkedIn, Walmart, Uber, Medium, Yahoo!, IBM…). Support officiel dans VS. |
| Aujourd’hui | > 2,5M+ modules dans npm (+1000/j, 55B DLs/sem.), Top-5 PaaS, conférences JS/Node pratiquement toutes les semaines. |
const path = require('path')
const os = require('os')
const nodeVersion = process.version
const hostName = os.hostname().split('.')[0]
const progName = path.basename(__filename)
console.log('Hello world!')
console.log('This is', progName, 'running on', hostName, 'using Node', nodeVersion)
node index.js
Hello world!
This is index.js running on CodeForTheWin using Node v20.7.0
import { createServer } from 'http'
createServer((request, response) => {
response.writeHead(200, { 'Content-Type': 'text/plain' })
response.end(new Date().toString())
}).listen(3333, () => {
console.log('Server running at http://127.0.0.1:3333/')
})
chmod +x richer-program.js
#! /usr/bin/env node
Partout… sauf sur Windows (ou alors WSL).
Le format historique, synchrone, popularisé par Node
On parle en fait de CommonJS/1.0 (plein d'extensions…)
Très facile à comprendre :
exports.key = value
mod = require('pathspec')
Niveau langage, donc analysables statiquement ; pas de soucis de dépendances circulaires grâce à l’export de live bindings.
Mode strict par défaut (comme les corps de classes).
|
|
// Import intégral dans un « namespace »
import * as types from '../constants/ActionTypes.js'
// Import nommé de l’export par défaut, imports homonymes d’exports nommés
import React, { PropTypes, Component } from 'react'
// Ou juste un des deux modes :
import classnames from 'classnames'
import { SHOW_ALL, SHOW_COMPLETED, SHOW_ACTIVE } from '../constants/TodoFilters.js'
// Import renommé
import { makeHigherOrder as wrap } from 'toolkit'
.mjs ou package.json parent
avec "type": "module"
import(…)) est
possible
import.meta fournit les métadonnées (URL du chemin,
notamment)
package.json d’un module peut contrôler ses
exports ESM séparément via son champ exports.
const fs = require('node:fs') // ou require('fs'), mais soyons explicites
// import fs from 'node:fs' (export par défaut)
// import * as fs from 'node:fs' (consolidation des exports nommés)
node_modules
const { invoke } = require('underscore')
// import { invoke } from 'underscore'
const HumanView = require('./views/human_view') // .default si on requiert un ESM
// import HumanView from './views/human_view.mjs'
require('/usr/shared/node_modules/house_framework')
// import '/usr/shared/node_modules/house_framework/index.js'
Évitez de fournir une extension explicite dans le
require…
.js, .json ou
.node (binaire)
package.json doté d’une clé
main
index.js
index.json
index.node
Les callbacks, ou fonctions de rappel, existent depuis ES1 (fonctions de premier ordre).
ES2 a ajouté les fonctions d’ordre supérieur, qui
permettent de les passer en arguments.
Traditionnellement, on passe un callback unique pour un traitement asynchrone one-shot, ou associé à un type d’événement.
La valeur de retour du callback est généralement sans importance.
En raison de leur exploitation asynchrone, les callbacks ne proposent pas le flux habituel de propagation des exceptions (ils se déclenchent généralement en racine de stack), de sorte que pour faire « remonter » une erreur, on doit souvent la repasser manuellement en argument. Ainsi, Node.js a établi une convention error-first sur tous les callbacks hors événements :
function loadConfig(cb) {
fs.readFile(CONFIG_PATH, { encoding: 'utf-8' }, (err, str) => {
if (err) {
return cb(err)
}
return cb(null, JSON.parse(str))
})
}
Fastidieux et bourré de risques…
Le Callback Hell / Pyramid of Doom :
db.users.findById(req.session.userId, (err, user) => {
// -- snipped: error forwarding --
user.posts.findRecent({ page: req.params.page, limit: PAGE_SIZE }, (err, posts) => {
// -- snipped: error forwarding --
posts[0].comments.findRecent((err, comments) => {
// -- snipped: error forwarding --
res.render('users/show', { user, posts, comments })
})
})
})
Exceptions et long stacks :
try {
fs.readFile('foo.xml', { encoding: 'utf-8' }, (err, xml) => {
if (err) {
throw err // Hu-ho…
}
JSON.parse(xml) // => Lève une `SyntaxError`
})
} catch (err) {
// Jamais atteint ! L’exception est levée hors de ce contexte.
}
La poussière sous le tapis :
fs.readFile('config.json', { encoding: 'utf-8' }, (err, json) => {
// Et hop, on oublie de traiter l’erreur ou de la refiler…
processConfig(JSON.parse(json))
})
Après l’heure, c’est plus l’heure :
notifier.emit('ready')
notifier.on('ready', () => {
// Jamais appelé : l’événement a été déclenché avant…
})
Appels uniques qui sont en fait multiples :
function connect(uri, cb) {
if (__connection) {
process.nextTick(cb, null, __connection)
// Oubli de `return`, par exemple…
}
db.setup(uri, (err, cnx) => {
if (err) {
return cb(err)
}
__connection = cnx
cb(null, __connection)
// Et hop, 2e fois !
}
}
Appels conflictuels (erreur + succès) :
function connect(uri, cb) {
db.connect(uri, (err, cnx) => {
if (err) {
cb(err)
// Oubli de `return` !
}
__connection = cnx
cb(null, __connection)
})
}
function connect(uri, cb) {
if (__connection) {
return cb(null, __connection)
// cb synchrone !
}
db.connect(uri, (err, cnx) => {
if (err) {
return cb(err)
// cb asynchrone
}
__connection = cnx
cb(null, __connection)
// cb asynchrone
})
}
Les promesses, abstraction déjà ancienne, blindent contre une bonne partie de ces pièges.
Une promesse est un objet qui représente l’aboutissement d’un traitement asynchrone.
Elle ne transtionne qu’une seule fois, dans un seul sens (pas d’appels multiples / en conflit).
L’appel est garanti (même « après la bataille »), et garanti asynchrone (pas de Zalgo).
Les levées d’exceptions sont capturées et converties en rejets de promesse.
Standard de facto des promesses en JS. Domenic Denicola, 2012*. Simple comme tout. Pour commencer :
new Promise(), qui
utilise le revealing constructor pattern.
then(), qui prend jusqu’à 2 callbacks, optionnels :
succès et erreur. Ils sont garantis asynchrones et exclusifs.
Une tonne de bibliothèques proposent des promesses compatibles, notamment Bluebird. Et c’est natif en ES2015.
function readConfig(path) {
return new Promise((resolve, reject) => {
fs.readFile(path, { encoding: 'utf-8' }, (err, text) => {
if (err) {
return reject(err)
}
try {
resolve(JSON.parse(text))
} catch (err) {
reject(err)
}
})
})
}
readConfig('config.json')
.then((obj) => obj.user.email)
.then((email) => console.log('EMAIL:', email), (err) => console.error('OOPS:', err))
.catch((err) => console.error('DAMNED:', err))
Toute exception levée, une fois la chaîne de promesse en place, est convertie en promesse rejetée.
Toute valeur renvoyée qui n’est pas une promesse est convertie en
promesse accomplie via Promise.resolve()
Le renvoi d’une promesse l’incruste dans la chaîne : c’est la composition de promesses.
On peut même incruster un « thenable » : un objet doté d’une
méthode then() compatible…
fetch('https://jsonplaceholder.typicode.com/users/1')
// Incrustation de promesse intermédiaire (équiv. waterfall, sans l’imbrication)
.then((res) => res.json())
// La déstructuration ci-dessous va échouer, car `names` n’existe pas.
// La `TypeError` va être convertie en promesse rejetée. Mais si ça avait marché,
// l’objet renvoyé aurait été converti en promesse accomplie.
.then(({ names: { first, last }, website }) => ({ first, last, website }))
// On ne verra jamais ce log, car il correspond à un callback de succès / accomplissement
.then(console.log)
// En revanche, on verra celui-ci, la chaîne étant en rejet à ce stade.
.catch((err) => console.error('OOPS', err))
// …et celui-ci aussi, `catch()` neutralisant le rejet, comme dans un `try`…`catch` classique.
.then(() => console.log('DONE'))
Blinder l’initialisation de la chaîne
function setupCall() {
gabuzomeu() // => ReferenceError
return new Promise((resolve, reject) => resolve(42))
}
// Cette chaîne ne protègera pas de l’erreur, qui a lieu hors du périmètre de la promesse…
setupCall()
.then(console.log)
.catch((err) => console.log('OOPS', err))
On préfèrera donc ceci :
function setupCall() {
return Promise.resolve()
.then(() => {
gabuzomeu() // => ReferenceError, deviendra un rejet
return new Promise((resolve, reject) => resolve(42))
})
}
Toujours finir par un catch()
D’un côté, la capture automatique des levées d’exception au sein
d’une chaîne de promesse est pratique, car elle permet une
certaine préservation de contexte et améliore la robustesse. De
l’autre, si on oublie le catch() final, elle passe
sous le tapis…
Dans Node, au même titre que
process.on('uncaughtException') pour les exceptions
non rattrapées et les événements 'error' non traités,
on a process.on('unhandledRejection'). Depuis Node 7,
ne pas traiter les exceptions rejetées est officiellement déprécié
(code DEP0018), et on s’attend prochainement à ce que ça fasse
comme pour les exceptions et erreurs : par défaut, ça terminera le
processus avec un code d’erreur.
C’est notamment utile pour capturer des problèmes qui surviendraient dans le dernier callback (que ce soit succès ou erreur).
Promise.resolve()
// Premier rejet
.then(gabuzomeu)
// Le 2e callback ici gère l’erreur… mais il est défectueux lui-même !
.then(console.log, (err) => shadok(err))
// Ouf, heureusement qu’on avait l’attrape-tout final.
.catch((err) => console.error('OOPS', err))
Paralléliser intelligemment
Promise.all([
Entry.getEntries(filter),
Entry.tags(),
Entry.count(),
])
.then(([entries, tags, count]) => res.render('entries/index', { entries, tags, count }))
Ça sera encore plus criant avec async /
await.
Dans les rares cas où on veut du “first past the post”,
on utiliserait Promise.race()…
Imbrication excessive
Quand on croit que les promesses sont juste une autre manière d’écrire des callbacks, on a tendance à reproduire le style…
fetch('https://jsonplaceholder.typicode.com/users/1')
.then((res) => {
return res.json()
.then((obj) => {
return db.persistUser(obj)
.then((id) => res.redirect(`/my-users${id}`)) // BEUARK™
})
})
.catch(console.error)
Ça vient souvent d’une incompréhension de l’incrustation de promesse (composition) :
fetch('https://jsonplaceholder.typicode.com/users/1')
.then((res) => res.json())
.then((obj) => db.persistUser(obj))
.then((id) => res.redirect(`/my-users${id}`))
.catch(console.error) // SWEET!
Les promesses ES2015 ne sont pas annulables : quand c’est parti, c’est parti
Dans le même esprit, il n’y a pas de timeout.
Ce genre de chose est simulable, en mode Powerbidouille™, avec
Promise.race(). Par exemple :
return Promise.race([
originalPromise,
new Promise((resolve, reject) => {
setTimeout(() => reject(new Error('Timeout')), timeout)
})
])
Un chantier phénoménal,
Cancellation API, végète au stade 1 depuis juillet 2018 en attendant de voir si
AbortController et AbortSignal gagnent
la partie. Node 15+ les prend notamment en charge pour les flux en
lecture, timers et événements. Côté browser, c'est surtout Fetch
qui s’en sert.
Une promesse représente un traitement one-shot, comme un appel réseau ou un calcul asynchrone. Ce n’est pas la meilleure primitive pour traiter un flux de données, ou un flux d’événements.
On préfèrera alors les observables, généralement au travers de l’excellent RxJS. Ces derniers sont également utilisables pour les cas one-shot, même si c’est généralement moins pertinent en isolation.
Rx.Observable
.timer(0, 5000)
.take(10)
.map(() => Math.floor(Math.random() * 5) + 1)
.flatMap((x) => Rx.Observable.ajax({
crossDomain: true,
responseType: 'text',
url: `https://baconipsum.com/api/?type=all-meat¶s=${x}&format=html`,
}))
.subscribe((req) => { container.innerHTML = req.response })
La révolution. Intègre la gestion des promesses
directement dans la syntaxe, ce qui les rend éminemment plus
lisibles
(et présente même de jolis bonus en performances). ES2017 /
evergreens / Node 7.6 / Babel.
Une fonction peut être déclarée async (mot-clé devant
sa déclaration/expression).
Elle renvoie alors implicitement une promesse (tout son code est enrobé dans une chaîne de promesse).
Son code immédiat (sa portée racine) peut utiliser le mot-clé
await pour
suspendre sa propre exécution le temps qu’une
autre promesse / fonction asynchrone s’exécute.
Cette suspension est non-bloquante : le thread
courant récupère la main pour autre chose.
Une fois la promesse établie, à la première opportunité, la
fonction « dégèle ».
Si la promesse est accomplie, on récupère sa valeur. Sinon,
l’exception de rejet est levée.
Absolument pas. En fait, c’est entièrement basé
sur les promesses, ça leur est intrinsèquement lié. Mais cette
syntaxe nous permet de
faire du code non-bloquant qui ressemble à du code
bloquant
(et donc est extrêmement simple à lire) : on récupère les
if-else, les boucles, le try…catch, etc.
async function showPost(req, res) {
try {
const post = await db.posts.find(req.params.id)
const author = await post.author.find()
const comments = await post.comments.find({ limit: 10 })
res.render('posts/show', { post, author, comments })
} catch (err) {
handleError(err)
}
}
import { delay, limit, map } from 'awaiting'
async function superDuper(req, res) {
await delay(500)
const user = await limit(User.find(req.params.id), 2000)
const payloads = await map(req.params.urls, 3, fetchText)
await user.processPayloads(payloads)
res.json({ result: 'success' })
}
Le séquençage inutile d’opérations indépendantes les unes des autres, « façon code bloquant »
const entries = await Entry.getEntries(filter)
const tags = await Entry.tags()
const count = await Entry.count()
Alors on parallélise, et c’est 😍 :
const [entries, tags, count] = await Promise.all([
Entry.getEntries(filter), Entry.tags(), Entry.count()
])
Le return await débile
async function fetchJSON(url) {
return await fetch(url).then((res) => res.json())
}
Le problème ici, c’est que cette fonction va suspendre son exécution jusqu’à avoir récupéré le contenu et l’avoir parsé. Ça empêche, préemptivement, toute parallélisation utile au niveau du code appelant, ce qui est super dommage.
Il suffit de renvoyer une promesse non-attendue ; ici
spécifiquement, async-await est même superflu !
« Génial, ça devient bloquant et tout ! » 🤦🏼♀️
const animations = [animPromise1, animPromise2, animPromise3 /*, … */]
// EPIC FAIL
const results = animations.map(async (anim) => await anim)
Une fonction async enrobe son traitement dans une
promesse, et renvoie celle-ci. Ici, on obtient un tableau de
(nouvelles) promesses (au lieu de leurs résultats), et les
animations restent parallélisées. C’est assez simple à faire en
séquentiel :
// Dans une fonction async…
const results = []
for (const anim of animations) {
results.push(await anim())
}
…mais si nos promesses / fonctions asynchrones sont en fait
indépendantes, et qu’on souhaite juste les résultats dans l’ordre
tout en parallélisant, ce séquençage forcé est contre-productif,
on préfèrera Promise.all(). Et si on veut traiter au
fil de l’eau, sans attendre la toute fin mais en respectant
l’ordre, ES2018* nous couvre :
for await (const anim of anims) { /* … */ }
Le bébé d’izs (Isaac Z. Schlueter), ex-lead Node
Racheté en 2020 par GitHub (lui même faisant partie de Microsoft)
Le référentiel officiel des « paquets » node
À l’origine des fichiers
package.json
Trivial à utiliser (y compris comme auteur de paquet)
Vital à l’écosystème node/JS, au même titre que GitHub
npm install async
(barre de progression…)
added 1 package, and audited 2 packages in 583ms
found 0 vulnerabilities
npm install --save joi # Défaut pour npm5+ ; version courte : -S
(barre de progression…)
added 11 packages, and audited 12 packages in 971ms
found 0 vulnerabilities
Lorsqu'un module fournit des « exécutables » auxquels on souhaite accéder de n’importe où (ex. npm, Docco, RequireJS, Uglify, CoffeeScript…), il est préférable de l’installer en global.
On découpe de plus en plus fréquemment le paquet
…-cli pour l'outil global, et le paquet de base pour
l'implémentation locale (ex. Babel, Grunt, Ember, etc.)
npm install --global gtop
(…)
added 62 packages, and audited 63 packages in 4s
Un module peut attirer l’attention là-dessus en activant son
drapeau preferGlobal.
man package.json
npm init
name et
version (
semver)
files
homepage,
repository,
bugs
author (nom, e-mail,
URL), contributors (idem) et
license
main,
scripts, config, bin et
man
description,
keywords
directories (lib, bin, man, doc,
example)
dependencies ( --save)
optionalDependencies (
--save-optional)
peerDependencies
devDependencies (
--save-dev)
engines
os,
cpu
search,
view
install, update,
ls
run
* + raccourcis (notamment start et
test), config (overrides persistants
de réglages par défaut)
docs,
home,
bugs
init,
publish
* officiellement
run-script
global : le scope globalprocess et console (modules homonymes)
Buffer, issue du module
buffer
setTimeout, clearTimeout,
setInterval et clearInterval, issus de
timers
require, module et
exports si en CJS ou CLI (import() si en
ESM ou CLI)
__dirname et __filename si en CJS (import.meta.url
si en ESM)
WebAssembly (8+)URL et URLSearchParams (10+)queueMicrotask, TextDecoder et
TextEncoder (11+),
performance (14+),AbortController, Event,
EventTarget, MessageChannel,
MessageEvent, MessagePort (15+)
structuredClone, DOMException,
fetch / Request /
Response / Headers /
FormData
et la crypto web (17 / 17.6+)
Parce qu’il n’y a pas que String et
Number dans la vie.
String stocke forcément en ucs2 (UTF-16LE)
Number est forcément un Double avec l’
endianness du système
En passant par Buffer, on a deux avantages :
data.
readable et méthodes read([size]) et
pause(). data).
end dans les deux modes, quand les
données sont totalement épuisées.
Readable), écriture (
Writable) ou R/W ( Duplex), un cas
spécial de R/W étant les flux de transformation (
Transform).
ReadableStream, WritableStream,
TransformStream, etc.) si vous souhaitez écrire du
code universel. Des convertisseurs entre flux Node et flux
WHATWG sont mis à disposition.
import { createReadStream, createWriteStream } from 'fs'
import { fileURLToPath } from 'url'
const fileName = fileURLToPath(import.meta.url)
const src = createReadStream(fileName)
const dest = createWriteStream(`${fileName}.copy`)
src.on('data', (chunk) => dest.write(chunk))
// En fait ça c'est optionnel, mais histoire d’être explicite…
src.on('end', () => dest.end())
import { createReadStream, createWriteStream } from 'fs'
import { fileURLToPath } from 'url'
const fileName = fileURLToPath(import.meta.url)
const src = createReadStream(fileName)
const dest = createWriteStream(`${fileName}.copy`)
src.on('readable', () => {
let buf
// Attention, cette boucle gère mal la *backpressure*
while ((buf = src.read())) {
dest.write(buf)
}
})
// En fait ça c'est optionnel, mais histoire d’être explicite…
src.on('end', () => dest.end())
Un flux fournit (ou reçoit) des Buffers par défaut.
Pour travailler directement avec des Strings, il
suffit de lui associer un encodage (le plus souvent
utf-8), comme chaque fois qu’on travaille avec un
Buffer.
import { createReadStream } from 'fs'
import { setTimeout as delay } from 'timers/promises'
const src1 = createReadStream('./lorem.txt')
const src2 = createReadStream('./lorem.txt')
// KO:
for await (const buf of src1) {
process.stdout.write(buf)
console.log('\n', '-1'.repeat(10))
}
await delay(5000)
// // OK:
src2.setEncoding('utf-8')
for await (const str of src2) {
// str est une String, et non un Buffer ; par ailleurs, pas de découpage à tort
// d'un codepoint au sein du texte.
process.stdout.write(str)
console.log('\n', '-2'.repeat(10))
}
.pipe fait ça très bien :-)
import { createReadStream, createWriteStream } from 'fs'
import { fileURLToPath } from 'url'
const fileName = fileURLToPath(import.meta.url)
const src = createReadStream(fileName)
const dest = createWriteStream(process.argv[2] || `${fileName}.copy`)
src.pipe(dest)
src.pipe(process.stdout)
// Sinon on peut faire un wget…
import { createWriteStream } from 'fs'
import { get } from 'http'
const [
src = 'http://delicious-insights.com/training.json',
dest = 'sessions.json'
] = process.argv.slice(2)
get(src, (res) => res.pipe(createWriteStream(dest)))
Stream Handbook (npm)
Stream Adventure (npm)
Bon, console.*, ça va bien 5 minutes…
Un vrai débogueur visuel, c’est possible ? Bien sûr !
node --inspect + Chrome DevTools
Une majeure tous les 6 mois
Les versions paires, en avril, deviennent LTS en octobre
Nombreuses mises à jour mineures / patchlevel pour sécurité ;
au moins tous les deux mois, comme par exemple ici pour
août 2023.
Effectué sur toutes les LTS (maintenance et active) et
l’actuelle,
donc en ce moment (début octobre 2023) les branches 18 et 20.
Dépréciation occasionnelle des API noyau présentant un danger,
telles que new Buffer(…), domain,
fs.exists(…) ou process.binding(). On
peut
demander à ESLint de nous les signaler.
npm audit (fix)
npm Inc. a racheté en avril 2018 ^Lift Security et la Node
Security Platform. Cette dernière a cessé de fonctionner seule le
30/09/2018 dernier. Du coup, les fonctionnalités de
nsp sont intégrées directement à
npm lors des installations et mises à jour :
npm audit fix permet souvent d’ajuster nos
dépendances pour corriger les problèmes ; certaines corrections
peuvent nécessiter une action manuelle, qui est souvent décrite
dans le rapport détaillé.
Depuis octobre 2017, GitHub permet par ailleurs de signaler les vulnérabilités (JS et Ruby, notamment) sur la branche principale de vos projets, ce qui est assez pratique. Les abonnements de niveau Enterprise (ne pas confondre avec GitHub Enterprise, la solution on-premise) ont aussi accès à GitHub Advanced Security, qui fournit notamment CodeQL pour JS.
Snyk* est un service orienté CI qui
supervise lui aussi vos dépendances à la recherche de
vulnérabilités ; c’est un complément intéressant à
npm audit, et il propose des intégrations CI, UX,
GitHub, etc. qui sont sympa.
Si vous avez besoin de blinder absolument l’utilisation des modules tiers.
Outil desktop + registry sécurisée qui audite en permanence les publications de module sur tout un tas de critères, et bloque l’installation des modules problématiques.
Fait partie de la plate-forme NodeSource (avec N|Solid, une surcouche de la runtime Node), et est donc payant au-delà d’un utilisateur.
postinstall (et autres)
Par défaut, lorsque npm installe des modules, il
exécute leurs scripts de cycle de vie associés
(prepublish*, prepare,
preinstall, install et
postinstall).
Ces scripts peuvent contenir tout et n’importe quoi, aussi vous pouvez choisir une approche un peu bourrine qui consiste à ne pas les exécuter :
npm install --ignore-scripts
Gardez toutefois à l’esprit que de nombreux paquets ont du travail valide voire nécessaire dans leurs scripts d’installation (builds d’extensions binaires, etc.), aussi le mieux reste de « faire vos devoirs » sur les modules que vous choisissez d’installer.
Par secret, on entend toute donnée dont la connaissance par un tiers peut créer une vulnérabilité (mots de passes, identifiants, clés API, tokens, clés de chiffrement, URLs authentifiées de connexion…)
Ces données ne doivent jamais figurer dans le code ni être versionnées.
Elles doivent être fournies par l’environnement (au sens POSIX du terme).
En production, le PaaS / serveur hôte les fournira (configuration Heroku, AWS Secrets Manager, Azure Key Vault, GCP Cloud KMS…).
En développement, on utilisera généralement un
fichier d’environnement non versionnable, souvent
de type .env, lu par
dotenv-safe.
Avec le RGPD, c’est encore plus brûlant…
Hachage Bcrypt*
avec un salt adapté et 10–16 rounds en production (2–4 en
dev). Pour du contenu trop long, on peut pré-hacher en SHA512. La
référence est
bcryptjs,
plus facile à installer/builder que bcrypt.
Tu peux carrément la jouer au top avec Argon2id si tu y tiens… (par exemple avec ça)
Classiquement : e-mails, n°s de Sécurité Sociale, de CNI, de passeport, de permis de conduire, de téléphone.
Chiffrage symétrique avec une clé secrète et un IV dynamique, en AES256** ou, au pire, AES192 ou AES128.
sqreen est un service qui s’intègre directement à votre codebase pour repérer à la volée toute tentative d’attaque (injection ORM/ODM, XSS, DDoS) ou d’activité suspecte des utilisateurs, et les empêcher de fonctionner.
Il fonctionne en analysant les requêtes qui partent vers les bases de données, en regard notamment des paramètres de la requête web / API en cours, mais aussi en appliquant des règles métier sur tout un tas de critères, comme la fréquence d’appels par un même utilisateur à certains points d’accès, etc.
Extensible à coup de plugins, il offre de nombreuses possibilités.
Modèle SaaS, avec une couche gratuite amplement suffisante pour se faire la main.
git clone https://github.com/deliciousinsights/toptunez
cd toptunez
npm install
Non, il y aurait vraiment trop de choses à rajouter par-dessus.
Idem pour Hapi (dont l’avenir semble hélas compromis), et Koa
(même 2) reste à la marge.
Si vous y tenez absolument, un
dépôt de départ
(boilerplate pour du REST basé 100% Express est
disponible,
mais je ne le recommande pas.
C’est la référence pour les API REST en Node, avec d’excellentes performancess et beaucoup de fonctionnalités intégrées.
Utilisé notamment par npm, Netflix, Pinterest… La doc est un peu
spartiate mais ça vaut le coup.
Notez aussi que c'est un moteur indépendant, pas une surcouche
d’Express.
Par ailleurs, pour évoluer vers du GraphQL on n’aura pas besoin de
remettre ce choix en question :
la v2 du serveur Apollo se pose très bien par-dessus.
En direct : ✅ Routes ✅ Versions ✅ Hypermédia ✅ Rate limiting ✅ Formats variables ✅ Aide code client
Via plugins : 📦 Optimisation cache HTTP 📦 Validation des requêtes entrantes 📦 Middlewares / Services
Centré sur la perf, avec une architecture modulaire et scopée très intéressante.
A atteint une maturité et un écosystème suffisants, ce serait mon choix par défaut aujourd'hui.
Pas encore eu le temps de migrer cette formation dessus, désolé 😳. Mais le jour où je le fais, je vous notifierai pour accéder aux nouvelles ressources !
Le point le plus connu : les ressources
Une ressource est définie par une URL de base, à partir de laquelle on peut déduire des points d’accès conventionnels, par méthode HTTP + suffixe normalisé.
Supposons une URL de base /posts pour des articles :
| Méthode | URL | Action |
|---|---|---|
| GET | /posts | Listing (filtrable par query string) |
| POST | /posts | Create : création initiale de la ressource |
| GET | /posts/:id | Read : contenu de la ressource |
| PATCH | /posts/:id | Update : mise à jour partielle de la ressource |
| DELETE | /posts/:id | Destroy : suppression de la ressource |
| PUT | /posts/:id | Replace : remplacement (idempotent) de tout le contenu de la ressource. En pratique, trop de projets traitent PUT comme un PATCH… |
Y’a pas que 200, 302 et 404 dans la vie !
Utiliser intelligemment les codes HTTP dans les réponses permet une meilleure gestion automatique des clients.
Quelques exemples importants :
201 Created est le code officiel de réponse à une
création réussie.
On complète souvent d’un en-tête Location avec l’URL
de lecture de la ressource.
204 No Content est un code utile pour toute
modification réussie qui n’appelle pas de corps de réponse
(ex. pas de données normalisées renvoyées).
400 Bad Request indique une erreur syntaxique des données entrantes, par exemple du JSON malformé.
422 Unprocessable Entity indique une erreur
sémantique des données entrantes :
par exemple, des champs manquants ou une entité identifiée
incompatible avec le traitement demandé
(pour une erreur due aux droits d’accès de l’utilisateur, préférer
le 403 Forbidden).
REST va bien au-delà des simples conventions d’URL et de codes HTTP… La navigation dynamique autour d’une réponse (pagination, entités et manipulations associées…) est aussi très pratique pour avoir des API « découvrables ». On parle alors d’HATEOAS, nom pas ragoûtant du tout, et cette évolution est pas mal décrite dans cet article.
Si le mode d’envoi des liens complémentaires n’est pas
standardisé, deux approches fréquentes consistent soit à ajouter
des éléments link au sein du corps structuré de
réponse (JSON, YAML, XML…), soit à ajouter des en-têtes HTTP
Link: avec la même information (ce qui n’est
sémantiquement possible que pour une navigation de listing, ou
lorsqu’on est sur une ressource bien définie).
L’API v3 de GitHub est un bon exemple d’utilisation de ces diverses approches.
Supertest
reste la meilleure option,
qu'on peut combiner tranquillement
avec Jest (avec ou sans snapshots des réponses) par exemple.
Certain·e·s préfèrent faire leurs tests E2E avec
Cypress,
mais c'est surtout utile pour des
pages web, pas vraiment pour des API pures.
Une API, ça change avec le temps. On renvoie / exige des champs différents, on ajoute et on enlève des trucs… Pour éviter de tuer tous les clients de notre API, on va donc devoir versionner.
Il y a deux approches : des préfixes d’URL ou des en-têtes de requête HTTP. Cette deuxième approche est plus versatile, ne serait-ce que parce qu’elle évite de devoir maintenir le jeu complet des URLs communes pour chaque version, par exemple. En revanche, quand une URL se comporte différemment d’une version à l’autre, le client peut demander une version spécifique par en-ête de requête.
Là aussi, GitHub est un bon élève :
# Requête :
Accept: application/vnd.github.v3+json
…
# Réponse :
Content-Type: application/json; charset=utf-8
X-GitHub-Media-Type: github.v3
…
mongo
MongoDB shell version v4.4.6
connecting to: mongodb://172.24.96.1:27017/test?…
Implicit session: session { "id" : UUID("…") }
MongoDB server version: 4.4.3
…
>
Current Mongosh Log ID: …
Connecting to: mongodb://172.24.96.1:27017/test?…
Using MongoDB: 4.4.3
Using Mongosh Beta: 0.12.1
…
>
Depuis 3.2, on préfèrera :
insertOne(doc[, options])
insertMany(docs…[, options])
updateOne(query, doc[, options])
updateMany(query, docs…[, options])
replaceOne(query, doc[, options])
deleteOne(query[, options]) / deleteMany(query[, options])
En gros, c’est le futur des API. REST était les balbutiements, GraphQL est l’évolution suivante.
Piloté par Facebook mais 100% FLOSS.
Fortement typé et validé.
Le client précise exactement ce qu’il veut, et ne récupère que ce dont il a besoin, en une seule requête même si elle traverse des ressources*.
Utilisé par tous les grands :
Facebook, GitHub / GitLab
/ Atlassian / JetBrains,
PayPal,
Shopify
/ Magento,
Twitter,
Yelp, LinkedIn, Netflix, Zillow, Expedia, Lyft, Airbnb, PluralSight /
Coursera / Codecademy, Rakuten, Cloudinary, MongoDB…
(En France, citons notamment 20 Minutes et Dailymotion.)
| Concept | Descriptif rapide |
|---|---|
| Type | Structure de données stricte avec des champs typés |
| Query | Description d’un requêtage possible de données sur un type |
| Mutation | Description d’une modification possible de données sur un type |
| Subscription | Similaire aux queries, mais notifie des mises à jour en temps réel |
| Schéma | Ensemble de définitions de types, queries, mutations et subscriptions |
Un type strictement défini et validé. GraphQL distingue les types
scalaires, notamment ceux fournis de base
(Int, Float, String,
Boolean, ID et le plus souvent
Date) ; les types énumérés, définis
manuellement ; et les types objets, pour nos structures
de données, par exemple ceci :
type User {
birthday: Date
email: String!
name: String!
password: String
role: Role!
tags: [String!]!
token(regen: Boolean = false): String
}
Le suffixe ! indique que le champ ou élément concerné
ne peut être null. L’ordre des champs n’est pas
significatif.
On peut factoriser des définitions en tant qu’interfaces ou avec des types unions.
Une query est un descripteur de toutes les requêtes (R/O) de données du domaine envisagé.
type Query {
allUsers(order: SortOrder = ALPHABETICAL): [User!]!
user(email: String!): User
}
Côté requêtage, on peut par exemple demander :
query {
allUsers { name }
}
(Pour que le client puisse requêter des objets et champs, les descripteurs query associés doivent avoir été prévus au schéma)
Ou de façon plus personnalisée :
{
allUsers(order: RECENT_FIRST) {
createdAt
email
name
}
user(email: 'christophe@delicious-insights.com') {
name
role
}
}
Là où une query requête, une mutation modifie (tout type d’altération imaginable).
type Mutation {
createUser(user: UserInput!): User!
}
Côté utilisation, on pourrait faire par exemple :
mutation {
createUser(user: { name: 'John', email: 'john@example.com', role: USER }) {
token
}
}
Une mutation GraphQL peut toujours renvoyer des données, par
exemple, la version à jour, normalisée, d’informations transmises,
mais aussi les ajustements opérés côté serveur sur des données
connexes.
On évite ainsi des A/R supplémentaires avec le serveur.
Flux de notifications temps réels des modifications qui nous intéressent. Similaire aux change streams de CouchDB, MongoDB, etc. mais plus granulaire.
type Subscription {
newUser(role: UserSubscriptionRole = ALL): UserSubscriptionPayload
}
Pour s’inscrire :
subscription {
newUser {
email
name
role
}
}
GraphQL est agnostique quant au langage de développement, toutefois il définit l’algorithme d’exécution des queries, mutations et subscriptions. C’est en fait une « simple » traversée récursive de fonctions positionnées sur leurs types respectifs. Tout étant clairement typé, le lookup est plutôt facile à chaque fois.
Par exemple, la requête suivante…
query {
allUsers { name }
}
…est typée comme suit, explicitement :
query: Query {
allUsers: [User!]! { name: String! }
}
On peut dérouler la liste des appels de resolvers nécessaires, qui sont des fonctions renvoyant des objets ou promesses :
Query.allUsers(root, context) -> users
for each user in users
User.name(user, null, context) -> name
Le cœur du truc, inaliénable, utilisé de toutes
façons, c’est
graphql.
Comme ça fournit le noyau, mais que c’est pas hyper pratique à
l’usage, on utilisera souvent, directement ou de façon intégrée
(ex. Apollo),
graphql-tools, notamment pour faire le lien entre le schéma lui-même, ses
exécutants (les fameux resolvers) et l’algorithme
d’exécution.
Les outils (et leurs modules) type GraphiQL sont des sortes d’EDIs web minimalistes pour explorer un schéma, qui servent de base à des solutions comme GraphQL Playground.
Le module historique
express-graphql
est obsolète, pour des tas de raisons, même s’il reste dans les
tutos d’origine.
Apollo est la plate-forme de référence pour faire du GraphQL, côté serveur comme côté client*.
Il se vend en premier lieu comme un « adaptateur » permettant de proposer un point d’accès unique GraphQL par-dessus nos ressources légataires : APIs REST, microservices / fonctions, etc. Mais en fait il peut aussi taper directement dans les données, donc être une alternative, ou un complément, à l’existant. Ça permet une mise à niveau incrémentale, en tout cas !
Apollo 1 était bien mais pas parfait, du coup on avait tendance à
ajouter du sucre comme
graphql-yoga, ou carrément à utiliser son principal concurrent,
Prisma (anciennement
Graphcool), dont le positionnement est pourtant très différent.
Apollo 2 était une énorme mise à jour qui a fait référence, et Apollo 3 poursuivait le mouvement.
Apollo 4 vise à découpler les intégrations avec l'écosystème du travail noyau.
En somme, on a besoin de graphql,
@apollo/server et éventuellement notre propre couche
côté serveur, et @apollo/client pour le client. Tout
l’outillage avancé est fourni avec !
On vous recommande chaudement l’extension GraphQL ; installez-la si ce n’est pas déjà fait !
Coloration syntaxique (fichier et TTS gql)
Autocomplétion
Validation à la volée vis-à-vis du schéma
…et j’en passe !
Un schéma GraphQL, au début, c’est comme quand on a commencé à faire des modèles de données SGBD/R, puis quand on a commencé à faire du NoSQL (ex. MongoDB), etc. : on fait des boulettes classiques.
Une modélisation qui semble parfaitement raisonnable à première vue peut vite poser des problèmes de duplication, d’utilisabilité, de performance, de responsabilités…
À lire absolument, en prenant le temps : le guide de Shopify sur le sujet.
Plus générique et concis, mais très sympa aussi : le guide d’Apollo.
La règle d’or : le client d’abord. Ce sont les besoins opérationnels du côté client qui doivent piloter le schéma ! C’est un renversement d’approche par rapport à REST, notez bien… (et à mon sens, c’est une des failles de l’approche de Prisma). Il ne faut toutefois pas sacrifier le domaine métier sur l’autel de l’UI, qui peut changer d’un client à l’autre ; c’est un arbitrage. Mais ce qui est sûr, c’est que les détails d’implémentation et l’historique d’API ou de dépendances n’ont aucun rôle à jouer.
Principe n°2 : n’y mettre que ce dont on a besoin
maintenant.
Ajouter des données et opérations sera trivial, en retirer le sera
beaucoup moins.
Apollo Studio est un énorme outil en ligne qui permet notamment de monitorer finement vos performances, de faire des alertes, recos, etc.
Par ailleurs, de nombreuses voies d'optimisation sont possibles côté serveur et côté client.
Notre stack de test habituelle marche toujours !
On peut simuler les données sur base d’un schéma avec le
mocking
Apollo (depuis le serveur ou directement avec
graphql-tools), très pratique pour mettre au point le
client indépendamment du développement serveur, isoler de la base
ou permettre un Storybook…
Le site officiel
(.org)
Un excellent agrégat de ressources, tutos, études de cas…
(.com)
GraphQL is the new REST :
bouquin, codes sources, vidéos et exos approfondis
par John
Resig et Loren Sands-Ramshaw (payant) (.guide)
How To GraphQL : excellente intro vidéo/texte et tutos interactifs par techno
Le site officiel Apollo est bourré d’infos, docs et démos utiles
Robin Wieruch a aussi deux tutos détaillés autour d’Apollo :
un côté client avec React, et
un côté serveur au sein d’Express.
Il est impératif de valider la structure, et idéalement les
valeurs, des éléments de la requête entrante (params,
query, body…).
Les deux principales approches sont :
joi, issu de l’écosystème Hapi
Le standard JSON Schema, exploité via ajv
On trouve aussi deux approches principales :
Le plus complet est
node-restify-validation, mais il a gelé et cherche de nouveaux mainteneurs ;
on a aussi, plus léger mais moins puissant,
restify-request-validator. Les deux ont besoin d’un ajustement vis-à-vis du dernier
Restify, dont le routeur a changé. On le fait dans notre code.
DI est en train de boucler un travail de reprise avec mise à jour intégrale et correctifs pour tous les tickets et pull requests proposés au fil du temps dans restify-fresh-validation.
La phase de validation inhérente à GraphQL, couplée à son typage fort, offre un premier niveau de protection non négligeable. Apollo Server en rajoute une couche, mais l’injection reste possible si on n’est pas soigneux dans le design de nos mutations.
On recommande de recourir intelligemment aux scalaires personnalisés* et à leurs validateurs sur-mesure, en complément potentiel d’une validation structurelle plus générique.
Il existe ausi un risque de saturation au moyen de relations cycliques et de la profondeur arbitraire des graphes résultats.
Si toutes les requêtes potentielles viennent de nos propres codes
sources (et non librement d’utilisateurs tiers), on peut créer des
listes blanches de requêtes acceptables grâce à
persistgraphql. Sympa, mais pas sans inconvénients…
Dans tous les cas, imposer des limites de complexité et de profondeur est une bonne idée.
REST ou GraphQL, imposer un Rate Limiting est une bonne
idée.
Dans l’idéal, les en-têtes de réponse gardent le client informé de
sa progression, du genre :
HTTP/1.1 429 Too Many Requests
…
X-RateLimit-Remaining: 0
X-RateLimit-Limit: 5
X-RateLimit-Rate: 1
Restify a un
plugin interne parfaitement adapté, qui peut travailler par IP distante, IP via proxy
(X-Forwarded-For), ou utilisateur logique, gère la
notion de burst et peut utiliser une couche de stockage
externe pour gérer le multi-process/instance.
Pour une API GraphQL, on considère souvent que les limitations de
complexité sont suffisantes ;
GitHub, par exemple, implémente
deux limites
basées sur le nombre de nœuds résultats et le « coût » (exprimé en
points, dérivé de la complexité) des requêtes.
Distinction Sign Up / Sign In ou pas ?
Besoin d’une entité User en base, sauf à déporter
toute la gestion ailleurs (OAuth 2.0 typiquement).
Dans une API, on n’utilise pas tellement les cookies, et donc les
sessions… On authentifie chaque requête, généralement via un
en-tête
Authorization, le plus souvent dans sa forme
Bearer
(pas tellement parce qu’on fait de l’OAuth 2.0, mais parce qu’on
utilise un token). On peut tout à fait utiliser un en-tête étendu
à nous, genre X-API-Token, si on préfère…
Quel type de token ? Ce qu’on veut, en vrai. JWT a le vent en poupe, mais c’est à manipuler avec des pincettes, surtout en termes de forçage d’expiration. Le module clé est jsonwebtoken (on a aussi fast-jwt).
Avec Restify, on utilisera
restify-jwt-community ; pour GraphQL avec Apollo, il faudra fournir un habilleur de
contexte, qui examine la requête HTTP pour équiper le contexte
d’une clé (classiquement user), afin de
filtrer dessus au global, ou plus granulairement l’examiner
dans les resolvers.
Rien de top existant pour faire du RBAC avec Restify, mais c’est
assez facile à la main, avec un champ role dans
User et un middleware générique incrustable dans les
gestionnaires de routes…
Le truc top moumoute, c’est d’y aller par directives personnalisées, granulairement, au niveau du schéma. Ça pète grave. Y’a une bonne page de doc chez GraphQL Tools sur le sujet.
Permet à un serveur d’indiquer quelles origines (« qui ») peut venir consulter ses données, utiliser ses APIs, etc.
Basé sur des en-têtes HTTP (Origin,
Access-Control-*)
Pour Restify, le plus simple est d’utiliser restify-cors-middleware2
Apollo 3 gérait ça directement avec l’option cors du
constructeur ApolloServer (en standalone),
mais depuis Apollo 4 ça nécessite de passer par un framework
serveur tiers (la doc et notre propre code illustrent ça pour
Express, mais ça peut être géré par Fastify, etc.).
Eh, pas de mots de passe en clair, tu te rappelles ?
Eh, les PII sont chiffrées, pas vrai ?
Le module mongoose-pii gère ça de façon simple et transparente
const { markFieldsAsPII } = require('mongoose-pii')
userSchema.plugin(markFieldsAsPII, {
fields: ['email', 'firstName', 'lastName', 'mfaSecret'],
key: process.env.MONGOOSE_PII_KEY,
passwordFields: 'password'
})
await User.authenticate('christophe@delicious-insights.com', 'secret')
// => Ben `null`, hein, j’ai pas un mot de passe aussi pourri !
On utilisera généralement :
otplib pour générer les secrets, générer et vérifier les tokens, générer les URIs d’enregistrement pour les apps d’authentification (type Authy, ou en moins bien Google Authenticator ou MS Authenticator)
qrcode pour générer des QRCodes (en image, en Data URI…) afin de faciliter l’enregistrement dans les apps
Un middleware (REST) ou habilleur de contexte (GraphQL) pour
confirmer le second facteur, généralement depuis un en-tête HTTP
étendu (genre X-TOTP-Token)
On réfléchit au niveau serveur (les nôtres ou de l’IaaS)
On déploie de façon automatisée, répétable et réversible, par exemple avec Capistrano. Oubliez ce !@# de FTP !
En revanche, il faut faire en sorte que notre application…
démarre toute seule au lancement du serveur
redémarre toute seule en cas de problème ou suite à une mise à jour
évite dans ce dernier cas les interruptions de service
stocke convenablement ses logs
tire parti de tous les processeurs de la machine
etc.
On réfléchit au niveau codebase et services tiers (notamment la base MongoDB).
On déploie par git push, sur l’hébergeur ou sur nos
branches de recette / production, qui déclenchent une
pipeline.
Toute la partie DevOp est gérée pour nous : déploiement, supervision, et surtout… élasticité de charge !
Y’a pratiquement pas un seul PaaS qui ne prenne en charge Node.js (parfois même que lui) :
AWS, Azure, Google Cloud Platform (GCP), Heroku, Vercel,
🇫🇷 Clever Cloud, Linode, platform.sh, AppFog, OpenShift,
EngineYard, Joyent, Bluemix…
J’adore Heroku, mais d’une force !
Pour l’admin, je préfère leur ligne de commande
heroku --version
heroku/7.16.5 darwin-x64 node-v10.10.0
heroku login
…
heroku apps:create --addons=mongolab:sandbox --region=eu toptunez
Creating ⬢ toptunez... done, region is eu
Adding mongolab:sandbox... done
https://toptunez.herokuapp.com/ | https://git.heroku.com/toptunez.git
heroku config:set APOLLO_ENGINE_API_KEY=ser… JWT_SECRET=111… MONGOOSE_PII_SECRET=4a5…
Setting APOLLO_ENGINE_API_KEY, JWT_SECRET, MONGOOSE_PII_SECRET and restarting ⬢ toptunez... done, v5
…
Allez, maintenant que c’est provisionné (ça a pris genre 20 secondes), on déploie :
git push heroku master
…
remote: -----> Node.js app detected
…
remote: -----> Installing binaries
remote: engines.node (package.json): >= 8
remote: engines.npm (package.json): >= 6
remote:
remote: Resolving node version >= 8...
remote: Downloading and installing node 10.11.0...
remote: Bootstrapping npm >= 6 (replacing 6.4.1)...
…
remote: -----> Launching...
remote: Released v6
remote: https://toptunez.herokuapp.com/ deployed to Heroku
remote:
remote: Verifying deploy... done.
Ici, spécifiquement via App Service. À la main (dans le portail), ou avec la CLI, c’est un peu pénible, mais…
…l’extension Azure App Service pour VS Code est super cool !
On crée l’app, on définit l’environnement (clés, URLs, etc.) grâce
aux Application Settings, et on déploie !
L’installation npm prend pas mal de temps, ainsi que le boot, mais
ça va…