🪤 Pièges réels rencontrés
🎯 Pourquoi cette page existe. Aucun tutoriel ne t’enseigne ce qui casse vraiment quand tu codes. Cette page le fait. Chaque entrée est un vrai bug rencontré en construisant ce guide ou en exécutant les exercices, capturé dans le format Symptôme → Cause → Fix → Leçon.
🧪 Tests(9)
`await` dans `forEach` ne fait rien Tests axe 6.2 🔗
🩹 Symptôme
// On attend que chaque insert finisse avant le suivant... pas vraiment.
items.forEach(async (item) => {
await db.execute({ sql: 'INSERT ...', args: [item] });
});
console.log('done'); // s'affiche AVANT que les inserts soient finis
🔍 Cause
Array.prototype.forEach ignore la valeur de retour du callback. Même si tu mets async, la Promise retournée n'est pas await-ée. Le code continue immédiatement, et tes inserts s'exécutent dans le désordre (souvent en parallèle). 🩺 Fix
Utiliser for...of (séquentiel) ou Promise.all(.map(...)) (parallèle) :
// ✅ Séquentiel : 1 insert puis le suivant
for (const item of items) {
await db.execute({ sql: 'INSERT ...', args: [item] });
}
// ✅ Parallèle : tous les inserts en même temps
await Promise.all(items.map(item =>
db.execute({ sql: 'INSERT ...', args: [item] })
));
Choisir : séquentiel si l'ordre compte ou si tu veux pas saturer la DB. Parallèle si l'ordre n'importe pas et tu veux la perf. 🧠 Leçon
Règle d'or : forEach n'est jamais le bon choix avec async. Utilise for...of quand tu veux séquentiel, Promise.all quand tu veux parallèle. Si tu vois forEach(async ...) dans une PR, c'est presque toujours un bug.
useEffect avec dépendance manquante = stale closure Tests axe 7.2 🔗
🩹 Symptôme
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // affiche toujours 0 !
}, 1000);
return () => clearInterval(id);
}, []); // ← dependency array vide
// ...
}
🔍 Cause
Le useEffect capture count au 1er render (valeur 0). Le tableau de deps [] dit à React « ne ré-exécute jamais cet effet ». Donc le setInterval continue de logger 0, même quand count change ailleurs. C'est le stale closure.
🩺 Fix
Trois options :
1. Mettre la dep (correct mais recreate l'interval à chaque change) :
useEffect(() => { ... }, [count]);
2. Utiliser le setter callback pour ne pas dépendre du state actuel :
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(id);
}, []);
3. Ref si tu veux la valeur courante sans re-render :
const countRef = useRef(count);
useEffect(() => { countRef.current = count; });
Le linter react-hooks/exhaustive-deps détecte ce piège — l'activer en CI bloquante. 🧠 Leçon
99 % des bugs React du quotidien viennent de stale closures + dépendances manquantes. Active react-hooks/exhaustive-deps en error (pas warning) dans ESLint. Quand tu as besoin de l'ignorer (rare), c'est presque toujours le signal qu'il faut refactorer.
TypeScript `as` ment au compilateur Tests axe 6.4 🔗
🩹 Symptôme
const user = response.data as User;
// 1 mois plus tard, l'API a changé et user.email n'existe plus
// → bug runtime "Cannot read property 'toLowerCase' of undefined"
// → 0 erreur TypeScript
🔍 Cause
as User est un type assertion, pas une vérification runtime. Tu dis au compilateur « fais-moi confiance, c'est un User ». Si la vraie data n'a pas la forme attendue, TS n'a aucun moyen de le savoir. 🩺 Fix
Utiliser Zod (ou Valibot, ArkType) pour valider runtime :
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
email: z.string().email(),
name: z.string(),
});
// Au lieu de :
const user = response.data as User;
// Faire :
const user = UserSchema.parse(response.data);
// → throw immédiat si la forme est invalide, message précis
as reste OK pour les cas où tu sais quelque chose que TS ne peut pas inférer (ex. après un if (typeof x === 'string'), le narrowing ne marche plus dans une closure → as string raisonnable). 🧠 Leçon
Règle d'or 2026 : inputs externes (API, JSON, localStorage, formulaires) → toujours validation runtime. Le typage statique TS protège du bug en interne, pas à la frontière. Zod parse > as cast 9 fois sur 10.
Muter le state React imbriqué ne re-render pas Tests axe 7.2 🔗
🩹 Symptôme
const [user, setUser] = useState({ name: 'Alice', address: { city: 'Lyon' } });
function changeCity() {
user.address.city = 'Paris'; // mutation directe
setUser(user); // ← React voit la même référence, ne re-render pas
}
🔍 Cause
React utilise une comparaison référentielle (Object.is) pour décider de re-render. Si tu mutes l'objet en place, la référence reste identique → React croit que rien n'a changé.
🩺 Fix
Toujours créer un nouvel objet pour les modifs imbriquées :
setUser({
...user,
address: { ...user.address, city: 'Paris' }
});
Pour les structures profondes, utiliser Immer :
import { produce } from 'immer';
setUser(produce(user, draft => {
draft.address.city = 'Paris'; // OK, Immer fait le clone derrière
}));
Ou refactorer le state en plusieurs useState plats si possible.
🧠 Leçon
Règle d'or React : state immuable. Toute modif = nouvel objet/array. Si ton state a 3+ niveaux de profondeur, c'est souvent le signal pour le scinder ou utiliser useReducer + Immer.
`parseInt` sans radix peut interpréter en octal Tests axe 6.1 🔗
🩹 Symptôme
parseInt('010'); // → 8 dans certains navigateurs anciens (octal)
parseInt('0x10'); // → 16 (hex)
parseInt('010', 10); // → 10, comportement attendu
🔍 Cause
parseInt sans 2e argument tente de deviner la base depuis le préfixe. Strings commençant par 0 ont été interprétées en octal (jusqu'à ES5), 0x en hex. 🩺 Fix
Toujours passer le radix :parseInt(input, 10); // décimal explicite
Ou utiliser Number() (plus strict, ne tolère pas les chars trailing) :
Number('42'); // → 42
Number('42abc'); // → NaN (vs parseInt = 42)
Linter ESLint : radix: error détecte automatiquement.
🧠 Leçon
Tout littéral numérique parsé d'une string → parseInt(s, 10) ou Number(s). Toujours préciser. Linter ESLint en CI = jamais de surprise.
Vitest 2/3 + Node 24 + Windows : Tinypool meurt sur les tests qui spawn Tests axe 2.0 🔗
🩹 Symptôme
Error: Worker exited unexpectedly
at ChildProcess.onUnexpectedExit (.../tinypool/dist/index.js:118:30)
Emitted 'error' event on Tinypool instance
# ou bien
Error: Channel closed (ERR_IPC_CHANNEL_CLOSED)
🔍 Cause
Vitest exécute chaque fichier de test dans un worker (Tinypool). Sous Node 24 + Windows, dès qu'un test fait du spawnSync (par exemple pour tester un CLI), l'IPC entre le worker Tinypool et son parent se casse — les workers sortent en plein vol et tout le run échoue avant même d'avoir démarré le test.
Le problème est connu côté Vitest et n'a pas de fix simple : le pool 'forks' déclenche ERR_IPC_CHANNEL_CLOSED, le pool 'threads' fait crasher le worker. Lié à des changements internes IPC dans Node 24.
🩺 Fix
Pour les exercices qui ont besoin de lancer un sous-processus (CLI, serveur), ne pas utiliser Vitest. Soit :
- Utiliser
node:test(le runner intégré, pas de pool de workers). - Ou écrire un mini-runner custom (10–20 lignes : un tableau de
{name, fn}et unforqui try/catch). C'est ce que ce guide fait pour mini-curl.
Et toujours utiliser spawn async (pas spawnSync) quand le parent est aussi un process Node — voir piège suivant.
Pour les tests qui ne spawn pas (test unitaire d'une fonction, ou test d'une route HTTP via fetch sur un serveur in-process), Vitest reste OK sous Node 24 + Windows.
🧠 Leçon
Un test runner avec worker pool est une optimisation : ça parallélise. Mais ça ajoute une dépendance complexe sur l'IPC du système. Quand cette IPC bouge (Node majeur, OS spécifique), les frameworks avec worker pool cassent en premier.
Pour les tests d'intégration de CLIs, un script de 50 lignes en node:child_process est souvent plus robuste qu'un framework. Connaître les deux options vaut le coup.
spawnSync depuis Node se fait killer (status 143) quand parent et enfant utilisent tsx Tests axe 2.0 🔗
🩹 Symptôme
// Test : spawnSync(node, ['--import', 'tsx', 'cli.ts', ...], { encoding: 'utf-8' })
status: 143
signal: null
stdout: ''
stderr: ''
elapsed: ~30000 ms (= timeout par défaut)
🔍 Cause
spawnSync bloque l'event loop du process parent. Quand parent et enfant chargent tous les deux tsx (loader TypeScript), le loader tsx utilise des workers internes qui ont besoin que l'event loop tourne pour s'initialiser. Le parent bloqué = l'enfant ne peut pas finir son init (ports, named pipes), un timeout interne tire un SIGTERM (status 143), et le stdout reste vide.
Le même appel depuis bash/zsh (parent qui n'est pas Node) marche en <2s. Le bug est l'interaction parent-Node × enfant-Node × --import tsx × Windows.
🩺 Fix
Utiliser spawn (asynchrone) wrappé dans une Promise au lieu de spawnSync :
import { spawn } from 'node:child_process';
function runCli(args: string[]) {
return new Promise((resolve) => {
const child = spawn(process.execPath, ['--import', 'tsx', ENTRY, ...args]);
let stdout = '', stderr = '';
child.stdout.setEncoding('utf-8');
child.stderr.setEncoding('utf-8');
child.stdout.on('data', d => stdout += d);
child.stderr.on('data', d => stderr += d);
child.on('close', (status) => resolve({ status, stdout, stderr }));
});
}
// Tests deviennent async
test('GET 200', async () => {
const r = await runCli(['http://...']);
assertEqual(r.status, 0);
});
🧠 Leçon
Sur Node, spawnSync est commode mais bloque tout. Si l'enfant a besoin de l'event loop du parent (pour de l'IPC, des hooks de loader, du SharedArrayBuffer), spawnSync le suffoque silencieusement.
Règle : dès qu'un test fait du subprocess en Node, utiliser spawn async, jamais spawnSync. Le coût en lignes est nul (10 lignes de wrapper) et c'est plus prévisible.
Tests Vitest crashent : « window.matchMedia is not a function » Tests axe 7.0 🔗
🩹 Symptôme
TypeError: window.matchMedia is not a function
at E node_modules/next-themes/dist/index.mjs:1:3278
at mountStateImpl node_modules/react-dom/cjs/react-dom-client.development.js:8268:24
at Object.useState …
🔍 Cause
jsdom (utilisé par Vitest avec environment: 'jsdom') n'implémente pas window.matchMedia — c'est une API navigateur que jsdom a choisi de ne pas mocker par défaut. Toute lib qui répond aux media queries (next-themes, MUI dark mode, ChakraUI, code custom prefers-color-scheme) crashe au premier render dans un test.
Le même test passe en Playwright/Cypress (vrai navigateur) mais explose en Vitest dès qu'un Client Component se monte.
🩺 Fix
Créer un vitest.setup.ts qui mocke matchMedia, et le déclarer dans vitest.config.ts :
// vitest.setup.ts
import { vi } from 'vitest';
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // deprecated mais utilisé par certaines libs
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// vitest.config.ts
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
},
});
Mocker toutes les méthodes (addListener, removeListener, etc.) — sinon une lib qui les utilise crashe en cascade.
🧠 Leçon
jsdom est une approximation du navigateur, pas un clone. Quand un test crashe sur window.X is not a function, c'est presque toujours une API que jsdom n'a pas implémentée : matchMedia, IntersectionObserver, ResizeObserver, scrollTo, localStorage (en partie), crypto.subtle.
Garde un vitest.setup.ts standard avec ces 4–5 polyfills dès le début d'un projet React/Next — tu gagnes des jours de debugging plus tard.
@hono/zod-validator renvoie 400 au lieu de 422 Tests axe 8.0 🔗
🩹 Symptôme
// Test
expect(res.status).toBe(422);
// Reçu : 400
AssertionError: expected 400 to be 422
🔍 Cause
Le zValidator de Hono renvoie un 400 Bad Request par défaut quand la validation échoue. Mais la convention REST moderne (RFC 4918) dit que 422 Unprocessable Entity est plus précis quand la requête est syntaxiquement valide mais sémantiquement invalide (un email mal formé, un champ requis absent…). Beaucoup d'APIs et de tests modernes attendent donc 422.
🩺 Fix
Wrapper zValidator dans un helper qui force 422 :
// src/lib/validator.ts
import { zValidator } from '@hono/zod-validator';
import type { ZodSchema } from 'zod';
export function validate<T extends ZodSchema>(target: 'json' | 'query' | 'param', schema: T) {
return zValidator(target, schema, (result, c) => {
if (!result.success) {
return c.json(
{ error: 'Validation failed', issues: result.error.issues },
422
);
}
});
}
Puis dans les routes : validate('json', MySchema) au lieu de zValidator(...).
🧠 Leçon
Quand un framework expose des defaults qui ne matchent pas ta convention (codes HTTP, format d'erreur, schéma de réponse), wrap-le dans un helper plutôt que de l'utiliser brut partout. Tu écris la convention une fois, et tu peux la changer une fois au lieu de 30 fois.
⚡ Performance(6)
React `key={index}` casse les listes réordonnables Performance axe 7.2 🔗
🩹 Symptôme
// Liste de tâches qu'on peut réordonner ou supprimer
{tasks.map((task, index) => (
<Task key={index} task={task} />
))}
// Bug visible : input qui ne suit pas son item après un réordonnement,
// state local qui se mélange entre items, animations buggées.
🔍 Cause
React utilise key pour identifier chaque élément entre deux renders. Si tu utilises index, supprimer l'item 0 fait que l'item 1 hérite de la key=0 — React croit que c'est le même composant, conserve son state local, son input value, son DOM.
🩺 Fix
Toujours utiliser un id stable (souvent task.id venant de la DB) :
{tasks.map(task => (
<Task key={task.id} task={task} />
))}
Cas particulier : si tu as une liste read-only et qui n'est jamais réordonnée (un tableau de stats statique), key={index} est OK. Mais ne fais pas l'erreur de partir de index puis ajouter le tri/filtre/reorder plus tard.
🧠 Leçon
key={index} est un piège qui ne se révèle pas immédiatement. Tout marche jusqu'au jour où l'utilisateur réordonne sa liste, puis « bizarrement » l'input value se mélange. Préviens le bug en utilisant des IDs stables dès le départ. Image sans `width`+`height` plombe le CLS Performance axe 13.2 🔗
🩹 Symptôme
<img src="/hero.jpg" />
<!-- Lighthouse : CLS 0.4 (cible : ≤ 0.1) -->
<!-- Le contenu en dessous saute quand l'image charge -->
🔍 Cause
Sans width et height (ou aspect-ratio CSS), le navigateur ne sait pas combien de place réserver. Quand l'image charge, le contenu en dessous est poussé → Cumulative Layout Shift. Pénalisé par Lighthouse et Google Search.
🩺 Fix
Toujours fournir les dimensions intrinsèques :
<img
src="/hero.jpg"
width="1024"
height="600"
alt="..."
/>
Le navigateur calcule le ratio (1024/600) et réserve la place avant le téléchargement. Ratio préservé même si CSS max-width: 100%; height: auto;.
Pour les images responsives (srcset), donne les dimensions de la version par défaut — le ratio sera identique pour toutes les variantes.
🧠 Leçon
Sur 9 sites sur 10, width+height manquants sont la première cause de mauvais CLS. C'est gratuit à corriger, ça améliore le SEO et l'UX. Linter HTML ou test Lighthouse en CI bloquant te le rappelle automatiquement.
JSONB Postgres sans index GIN → seq scan systématique Performance axe 9.1 🔗
🩹 Symptôme
-- Schéma
CREATE TABLE products (id SERIAL, metadata JSONB);
-- Query métier
SELECT * FROM products WHERE metadata @> '{"color": "red"}';
-- EXPLAIN : Seq Scan, 800 ms sur 100K rows
🔍 Cause
Sans index GIN, Postgres ne peut pas accéder rapidement aux clés/valeurs JSONB. Il fait un Seq Scan qui parse chaque metadata row par row.
🩺 Fix
Créer un index GIN sur la colonne JSONB :
CREATE INDEX products_metadata_idx ON products USING GIN (metadata);
Pour les filtres sur une seule clé souvent utilisée (metadata->>'color'), un index plat ou expression :
CREATE INDEX products_color_idx ON products ((metadata->>'color'));
Le GIN couvre tous les opérateurs JSONB (@>, ?, ?|, ?&). L'index expression est plus petit mais ne sert que ce filtre précis.
🧠 Leçon
Si tu utilises JSONB en colonne, toujours un index GIN (ou expression) sur ce qui est filtré. Sinon tu as choisi JSONB pour la flexibilité mais tu paies le prix de la lenteur. Linter SQL en review : « JSONB sans index → flag ».
Foreign Key sans index → DELETE/UPDATE catastrophiques Performance axe 9.1 🔗
🩹 Symptôme
-- Schéma sans index sur la FK
CREATE TABLE order_lines (
id SERIAL PRIMARY KEY,
order_id INT REFERENCES orders(id) ON DELETE CASCADE,
-- pas d'index sur order_id !
...
);
-- DELETE FROM orders WHERE id = 42;
-- → seq scan sur order_lines (10M rows) pour trouver les lignes à cascader
-- → 8 secondes au lieu de 5 ms
🔍 Cause
Postgres (et la plupart des SGBD) ne créent pas automatiquement d'index sur la colonne FK enfant. Pour vérifier qu'aucun enfant ne référence un parent à supprimer, il scanne toute la table enfant.
🩺 Fix
Toujours indexer les colonnes FK :
CREATE INDEX order_lines_order_id_idx ON order_lines(order_id);
Audit script :
SELECT
c.conname,
c.conrelid::regclass AS table,
a.attname AS column
FROM pg_constraint c
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey)
WHERE c.contype = 'f'
AND NOT EXISTS (
SELECT 1 FROM pg_index i
WHERE i.indrelid = c.conrelid
AND a.attnum = ANY(i.indkey)
);
-- Liste les FK sans index — à indexer 🧠 Leçon
Postgres indexe la PK automatiquement, mais pas les FK côté enfant. Toujours créer ces index. Sur une grosse table avec CASCADE, l'oubli peut transformer un DELETE en panne de 30 secondes en prod.
Font Google CDN plombe le LCP sur mobile bridé Performance axe 13.2 🔗
🩹 Symptôme
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Inter">
<!-- Lighthouse mobile bridé : LCP 4.2 s, dont 1.8 s pour télécharger la font -->
🔍 Cause
Le navigateur doit : (1) résoudre DNS fonts.googleapis.com, (2) télécharger le CSS, (3) résoudre DNS fonts.gstatic.com, (4) télécharger les fichiers .woff2. 4 round-trips réseau avant de pouvoir afficher le 1er texte.
🩺 Fix
Self-host les fonts + preload + font-display: swap :
<!-- Préchargement explicite -->
<link
rel="preload"
href="/fonts/Inter-Regular.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<style>
@font-face {
font-family: 'Inter';
src: url('/fonts/Inter-Regular.woff2') format('woff2');
font-display: swap; /* affiche font système, swap quand prête */
font-weight: 400;
}
</style>
Outils : [fontsource](https://fontsource.org/) (npm package par font), [Fontsquirrel webfont generator](https://www.fontsquirrel.com/tools/webfont-generator).
🧠 Leçon
Les fonts Google CDN sont pratiques mais plombent le LCP. Self-host est gratuit, plus rapide, et ne bave pas de PII vers Google (RGPD). Sur tout projet pro 2026, self-host par défaut.
Service Worker garde une vieille version en cache Performance axe 13.2 🔗
🩹 Symptôme
// Tu déploies une nouvelle version.
// Les utilisateurs ouvrent le site.
// → ils voient l'ancienne version pendant des jours.
// Pourquoi ? Service Worker qui sert depuis le cache.
🔍 Cause
Stratégie cache-first sans expiration : le SW sert depuis le cache, sans vérifier le serveur. Tant que le cache n'est pas invalidé, les users voient l'ancienne version.
🩺 Fix
Trois stratégies :
1. Network-first (recommandé pour la coquille HTML) :
// Tente le réseau, fallback cache si offline
self.addEventListener('fetch', (e) => {
e.respondWith(
fetch(e.request)
.then(r => { caches.open('v1').then(c => c.put(e.request, r.clone())); return r; })
.catch(() => caches.match(e.request))
);
});
2. Stale-while-revalidate : sert le cache, met à jour en arrière-plan.
3. Versioning du SW : changer le nom du cache à chaque release force la migration :
const CACHE_NAME = 'app-v' + import.meta.env.VITE_BUILD_ID;
Outil moderne : [Workbox](https://developer.chrome.com/docs/workbox/) gère ces stratégies en quelques lignes.
🧠 Leçon
Service Worker en cache-first sans versioning = users bloqués sur l'ancienne version pendant des semaines. Versionner le cache + utiliser network-first sur le HTML. Tester le déploiement en mode incognito + cache disable pour voir la "vraie" expérience.
🔧 Build / Native modules(3)
Dockerfile mal ordonné → cache npm invalidé à chaque build Build / Native axe 4.4 🔗
🩹 Symptôme
# Dockerfile naïf :
FROM node:24-alpine
WORKDIR /app
COPY . . ← copie tout
RUN npm ci ← chaque modif de code → ré-install complet (5 min)
🔍 Cause
Docker invalide le cache à partir de la 1ère ligne modifiée. Si tu COPY . . avant RUN npm ci, toute modification de code source invalide COPY → invalide tout ce qui suit, y compris l'install npm long.
🩺 Fix
Copier d'abord les fichiers de lock, installer les deps, puis copier le reste :
FROM node:24-alpine
WORKDIR /app
COPY package*.json ./ ← change rarement
RUN npm ci ← cache OK la plupart du temps
COPY . . ← change souvent, mais après l'install
RUN npm run build
Avec --mount=type=cache (BuildKit) :
RUN --mount=type=cache,target=/root/.npm npm ci
Build de 5 min → 30 s sur changement de code.
🧠 Leçon
Ordonner ton Dockerfile du moins changeant au plus changeant est la 1ère règle de Docker. Toujours : OS deps → lock files → install → code source → build. Mémo : le cache Docker est ton ami, mais il faut le mériter.
Bind mount `./` écrase `node_modules` du container Build / Native axe 4.4 🔗
🩹 Symptôme
# docker-compose.yml
volumes:
- ./:/app # bind mount tout le repo
# → "Cannot find module 'tsx'"
# Pourtant npm install a marché dans le Dockerfile
🔍 Cause
Le bind mount ./:/app écrase le /app/node_modules du container avec celui (vide ou OS-incompatible) de l'hôte. Particulièrement vicieux quand l'hôte est macOS/Windows et le container Linux : les binaires natifs (@node-rs/argon2, better-sqlite3) sont incompatibles.
🩺 Fix
Volume anonyme par-dessus pour préserver node_modules du container :
services:
api:
build: .
volumes:
- ./:/app
- /app/node_modules # ← volume anonyme, masque le bind
Le 2e volume "remet" les node_modules du container par-dessus le bind mount.
volumes:
- ./src:/app/src
- ./package.json:/app/package.json 🧠 Leçon
Le bind mount ./:/app est tentant mais casse node_modules cross-OS. Volume anonyme /app/node_modules ou bind sélectif. Pattern à mémoriser sur tout Compose dev.
better-sqlite3 ne compile pas sur Windows + Node 24 Build / Native axe 4.4 🔗
🩹 Symptôme
npm error gyp ERR! stack ... node-gyp rebuild
better-sqlite3 ... not ok
node -v v24.14.1
🔍 Cause
better-sqlite3 est un module natif C++ : il doit être compilé via node-gyp. Sous Windows, ça exige les Visual C++ Build Tools (~ 5 GB). Sous Node 24 (très récent), les binaires prébuilds ne sont pas toujours encore publiés au moment de la sortie de Node, ce qui force la compilation locale. 🩺 Fix
Trois options par ordre de préférence :
1. Migrer vers @libsql/client (fork Turso de SQLite, binaires prébuilds pour toutes plateformes, pas de compilation). Drizzle a un driver drizzle-orm/libsql quasi drop-in. C'est ce que ce guide utilise.
2. Installer les Build Tools Windows : npm install --global windows-build-tools (ou Visual Studio Installer → « C++ Build Tools »).
3. Downgrader Node à 22 LTS où les prébuilds existent.
🧠 Leçon
Pour un projet pédagogique ou multi-plateforme, éviter les modules natifs quand un équivalent pure-JS / WASM existe. La friction d'installation tue les contributions. @libsql/client, @noble/hashes, @node-rs/argon2 (lui-même prébuild) sont des bons défauts 2026.
🌐 CORS(1)
CORS `*` avec credentials → bloqué par le navigateur CORS axe 12.0 🔗
🩹 Symptôme
// Backend
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Credentials', 'true');
// Console navigateur :
// "The value of the 'Access-Control-Allow-Origin' header in the response
// must not be the wildcard '*' when the request's credentials mode is 'include'."
🔍 Cause
La spec CORS interdit explicitement la combinaison Origin: * + Credentials: true. C'est une protection : si n'importe quelle origine peut envoyer des credentials, c'est CSRF garanti.
🩺 Fix
Allow-list explicite des origines autorisées :
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'http://localhost:5173', // dev
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Vary', 'Origin'); // ← important pour le cache
}
next();
});
Vary: Origin est crucial : sans, un proxy peut servir la mauvaise réponse à un autre origin. 🧠 Leçon
Access-Control-Allow-Origin: * n'est valide que sans credentials (API publique read-only). Dès que tu envoies un cookie ou un Authorization header, allow-list explicite obligatoire. C'est la 1ère vuln à chercher en audit. 🛡️ Sécurité(5)
Secrets `.env` qui finissent dans le bundle client Sécurité axe 12.0 🔗
🩹 Symptôme
// .env
DATABASE_PASSWORD=hunter2
// Code Vite/Next/CRA :
console.log(import.meta.env.DATABASE_PASSWORD);
// → en prod, "hunter2" est visible dans le JS bundle !
🔍 Cause
Les bundlers frontend (Vite, Next.js, Webpack) injectent toutes les variables d'environnement au build. Tu retrouves hunter2 en clair dans le .js servi au navigateur. Tout visiteur peut l'extraire.
🩺 Fix
Convention de nommage : préfixer les vars publiques (≠ secrètes) :
| Bundler | Préfixe public |
|---------|------------------|
| Vite | VITE_ |
| Next.js | NEXT_PUBLIC_ |
| Create React App | REACT_APP_ |
| Astro | PUBLIC_ |
# .env
NEXT_PUBLIC_API_URL=https://api.example.com ← OK, public
DATABASE_PASSWORD=hunter2 ← reste serveur, jamais bundlé
Au build, vérifier le bundle :
grep -r "hunter2" dist/ # doit retourner 0 résultat 🧠 Leçon
Toute variable accessible sans préfixe est en danger. Audit de bundle 1× par sprint : grep les valeurs sensibles dans dist/. Et toujours .env dans .gitignore — la fuite la plus courante.
Comparer secrets avec `===` = timing attack Sécurité axe 12.3 🔗
🩹 Symptôme
// Vérification de webhook signature
function verify(received, expected) {
return received === expected; // ← vulnérable
}
🔍 Cause
=== sur string s'arrête au 1er caractère différent. La différence de temps entre « 1er char faux » et « 100e char faux » est mesurable sur le réseau (10-100 ns). Un attaquant peut deviner le secret caractère par caractère en mesurant la latence. 🩺 Fix
Utiliser une comparaison constant-time :
import { timingSafeEqual } from 'node:crypto';
function verify(received: string, expected: string): boolean {
const a = Buffer.from(received);
const b = Buffer.from(expected);
if (a.length !== b.length) return false; // length OK to leak
return timingSafeEqual(a, b);
}
Toujours faire la check de longueur avant timingSafeEqual — cette fonction throw si les buffers font des tailles différentes.
🧠 Leçon
Toute comparaison de secret (token, signature, password hash) doit être constant-time. === est OK pour les ID publics (email, username). Mémo : si la valeur fuite donne accès à quelque chose, comparaison constant-time obligatoire.
Secrets GitHub Actions exposés dans les logs Sécurité axe 14.1 🔗
🩹 Symptôme
# .github/workflows/deploy.yml
run: |
echo "Deploying with $API_KEY" # ← log expose le secret
env:
API_KEY: ${{ secrets.API_KEY }}
🔍 Cause
GitHub Actions masque automatiquement les secrets dans les logs s'il les détecte tels quels. Mais : (1) si tu fais echo ou pipe vers un outil, ils peuvent fuir par d'autres canaux. (2) Si tu transformes le secret (concat, base64), GH ne masque plus.
🩺 Fix
Ne jamais echo un secret. Pour le passer à un outil :
- name: Deploy
run: ./deploy.sh
env:
API_KEY: ${{ secrets.API_KEY }}
# → API_KEY est dans l'env du process, pas dans le shell command
Si tu DOIS transformer (rare) :
- name: Mask transformed
run: |
TRANSFORMED=$(echo "${{ secrets.API_KEY }}" | base64)
echo "::add-mask::$TRANSFORMED" # ré-enregistre comme masque
echo "TRANSFORMED=$TRANSFORMED" >> $GITHUB_ENV
Audit : check les logs de tes workflows public pour secrets visibles. Si un secret a fuité, rotation immédiate dans le service concerné. 🧠 Leçon
GH Actions masque les secrets *connus*. Toute transformation (base64, concat, hash partiel) casse le masquage. Règle : passer en env var au process, jamais dans une commande shell. Rotation rapide en cas de doute.
Rate-limit basé sur IP cassé derrière un CDN Sécurité axe 12.3 🔗
🩹 Symptôme
// Middleware de rate-limit
const ip = req.socket.remoteAddress;
// → derrière Cloudflare/Vercel, c'est l'IP du proxy, pas du client
// → tous les utilisateurs partagent le même rate-limit bucket = blocage massif
🔍 Cause
Quand ton app est derrière un CDN ou load balancer, socket.remoteAddress retourne l'IP du proxy, pas du client. Tous tes users semblent venir de la même IP → rate-limit s'applique à eux globalement.
🩺 Fix
Lire la vraie IP dans les headers du proxy :
function getClientIp(req: Request): string {
// Cloudflare
const cf = req.headers.get('cf-connecting-ip');
if (cf) return cf;
// Vercel / Render / proxies standards
const xff = req.headers.get('x-forwarded-for');
if (xff) return xff.split(',')[0].trim();
// Fly.io
const fly = req.headers.get('fly-client-ip');
if (fly) return fly;
return req.socket.remoteAddress ?? 'unknown';
}
⚠️ Faille : ces headers sont forgeables par le client si tu n'es pas derrière un proxy de confiance. Toujours :
1. Configurer le proxy pour écraser les valeurs entrantes (pas append).
2. N'utiliser ces headers que derrière un proxy connu.
Pour Express/Hono : app.set('trust proxy', 1) ou config équivalente.
🧠 Leçon
Derrière un CDN, remoteAddress ment. Toujours lire X-Forwarded-For (ou CF-Connecting-IP côté Cloudflare). Vérifier que le proxy de confiance écrase ces headers — sinon un attaquant peut spoof son IP.
⚙️ Environnement / config(4)
`git pull` crée des merge commits parasites Env / config axe 4.2 🔗
🩹 Symptôme
# Tu as commit local "feat: X". Quelqu'un push sur main pendant.
git pull
# → merge commit "Merge branch 'main' of github.com/..."
# Historique pollué de "Merge branch" partout
🔍 Cause
git pull par défaut = fetch + merge. Si tu as des commits locaux non-pushés et que la branche distante a avancé, Git crée un commit de merge — qui n'apporte aucune valeur historique. 🩺 Fix
Configurer pull.rebase :
git config --global pull.rebase true
Désormais git pull = fetch + rebase → tes commits locaux sont rejoués au-dessus de origin/main, historique linéaire.
git config --global pull.ff only
Refuse le pull s'il faut merge → t'oblige à choisir explicitement --rebase ou --no-ff. 🧠 Leçon
Un historique Git linéaire (rebase) se lit en 30 sec, un historique fait de merges parasites en 30 min. Configure pull.rebase=true une fois pour toutes — c'est le réglage qu'aucun cours ne donne.
`localhost` ≠ `127.0.0.1` quand IPv6 est activé Env / config axe 4.4 🔗
🩹 Symptôme
// Le serveur écoute sur 127.0.0.1:3000
const server = app.listen(3000, '127.0.0.1');
// Le client tente :
fetch('http://localhost:3000/health');
// → ECONNREFUSED parfois, surtout sur Windows / macOS récent
🔍 Cause
localhost résoud en IPv6 (::1) avant IPv4 (127.0.0.1) sur la plupart des OS modernes. Si ton serveur écoute uniquement sur IPv4 127.0.0.1, le client peut taper sur ::1 et échouer. 🩺 Fix
Trois options :
1. Bind sur tout (le plus simple en dev) :
app.listen(3000, '0.0.0.0'); // IPv4 + IPv6
2. Utiliser localhost partout côté serveur ET client :
app.listen(3000, 'localhost');
3. Forcer IPv4 dans Node :
NODE_OPTIONS='--dns-result-order=ipv4first' node server.js 🧠 Leçon
Sur Windows/macOS récents, IPv6 est résolu en premier. Toujours 0.0.0.0 ou localhost (pas 127.0.0.1 strict) en dev. C'est le piège #1 quand un dev passe d'un OS à un autre dans une équipe.
Variable `.env` modifiée non prise en compte par Vite/Next Env / config axe 4.4 🔗
🩹 Symptôme
# Tu modifies .env :
# VITE_API_URL=https://new-api.example.com
# Tu rafraîchis le navigateur :
console.log(import.meta.env.VITE_API_URL);
// → toujours l'ancienne valeur
🔍 Cause
Vite (et Next.js) inlineent les vars d'env au moment du build, pas du runtime. Modifier .env ne change rien tant que le serveur dev n'est pas redémarré.
🩺 Fix
Restart hard :
# Ctrl+C, puis :
npm run dev
Pour les vars runtime (non-bundlées) côté serveur Node, utiliser process.env directement — il est lu à chaque request, pas inliné.
console.log('Loaded env:', { API_URL: process.env.API_URL }); 🧠 Leçon
Vars d'env bundlées (VITE_*, NEXT_PUBLIC_*) = restart obligatoire après changement. Vars serveur (process.env.X) = lues à chaque request. Si une var "ne se met pas à jour", check d'abord ce point.
Scripts shell qui utilisent `grep -P` cassent sur git-bash Windows Env / config axe 4.2 🔗
🩹 Symptôme
grep: -P supports only unibyte and UTF-8 locales
# Sur Linux ou macOS le même script fonctionne sans problème.
# Sous git-bash Windows, le PCRE de grep refuse d'opérer dans la locale par défaut.
🔍 Cause
Le binaire grep distribué avec git-for-Windows a été compilé sans support PCRE complet, ou bien la locale Windows par défaut (C ou cp1252) n'est pas compatible avec le mode -P. C'est une limitation du portage MSYS de grep, pas un bug.
Beaucoup de scripts CI/test écrits sur Linux font grep -oP 'pattern' et plantent silencieusement quand un dev tente de les rejouer sous Windows.
🩺 Fix
Utiliser sed -E (POSIX, marche partout) au lieu de grep -P. La capture passe par un groupe \1 :
# ❌ Cassé sous git-bash Windows
time_ms=$(echo "$output" | grep -oP 'Time: \K[\d.]+')
# ✅ Portable POSIX
time_ms=$(echo "$output" | grep "Time:" | tail -1 | sed -E 's/.*Time: ([0-9]+\.[0-9]+).*/\1/')
Alternatives :
- ripgrep (
rg) si tu peux dépendre d'un binaire externe. - Locale forcée :
LC_ALL=C.UTF-8 grep -P …(parfois insuffisant sous Windows). awk '{print $2}'si la position dans la ligne est stable.
🧠 Leçon
Si un script shell est censé tourner cross-OS (Linux/Mac/Windows), reste POSIX strict : sed, awk, grep (sans -P), tr. Les extensions GNU (grep -P, sed -i sans backup, xargs -d, mktemp -d) cassent souvent quelque part.
Mémo : avant de commit un script shell, mentalement check chaque flag — est-ce POSIX ? Si non, ya-t-il un équivalent ? Cinq minutes de discipline économisent une heure de debug Windows plus tard.
🔐 Authentification(2)
OAuth `redirect_uri` mismatch en prod Auth axe 10.0 🔗
🩹 Symptôme
# Ça marche en local : http://localhost:5173/auth/callback
# En prod : https://app.example.com/auth/callback
# → Erreur Google : "redirect_uri_mismatch"
🔍 Cause
Les providers OAuth (Google, GitHub, Clerk) exigent que le redirect_uri envoyé soit exactement dans la liste configurée côté provider — http vs https, port, trailing slash, tout compte.
🩺 Fix
Configurer toutes les URLs valides côté provider :
http://localhost:5173/auth/callback ← dev
https://preview-*.app.example.com/auth/callback ← previews Vercel
https://app.example.com/auth/callback ← prod
Côté code, calculer le redirect_uri dynamiquement à partir de l'origine de la request :
const redirectUri = new URL('/auth/callback', request.headers.origin).toString();
Ne JAMAIS hardcoder https://app.example.com/... — ça casse en preview. 🧠 Leçon
OAuth est strict sur redirect_uri. Toujours déclarer dev + previews + prod côté provider. Calculer dynamiquement côté code. La 1ère erreur de tout projet OAuth est ce mismatch.
hono/jwt verify échoue : « JwtAlgorithmRequired » Auth axe 8.0 🔗
🩹 Symptôme
JwtAlgorithmRequired: JWT verification requires "alg" option to be specified
at verifyToken (src/lib/jwt.ts:22)
🔍 Cause
Depuis Hono 4.x, verify(token, secret) ne prend plus d'algorithme par défaut — il faut le passer explicitement. C'est une mesure de sécurité : ne pas spécifier l'algorithme rend l'app vulnérable à l'attaque dite alg-confusion (un attaquant qui forge un token alg=none ou en HS256 contre une clé RS256 publique).
🩺 Fix
Passer explicitement l'algorithme à sign ET verify :
const ALG = 'HS256' as const;
export async function signToken(userId: number) {
return sign({ sub: String(userId), exp: ... }, JWT_SECRET, ALG);
}
export async function verifyToken(token: string) {
return verify(token, JWT_SECRET, ALG);
}
🧠 Leçon
Pour les libs sécurité, toujours lire le changelog avant d'upgrader. Quand une lib bouge ses defaults dans le sens « plus strict », c'est généralement parce qu'on a corrigé une vulnérabilité connue. Suivre les releases JWT / Auth0 / hono (channel security) vaut le coup.
📦 Dépendances(2)
Zod 4 incompatible avec @hono/zod-validator Dépendances axe 6.4 🔗
🩹 Symptôme
npm error code ERESOLVE
peer zod@"^3.19.1" from @hono/zod-validator@0.4.3
Found: zod@4.4.1
🔍 Cause
Zod 4 a introduit des breaking changes dans son API interne. Beaucoup de libs de l'écosystème (validators, ORM intégrations) ont des peer dependencies fixées sur Zod 3 et n'ont pas encore publié de version compatible Zod 4.
🩺 Fix
Deux options selon le contexte :
- Conservateur : downgrade Zod à
^3.23.8danspackage.json— fonctionne avec tout l'écosystème actuel. - Bleeding edge : vérifier si
@hono/zod-validator@0.5+est sorti, qui supporte Zod 4. Pour les autres libs, idem.
🧠 Leçon
Avant d'upgrader une dépendance « cœur » (Zod, React, TypeScript, ORM), faire npm ls ou npm-check-updates --doctor pour repérer les peer-deps qui ne suivront pas. Une migration majeure ne se fait jamais seule — elle entraîne tout son écosystème.
Pydantic v2 + EmailStr : « email-validator is not installed » Dépendances axe 8.0 🔗
🩹 Symptôme
ImportError: email-validator is not installed,
run pip install 'pydantic[email]'
Apparait au premier import du module qui contient un BaseModel avec EmailStr,
souvent à la collecte des tests :
ERROR tests/test_api.py
Interrupted: 1 error during collection
🔍 Cause
Pydantic v2 ne ship pas email-validator par défaut pour ne pas alourdir l'install. EmailStr (et HttpUrl qui valide aussi via DNS) ont besoin de cette dep optionnelle. Si on a juste pydantic dans pyproject.toml, la première utilisation crashe — pas au démarrage du serveur, mais à l'import du module concerné (ce qui peut se produire en collecte de tests, et masquer la cause).
🩺 Fix
Toujours déclarer pydantic[email] dans les deps :
# pyproject.toml
dependencies = [
"pydantic[email]>=2.10",
# ...
]
Puis uv sync (ou pip install -e .).
Si tu utilises aussi HttpUrl qui fait des résolutions DNS au runtime, elle est OK avec pydantic[email] — c'est le même paquet d'extras.
🧠 Leçon
Les extras Pydantic ([email], [timezone]) sont opt-in pour économiser quelques Mo. C'est aussi le cas pour des libs comme passlib[argon2], uvicorn[standard], requests[security]. Quand une lib propose des extras dans sa doc, scrute-les dès le départ : la lib t'évitera 30 minutes de debugging plus tard.
Mémo : si tu vois un ImportError: X is not installed après un uv sync qui semblait OK, c'est presque toujours un extra manquant.
Aucun piège ne correspond. Réinitialise les filtres ou modifie ta recherche.
🧰 Le format en détail
Section intitulée « 🧰 Le format en détail »Chaque piège suit la même structure :
| Champ | Quoi |
|---|---|
| 🩹 Symptôme | Ce que tu vois quand le bug survient (message d’erreur exact, comportement bizarre, signe extérieur). Conçu pour être searchable : si tu cherches dans Google, c’est ce que tu copies. |
| 🔍 Cause | Le pourquoi sous-jacent. Pas juste « ça plante » — la mécanique qui cause le crash. |
| 🩺 Fix | Ce que tu fais concrètement pour résoudre. Souvent du code à coller, parfois une commande, parfois un changement de stratégie. |
| 🧠 Leçon | Le principe général à retenir. Pour ne pas seulement résoudre CE bug, mais éviter les bugs similaires dans l’avenir. |
📝 Contribuer
Section intitulée « 📝 Contribuer »Si en suivant le guide tu rencontres un piège qui n’est pas listé ici, c’est précieux : ouvre une issue sur le repo avec ces 4 champs remplis, on l’ajoute. La meilleure ressource pédagogique est celle que les apprenants enrichissent.