From e4960af7f8c3e076a715d256780e3656277812ba Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Mon, 7 Sep 2026 10:50:15 +0200 Subject: [PATCH 01/80] =?UTF-8?q?docs(adr):=20aligner=20les=20ADR=20sur=20?= =?UTF-8?q?les=20d=C3=A9cisions=20du=20socle=20API-first?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Les aliases @controllers/, @services/ ne viennent pas d'igo mais de module-alias déclaré par le projet : l'ADR les présentait à tort comme imposés par le framework. - Format d'erreur API : RFC 9457 Problem Details, imposé plutôt que laissé au choix de chaque projet. - Validation : middleware global monté par igo, schéma attaché au handler, signature en Standard Schema. - Squelette greenfield sans alias — les fichiers d'une feature sont côte à côte. - Feuille de route : observabilité réalignée sur Grafana Cloud (l'ADR fusionné a écarté Sentry), .d.ts et squelettes remontés en phase 1. Co-Authored-By: Claude Opus 5 (1M context) --- docs/adr/architecture-front-de-reference.md | 192 +++++++++++ docs/adr/chaine-de-build-du-front.md | 73 +++++ docs/adr/format-echange-front-back.md | 52 +++ docs/adr/organisation-des-sources-back.md | 346 ++++++++++++++++++++ docs/adr/organisation-des-sources-front.md | 220 +++++++++++++ docs/adr/socle-back-nouveaux-projets.md | 213 ++++++++++++ docs/adr/strategie-de-test-back.md | 142 ++++++++ docs/adr/strategie-de-test-front.md | 175 ++++++++++ docs/adr/strategie-de-validation.md | 56 ++++ docs/adr/strategie-observabilite.md | 248 ++++++++++++++ docs/adr/systeme-de-design.md | 129 ++++++++ docs/adr/technologie-de-composants-front.md | 181 ++++++++++ docs/cadre-decision-stack-front.md | 104 ++++++ docs/feuille-de-route-socle-igo.md | 64 ++++ 14 files changed, 2195 insertions(+) create mode 100644 docs/adr/architecture-front-de-reference.md create mode 100644 docs/adr/chaine-de-build-du-front.md create mode 100644 docs/adr/format-echange-front-back.md create mode 100644 docs/adr/organisation-des-sources-back.md create mode 100644 docs/adr/organisation-des-sources-front.md create mode 100644 docs/adr/socle-back-nouveaux-projets.md create mode 100644 docs/adr/strategie-de-test-back.md create mode 100644 docs/adr/strategie-de-test-front.md create mode 100644 docs/adr/strategie-de-validation.md create mode 100644 docs/adr/strategie-observabilite.md create mode 100644 docs/adr/systeme-de-design.md create mode 100644 docs/adr/technologie-de-composants-front.md create mode 100644 docs/cadre-decision-stack-front.md create mode 100644 docs/feuille-de-route-socle-igo.md diff --git a/docs/adr/architecture-front-de-reference.md b/docs/adr/architecture-front-de-reference.md new file mode 100644 index 00000000..757350a5 --- /dev/null +++ b/docs/adr/architecture-front-de-reference.md @@ -0,0 +1,192 @@ +# Architecture front de référence + +**Statut** : accepté — pré-mortem passé le 20/08/2026 +**Date** : 2026-08-20 +**Décideur** : direction de l'agence +**Portée** : projets dont l'agence assure le build **et** le run. Les projets de build seul peuvent recevoir une stack imposée par le client. + +## Context and Problem Statement + +Le front des applications igo repose sur jQuery : 126 modules, ~8 000 lignes réparties entre ladom et certigo, plus 1 669 templates dust. L'inventaire des besoins d'interactivité montre que **~85 % de la surface réactive exige un modèle de composants et d'état** ; une mise à jour partielle de HTML ne couvre que les ~15 % restants (filtres, tri, pagination). + +`@igojs/component`, écrit pour répondre à ce besoin, est aujourd'hui fonctionnellement complet — composition, réconciliation de listes par clé, état partagé, événements parent↔enfant — mais **au strict minimum** : ni typage, ni transitions, ni testabilité applicative. Il a été construit en sept mois et demi par une seule personne, au rythme de huit releases entre le 21 mai et le 17 juin 2026, après avoir été prototypé dans certigo puis extrait. + +La question n'est donc plus la capacité, mais le **coût de l'écran suivant**. Trois écrans évalués sur le code donnent un taux d'environ **un sur trois** déclenchant du travail de framework ou un contournement — et ce taux est **prévisible en nature** : le péage ne se déclenche pas sur la complexité de la logique mais sur l'habillage (animations, réinitialisation de plugins, tooltips), soit ~3 800 des ~5 850 lignes de surface réactive. + +Deux refontes clientes, non encore confirmées, motivent une décision **prête en septembre 2026**, pour un horizon de 3 à 5 ans. + +## Decision Drivers + +Pondérations arrêtées par l'équipe en atelier le 19 août 2026, échelle 0-5 où **5 = un mauvais résultat peut à lui seul écarter un candidat**. + +| Poids | Critère | +|:--:|---| +| **5** | Capacité réactive | +| **5** | Testabilité — *« aucun test front écrit aujourd'hui »* | +| **5** | Exploitation et stabilité en production | +| **5** | Coût de framework par écran porté | +| 4-5 | Expérience développeur, sur la durée | +| 4 | Attractivité au recrutement · richesse de l'écosystème · TypeScript · onboarding | +| 3-4 | Charge de maintenance et montées de version | +| 3 | Coût de mise en œuvre · documentation et communauté · compétences en place | +| 2 | Poids de bundle et performance mobile | + +**Deux convergences distinctes, à ne pas confondre — leur fusion sous un seul libellé a produit un faux désaccord :** + +| Critère | Ce que c'est | Position | +|---|---|---| +| **Convergence du socle technique** | Même framework sur tous les projets : les corrections de bugs se capitalisent, une seule chose à apprendre | **Équipe : pas déterminant** — *« avantage historique fort, capitalisation des bugs transverses, mais moins critique aujourd'hui »* | +| **Convergence des compétences et du paradigme** | Des personnes interchangeables entre projets web et mobile, un vivier de recrutement commun | **Direction : important.** Non retenu comme tel par l'atelier | + +> **Divergence assumée, non tranchée ici.** Les deux positions portent sur des objets différents et peuvent être vraies simultanément ; le poids relatif à leur accorder fait partie de l'arbitrage. La seconde relève de l'**axe 2** — le choix de technologie de composants — et sera instruite dans l'ADR correspondant. + +Contrainte transverse : **pas de complexité pour rien.** Le SEO et la performance web ne sont pas critiques sur les projets à ce jour. + +## Considered Options + +1. **igo + `@igojs/component`** — maintien, plus ajout des fonctionnalités manquantes. +2. **Front à composants buildé en assets statiques**, API JSON exposée par igo. Deux modes de livraison : **un artefact** (front dans le dépôt igo) ou **deux artefacts** (front livré à part). +3. **Le socle back entre dans le périmètre**, pour les **nouveaux projets uniquement** — axe distinct, ADR distinct. + +> ⚠️ **L'option 2 ne désigne aucune technologie de composants.** Le POC est en React ; Vue, Svelte, Solid et Lit sont éligibles et **non évalués**. Ce choix relève d'un ADR distinct, qui ne peut être tranché qu'après celui-ci. + +> **Écarté d'emblée : un front avec son propre serveur** — BFF ou rendu serveur. Les deux motifs sont absents : pas d'exigence SEO, et le BFF est jugé superflu *« si les API sont déjà bien conçues pour le front »*. À réexaminer si un secret doit rester hors du navigateur, ou si une API tierce doit être proxifiée. + +## Pros and Cons of the Options + +### Option 1 — igo + `@igojs/component` + +- Bon : **un seul livrable, une seule instance.** La stabilité en production est une force constatée — *« les apps tournent bien, les mails de crash permettent une intervention rapide »*. +- Bon : **migration nulle**, aucun coût d'entrée. +- Bon : maîtrise totale — un besoin non couvert peut être ajouté sans attendre un tiers. +- Bon : `db`, `dust` et `server` sont matures et stables depuis dix ans. +- Bon : **4 personnes sur 6** travaillent sur la stack au quotidien. + +- Mauvais : **coût de framework récurrent**, sur une seule personne, et découvert *« en butant en cours de route »* — donc non provisionnable. +- Mauvais : **aucun harnais de test côté client** — ni composant ni template. +- Mauvais : **pas d'écosystème natif** — le vanilla est enveloppé à la main, et c'est là que tombe le plafond constaté de ~8M opérations pour 1 000 tuiles, faute de virtualisation disponible. +- Mauvais : **attractivité au recrutement faible** — *« stack propriétaire perçue négativement par les juniors »*. +- Mauvais : pas de typage, props non validées, erreurs silencieuses (`user.name` sur `null` n'affiche rien et ne plante pas). +- Mauvais : `component` est la couche la plus jeune et la plus mouvante du framework — huit releases en quatre semaines. +- Mauvais : **l'assistance par LLM y est structurellement plus faible.** Un modèle a vu des quantités massives de code des stacks de marché et quasiment aucun code igo. Le facteur d'accélération que le chiffrage de ladom pose à ×2 n'est donc probablement pas le même des deux côtés. **Hypothèse non mesurée** — listée en dernier à ce titre. + +### Option 2 — Front à composants buildé en assets statiques + +- Bon : **testabilité, typage, écosystème et onboarding fournis** par l'écosystème. +- Bon : **coût de framework nul** — l'habillage manquant est déjà écrit ailleurs. +- Bon : **aucun process supplémentaire** — les assets sont servis par nginx. L'exploitation, critère à 5, n'est pas dégradée. +- Bon : **démontré par un POC** sur l'espace stagiaire de certigo, en **moins d'une journée**, SCORM inclus. +- Bon : corrige des défauts existants par construction — le bouton « Transmettre » absent après dépôt Ajax, le timer des tests théoriques mis en échec par les coupures réseau. +- Bon : même origine — pas de CORS, le cookie de session fonctionne tel quel, pas de pont d'auth. + +- Neutre : **les compétences en place dépendent de l'axe 2, pas de celui-ci.** **4 personnes sur 6 connaissent React** — mais le chiffre tombe si une autre technologie est retenue, et il est inconnu pour Vue, Svelte, Solid ou Lit. Critère pondéré 3, donc peu structurant. +- Neutre : **l'assistance par LLM réduit le coût d'apprentissage** d'une stack nouvelle, ce qui affaiblit d'autant l'objection des compétences. +- Neutre : exige un mécanisme de validation d'API côté serveur — mais ce coût échoit aussi à l'option 1 dès qu'elle passe au JSON. + +- Mauvais : dépendance à la cadence d'un écosystème externe — l'équipe cite *Svelte 3→5* en contre-exemple. +- Mauvais : **cohabitation Vite + Webpack sur un projet existant jugée complexe** — *« un projet vierge aurait été plus simple »*. Nul en greenfield. + +#### Mode de livraison — un artefact ou deux + +Aucun des deux modes n'ajoute de process : **nginx sert les assets dans les deux cas** (`try_files $uri @app`, le Node n'étant qu'un repli). Ce qui diffère est le cycle de vie. + +| | **Un artefact** | **Deux artefacts** | +|---|---|---| +| **Cohérence front / API** | Garantie par construction · retour arrière atomique | **À discipliner** — désynchronisation possible. C'est ici qu'un contrat d'API explicite devient structurant | +| **Correctif front** | Redéploie tout, avec la coupure `pm2 delete` → `pm2 start` | Indépendant, sans coupure | +| **Tests E2E** | Dans le pipeline unique | À décider : ils traversent les deux artefacts | +| **Supervision** | Rien de neuf | Une cible de plus · invalidation de cache si CDN | +| **Livraison** | nginx existant | **CDN possible** | +| **Ce qu'on achète** | La **simplicité** : rien de neuf à superviser, cohérence garantie, un seul pipeline | L'**indépendance** : livrer le front sans coupure ni redéploiement de l'API, et la porte ouverte au CDN | +| **Ce qu'on paie** | Toute correction front redéploie l'application et paie la coupure `pm2` | Une discipline de contrat et de versionnement, plus une cible de supervision | + +**Verdict du sous-axe.** **Un artefact est le choix par défaut** : c'est le chemin du POC, il ne demande aucune infrastructure nouvelle, et il est cohérent avec le critère d'exploitation pondéré 5. **Deux artefacts se justifient à deux conditions**, dont aucune n'est réunie aujourd'hui — que la coupure de déploiement devienne gênante en exploitation, ou qu'une livraison par CDN devienne nécessaire. + +Sur ce second point, la tension mérite d'être notée : le contexte outre-mer plaide pour garder la porte ouverte — le vhost de production autorise nommément des IP de La Réunion, Guadeloupe, Guyane et un lien **Starlink à Mayotte** — mais l'équipe pondère la performance mobile à **2**, ce qui en réduit l'urgence. **Ce choix n'engage pas l'architecture** : il peut être tranché au premier déploiement réel, ou renversé plus tard sans rien réécrire. + +### Option 3 — Le socle back entre dans le périmètre, pour les nouveaux projets uniquement + +**Ce n'est pas une troisième architecture front.** C'est un choix d'architecture front (option 1 ou 2) **plus** une question distincte sur le socle serveur, restreinte au greenfield. Elle est listée ici parce que l'équipe l'a formulée comme un scénario, mais elle relève d'un axe propre et d'un ADR distinct. + +**Ce que « remplacer le back » veut dire — et ne veut pas dire.** igo côté serveur, c'est Express plus un ORM, plus des conventions (routage par fichier, config, i18n, mail, cache, logs), plus une couche vue et une chaîne de build. À mesure que la couche vue est retirée — dust réduit aux PDF, forms hors sujet, webpack remplacé par Vite — **ce qui reste se concentre sur l'ORM et les conventions.** C'est là qu'est la valeur accumulée sur dix ans. Poser la question n'implique donc pas de quitter igo : la réponse peut être « on garde ». + +Deux sous-questions indépendantes, avec des éléments internes déjà disponibles : + +| Sous-question | Éléments constatés | +|---|---| +| **Express, ou autre ?** | *« Fastify plus performant sous charge, migration non triviale mais intéressante à explorer »* (démo du 20/08) | +| **`@igojs/db`, ou autre ?** | Pour : optimisations fines possibles — pattern de jointure custom. Contre : *« cache de requêtes potentiellement non optimisé sous forte charge, identifié sur Certigo »*. Comparaison non théorique : `api-ceremonie` tourne sous **MikroORM** chez funecap | + +- Bon : **restreinte au greenfield, c'est une expérience sans risque** — pas de migration, pas de cohabitation, rien en jeu sur un existant qui tourne. C'est le seul endroit du parc où un socle back peut être évalué gratuitement. +- Bon : la variante Next.js + NestJS est éprouvée en interne et connue de trois personnes. +- Mauvais : multiplier les socles back dans une agence de six personnes va contre la convergence du socle technique, dont l'avantage historique est reconnu même par ceux qui le jugent moins critique aujourd'hui. +- Mauvais : **la variante Next.js est écartée par l'équipe** — *« complexité ajoutée sans bénéfice SEO réel ici »* — et elle implique un runtime supplémentaire. + +**Différée** : sans objet tant qu'aucun projet neuf n'est lancé, et à instruire alors dans son propre ADR. + +## Decision Outcome + +**Option 2 retenue : un front à composants buildé en assets statiques, consommant une API JSON exposée par igo. Mode de livraison : un artefact** — le front vit dans le dépôt igo et se déploie avec lui. + +### Ce qui a décidé + +Sur les quatre critères pondérés 5, **deux ne discriminent pas et deux tranchent**. + +- **La capacité réactive ne discrimine pas.** Le socle réactif d'`@igojs/component` est complet — état, dérivation, composition, listes par clé, store, événements parent↔enfant. `planner/sessions`, le plus gros module jQuery du parc, est portable sans rien ajouter. **L'option 1 n'a pas été écartée pour une insuffisance du modèle réactif.** +- **L'exploitation plaidait pour l'option 1, et le mode un artefact referme l'écart.** nginx sert déjà les assets sur le parc existant ; le front buildé s'y insère sans process supplémentaire. Un déploiement, une supervision, retour arrière atomique. +- **La testabilité tranche, et c'est le vrai problème d'igo — d'où le poids de 5.** Le JS existe en volume et **n'est jamais testé** : le seul filet est une poignée d'E2E, trop lourds pour être nombreux, qui tiennent lieu de tests de composants. Vérification faite dans les sources : `@igojs/component` a ses propres tests, mais **aucun harnais pour tester un composant applicatif côté client** — pas d'environnement DOM dans la chaîne, pas d'utilitaire de montage exporté, et les tests du framework le montrent en creux en n'instanciant aucun composant. En option 1 ce harnais reste à construire ; en option 2 c'est une commodité qu'on installe. +- **Le coût de framework par écran tranche, et pour une raison de maturité plus que de taux.** Le taux passé — une intervention par écran porté, puis environ un écran sur trois sur les trois cas évalués — est une borne haute gonflée par la genèse. Mais le jugement du mainteneur ne porte pas sur un taux : **le péage reste probable tant que la couche n'a pas la maturité de `@igojs/server` ou de `@igojs/db`.** Or cette maturité s'achète en temps et en volume d'usage — dix ans sur tout le parc pour les deux paquets mûrs, contre la capacité résiduelle d'une personne pour la couche front. À l'échelle d'une agence de 5-6, l'asymétrie ne se referme pas. + +Le péage est par ailleurs **caractérisé** : il ne se déclenche pas sur la complexité de la logique mais sur l'habillage — animations, plugins à réinitialiser, tooltips — soit, d'après la ventilation de l'inventaire, environ 3 800 des ~5 850 LOC de surface réactive. C'est précisément le domaine qu'un écosystème de composants couvre, et c'est ce qui rend le critère « richesse de l'écosystème » structurant plutôt que confortable. + +Tous les critères pondérés 4 et 4-5 — expérience développeur, écosystème, attractivité, TypeScript, onboarding — vont dans le même sens. Le seul qui tire vers l'option 1 en dehors de l'exploitation est « compétences en place », pondéré 3. + +### Ce qui n'a pas décidé, et qu'il faut dire + +- **Le coût déjà engagé sur `@igojs/component`** — de quelques jours à quelques semaines. Il est derrière et n'a pesé dans aucun sens. +- **La continuité du socle** — plusieurs personnes savent le maintenir, une seule en a la maîtrise fine — tenue volontairement hors de la matrice : c'est un sujet d'organisation, pas de technologie. +- **La convergence des compétences et du paradigme**, importante pour la direction, qui relève de l'axe 2 et non de celui-ci. +- **Aucune technologie de composants n'est retenue par cette décision.** Le POC est en React ; Vue, Svelte, Solid et Lit restent éligibles et non évalués. + +### Pourquoi un artefact plutôt que deux + +Le mode un artefact **neutralise le seul critère à 5 qui plaidait pour rester** : il conserve le livrable unique, la supervision unique et le retour arrière atomique, tout en donnant accès à l'écosystème. Le mode deux artefacts reste disponible plus tard — le front étant buildé en assets statiques dans les deux cas, le passage de l'un à l'autre ne remet pas le front en cause. Rien n'oblige à payer maintenant une séparation dont le besoin n'est pas établi. + +### Consequences + +- Bon : **la testabilité devient un choix d'installation, non un chantier de framework.** +- Bon : **le péage sur l'habillage est transféré à un écosystème** dont d'autres paient la maturité. +- Bon : **plus fort que prévu sur la catégorie la plus douloureuse.** La recherche de l'axe 2 a établi que les transitions et animations — le manque nommément identifié par la rétrospective, ~3 800 des 5 850 lignes exposées — sont fournies **par le cœur** de Vue et de Svelte, sans aucune dépendance tierce. Ce n'est donc pas un écosystème à surveiller, c'est un acquis de plateforme. +- Bon : **igo se recentre sur ses deux paquets mûrs** — Express et l'ORM. Ce n'est pas un désaveu du socle, c'est un rétrécissement vers ce qu'il fait de mieux depuis dix ans. +- Bon : sortir de jQuery **débloque la migration vers Vite**, aujourd'hui empêchée par l'incompatibilité du chargement parallèle. Gain non attribuable à cette option — l'option 1 l'aurait aussi obtenu. +- Neutre : **les compétences en place restent inconnues** jusqu'à l'axe 2. Quatre personnes sur six connaissent React, mais le chiffre ne vaut que pour React. +- Neutre : **aucune migration d'existant n'est engagée.** Chaque refonte se décide ensuite, projet par projet, en cohabitation durable avec les 1 669 templates dust. Le build ne doit pas présupposer une reprise intégrale. +- Mauvais : **l'axe 2 devient bloquant.** Aucune mise en œuvre n'est possible avant de choisir la technologie de composants, et ce choix demande de la recherche externe. +- Mauvais : **exige un mécanisme de validation d'API côté serveur** avant d'exposer du JSON sérieusement. Coût de framework à payer une fois, sur la personne déjà chargée. +- Mauvais : **`@igojs/component` passe en maintenance.** Les six écrans de certigo restent supportés, pas étendus. À acter explicitement, faute de quoi la couche dérive sans porteur. +- Mauvais : **la décision concentre le back sans alléger sa maintenance.** En rétrécissant igo à l'API et à l'ORM, on rend ces deux paquets *plus* porteurs. Plusieurs personnes savent les maintenir et l'assistance par LLM abaisse le coût de reprise : le risque de continuité est modéré, mais il se déplace vers le back au lieu de disparaître. +- Mauvais : **elle peut rester longtemps inexécutée**, les deux refontes n'étant pas confirmées. Elle vaudra alors pour le premier projet neuf, quel qu'il soit — mais chaque écran porté en `@igojs/component` dans l'intervalle augmente le coût de son application. + +## Confirmation + +Comment on saura, dans douze mois, si la décision était bonne : + +- **Coût de framework par écran porté** — la mesure qui a fondé la décision. Il doit tendre vers zéro dans l'option 2 ; il reste à surveiller dans l'option 1. +- **Existence de tests de composants** — aujourd'hui aucun, le JS n'étant jamais testé hors quelques E2E. Un an plus tard, un compte encore nul signifierait que la décision n'a pas produit son effet, et que le blocage n'était pas l'outillage mais l'habitude ou le budget. +- **Part de la couverture qui ne repose plus sur l'E2E** — l'enjeu n'est pas d'ajouter des tests lourds, c'est de faire redescendre la vérification au niveau du composant, où elle est rapide et bon marché. +- **Boucle de retour** mesurée (temps entre l'enregistrement d'un fichier et le résultat visible), à comparer au relevé initial. +- **Bus factor par couche** — nombre de personnes capables de faire évoluer chaque couche, seuil d'alerte en dessous de deux. +- **Garde-fou de réversibilité** : la première mise en œuvre se fait sur un périmètre abandonnable — un espace utilisateur isolé par sous-domaine — de façon qu'un retour arrière reste possible sans toucher au reste du parc. + +## Notes d'implémentation + +Deux réglages nginx à traiter dès la première mise en œuvre de l'option 2, valables quel que soit le mode de livraison. Ils ne pèsent pas sur la décision mais ne doivent pas se découvrir en production. + +- Le vhost applique `expires max` à tout `location /`. **Fatal sur `index.html`** : l'utilisateur garderait un fichier référençant des assets disparus. Il faut une `location = /index.html` à cache court — les autres assets étant déjà versionnés par empreinte. +- `try_files $uri @app` envoie les URL inconnues au Node. Un routage client a besoin d'un repli sur `index.html`. + +## More Information + +**Éléments de preuve** : inventaire du besoin de réactivité, rétrospective du coût de framework, atelier équipe du 19/08/2026, POC React sur l'espace stagiaire de certigo du 20/08/2026. Le [cadre de décision](../cadre-decision-stack-front.md) en tient l'index. + +**Sujet distinct, volontairement hors comparatif** : la continuité du socle igo — maîtrise inégalement répartie, coût de reprise abaissé par l'assistance LLM — à traiter quelle que soit l'issue de celle-ci. diff --git a/docs/adr/chaine-de-build-du-front.md b/docs/adr/chaine-de-build-du-front.md new file mode 100644 index 00000000..23806cc9 --- /dev/null +++ b/docs/adr/chaine-de-build-du-front.md @@ -0,0 +1,73 @@ +# Chaîne de build et de déploiement du front + +**Statut** : accepté +**Date** : 2026-08-21 + +## Context and Problem Statement + +L'ADR [Architecture front de référence](architecture-front-de-reference.md) a tranché la cible — front à composants buildé en assets statiques, API JSON, **un artefact**, assets servis par nginx. Il n'a pas dit **où vit la source du front**, **où tourne son build**, ni **comment on développe**. + +Trois contraintes de l'existant cadrent la question : + +- `ovh-ladom2` construit **sur le serveur** au déploiement : `npm ci` puis `npm run webpack-prod` dans une tâche Ansible. Le déploiement enchaîne `pm2 delete` et `pm2 start`, avec sa fenêtre d'indisponibilité. **Six environnements.** +- nginx sert déjà les statiques (`try_files $uri @app`). igo ne les sert pas. +- **Le seul « Mauvais » chiffré de l'option retenue** était la cohabitation de deux chaînes de build dans un même projet npm, jugée complexe par l'équipe — *« un projet vierge aurait été plus simple »*. + +## Considered Options + +**Où vit la source du front** + +1. **Dans le projet npm existant** — une seule arborescence, deux chaînes de build à faire cohabiter. +2. **En projet npm frère, dans le même dépôt** — deux `package.json`, deux jeux de dépendances, un dépôt. +3. **Dans un dépôt séparé** — cycle de vie indépendant, artefact publié puis récupéré. + +**Où tourne le build** + +A. **Sur le serveur**, pendant le déploiement, comme le webpack actuel. +B. **En CI**, avec un artefact déposé que le déploiement recopie. + +## Decision Outcome + +**Option 2 + A : un dépôt par projet, front en projet npm frère du back, buildé sur le serveur au déploiement.** + +- **Un dépôt par projet applicatif**, contenant le front et le back côte à côte. Pas de dépôt front partagé entre projets. +- **Le front est une SPA Vite ordinaire.** Sur ses routes, il possède la page entière : sa coquille est son propre `index.html`, produit par le build, servi par nginx. **Aucun template serveur n'intervient, donc aucun manifeste à lire côté back et aucune conditionnelle dev/prod dans un template.** Le motif des intégrations back de Vite (`vite_rails`, `django-vite`) ne s'applique pas ici : il n'existe que parce que le back rend la page. +- **Build sur le serveur** : une tâche Ansible de plus, à côté de celle qui existe. Les statiques produits sont recopiés dans le répertoire servi par nginx. +- **En développement** : serveur de dev Vite avec rechargement à chaud, et un **proxy `/api` vers le port d'igo**. Le navigateur voit tout en même origine, donc le cookie de session passe sans CORS. +- **Les URL d'API sont relatives** (`/api/...`). C'est ce qui permet au même `index.html` haché de fonctionner dans les six environnements sans être recompilé par environnement. + +### Pourquoi les autres ont été écartées + +- **Option 1** — c'est exactement le point de friction mesuré. Deux répertoires frères avec leurs dépendances propres ne cohabitent pas, ils se juxtaposent : le problème disparaît au lieu d'être géré. +- **Option 3** — elle règle la friction de build, mais introduit une discipline de version entre deux dépôts : quelle version du front est déployée avec quelle version de l'API. Le dépôt unique rend cette cohérence **gratuite** — un commit porte un front et un back cohérents. +- **Un dépôt front partagé entre projets** — il ferait monter les versions une fois au lieu de N, mais imposerait la montée à tous les clients en même temps, alors que chaque projet a son budget et son go. Il mettrait aussi à portée de main le paquet interne partagé que l'ADR [Système de design](systeme-de-design.md) a refusé. +- **Option B** — meilleure sur le fond, prématurée ici. Elle demande de décider où vit l'artefact et d'ajouter un maillon à la chaîne, pour un risque qui ne s'est pas encore matérialisé. *Pas de complexité pour rien.* + +## Consequences + +- Bon : **la cohérence front/back est structurelle**, pas procédurale. Un commit, un déploiement, un artefact — conforme au mode de livraison décidé. +- Bon : la friction de cohabitation des chaînes de build **disparaît**, sans rien construire pour l'éviter. +- Bon : le développement du front est celui de n'importe quel projet Vite. Rien de spécifique à igo à apprendre, sauf le proxy. +- Bon : **l'existant n'est pas touché.** La tâche webpack actuelle continue de tourner à l'identique. + +- Neutre : six environnements, six builds. C'est déjà le régime actuel — pas de régression, pas d'amélioration. +- Neutre : le passage en CI reste ouvert et **peu coûteux** : c'est un déplacement de tâche, il ne remet aucune décision en cause. + +- Mauvais : **le build reste sur la production.** Le pré-mortem avait soulevé le scénario du build qui échoue faute de mémoire et laisse l'application entre deux états. Ce risque est reconduit et **assumé**. Sortie identifiée : basculer en CI. +- Mauvais : un `index.html` construit à l'avance **ne peut rien recevoir du serveur**. Pas d'utilisateur pré-sérialisé, pas de jeton dans une balise `meta`. Le front démarre par un appel du type `/api/me`, avec l'état de chargement initial que ça implique. Acceptable ici — SEO et web perf non critiques — mais c'est une contrainte, pas un détail. + +## Confirmation + +Trois vérifications avant le premier déploiement en production : + +1. **Ce que consomme le build Vite sur le serveur**, en mémoire et en temps, comparé au `webpack-prod` actuel. Si l'écart est significatif, l'option B se justifie tout de suite. +2. **Le proxy de développement face à la session d'igo** — que le cookie et les redirections d'authentification traversent bien. +3. **La configuration nginx du sous-espace front** : `try_files` doit retomber sur son `index.html` pour que le routage client fonctionne, et cet `index.html` **ne doit pas recevoir `expires max`** — les notes d'implémentation de l'ADR d'architecture le signalent déjà comme fatal. + +## More Information + +Cette décision met en œuvre [Architecture front de référence](architecture-front-de-reference.md) sans le contraindre : le choix du lieu de build est réversible dans les deux sens. + +Le refus du dépôt front partagé prolonge l'arbitrage de [Système de design](systeme-de-design.md) — on copie, on possède, on accepte la divergence, plutôt que de reconstruire une couche maison partagée. + +Le chiffre de la cohabitation des chaînes de build vient de l'atelier équipe du 19/08/2026. diff --git a/docs/adr/format-echange-front-back.md b/docs/adr/format-echange-front-back.md new file mode 100644 index 00000000..bd920770 --- /dev/null +++ b/docs/adr/format-echange-front-back.md @@ -0,0 +1,52 @@ +# Format d'échange front/back + +**Statut** : accepté +**Date** : 2026-08-20 + +## Context and Problem Statement + +Aujourd'hui le front des applications igo échange avec le serveur par **soumission de formulaires** et réception de **HTML rendu**. Là où de l'Ajax a été écrit à la main, le serveur renvoie encore un fragment HTML injecté dans la page — par exemple `document-upload.js` dans ladom : + +```js +success: function(html) { upload.find('.upload-content').html(html); window.refreshAll(); } +``` + +Ce modèle a deux défauts constatés. **Les plugins ne sont pas rebranchés** après injection, ce qui décourage de faire de l'Ajax et pousse à recharger la page entière. Et **la zone remplacée est plus étroite que la zone à mettre à jour** : c'est la cause racine du bouton « Transmettre » qui n'apparaît pas après un dépôt de pièce — sa visibilité dépend de la liste des documents, mais il vit en dehors du fragment renvoyé. Le défaut a été remonté du terrain et n'a reçu qu'un contournement partiel. + +Un front à modèle de composants a besoin de **données**, pas de balisage : il rend lui-même. + +## Considered Options + +1. **JSON** — le serveur expose des routes `/api` renvoyant des données ; le front rend. +2. **Conserver les fragments HTML rendus par le serveur** et les injecter côté client. +3. **Modèle mixte** — HTML pour les zones existantes, JSON pour les écrans nouveaux. + +## Decision Outcome + +**Option 1 retenue : le serveur expose du JSON.** + +- Les nouveaux échanges passent par des **routes `/api` renvoyant des données**, non du balisage. +- Les erreurs de validation sont renvoyées **structurées**, dans un format stable que le front affiche sans transformation. +- **Conséquence assumée** : l'actif « forms + validation » d'igo devient hors sujet sur les écrans concernés. Ce n'est pas une perte à compenser, c'est un changement de couche. +- **L'option 3 reste le régime transitoire de fait**, non par choix : les écrans non portés continuent de fonctionner en formulaires et HTML aussi longtemps qu'ils ne sont pas reprises. La cohabitation est durable — 1 669 templates dust ne seront pas réécrits. + +### Pourquoi les autres ont été écartées + +- **Option 2** — un modèle de composants qui reçoit du HTML étranger entre en conflit avec sa propre réconciliation du DOM, et le défaut de périmètre de la zone remplacée subsiste. Elle reconduirait la classe de bug qu'on cherche à éliminer. +- **Option 3 comme cible** — deux formats d'échange maintenus indéfiniment doublent les chemins de code et les modes de défaillance, sans bénéfice une fois le JSON en place. Acceptable en transition, pas comme état stable. + +## Consequences + +- Bon : le bug de la zone trop étroite **disparaît par construction** — la visibilité d'un élément se dérive de l'état, et il se rend où qu'il soit dans la page. +- Bon : ouvre l'inférence de types côté front et la dérivation d'un contrat, si un schéma isomorphe est retenu. + +- Neutre : question ouverte soulevée par l'équipe — faut-il **générer les types TypeScript depuis un contrat OpenAPI** pour éviter la dérive back/front, ou les écrire à la main comme dans le POC ? À trancher séparément. + +- Mauvais : **exige un mécanisme de validation d'API côté serveur** avant de pouvoir exposer du JSON sérieusement. igo n'a aucun outillage de contrat — OpenAPI, Swagger ou JSON-schema — à ce jour. +- Mauvais : les routes `/api` du POC ont été **dupliquées** depuis l'existant. À terme il faudra décider si les deux surfaces cohabitent ou si les vues serveur sont retirées écran par écran. + +## More Information + +Cette décision rend nécessaire [Stratégie de validation](strategie-de-validation.md) : en cessant d'échanger des formulaires, elle met hors jeu la validation orientée formulaire d'igo et oblige à statuer sur son remplacement. La dépendance est à sens unique — celle-ci se tient seule. + +Le défaut de périmètre est constaté dans `ladom/js/document-upload.js`. La démonstration du modèle JSON est le POC React sur l'espace stagiaire de certigo, 20/08/2026. diff --git a/docs/adr/organisation-des-sources-back.md b/docs/adr/organisation-des-sources-back.md new file mode 100644 index 00000000..7749c434 --- /dev/null +++ b/docs/adr/organisation-des-sources-back.md @@ -0,0 +1,346 @@ +# Organisation des sources back + +**Statut** : proposé +**Date** : 2026-08-24 + +## Context and Problem Statement + +Le front passe en SPA React servie en assets statiques. Le back igo, qui rendait des pages HTML via dust, doit maintenant exposer des **API JSON**. La structure actuelle est organisée par type (`controllers/`, `models/`, `services/`) avec des sous-dossiers par espace (`beneficiaire/`, `agent/`, `partenaire/`). Elle fonctionne et l'équipe la connaît. + +La question est double : **comment ajouter les API dans les projets existants** sans casser la structure, et **quelle structure adopter pour un greenfield** avec igo-next. + +### Ce que igo impose + +`@igojs/server` impose deux fichiers, découverts par chemin : + +- `app/routes.js` — doit exporter `init(app)`, point de montage des routes. +- `app/config.js` — doit exporter `init(config)`, surcharge de la config igo. + +C'est tout. Le framework ne connaît aucun autre chemin dans `app/`. + +Les aliases `@controllers/`, `@services/` et consorts que portent ladom et certigo **ne viennent pas d'igo** : ce sont des `_moduleAliases` déclarés dans le `package.json` du projet et résolus par la dépendance `module-alias`. Ni le framework ni son squelette ne les fournissent — le squelette utilise des chemins relatifs. Un projet est libre de les adopter ou non. + +### Ce que igo n'impose pas + +- L'organisation **à l'intérieur** de chaque dossier — libre. Ladom organise les contrôleurs par espace, d'autres projets pourraient faire autrement. +- L'existence de `forms/`, `validators/`, `middleware/` — choix de projet. +- Le dispatch par hostname ou par préfixe — code applicatif, pas framework. +- La couche service — ladom l'a construite, igo ne la fournit pas. + +### La structure actuelle + +``` +app/ + controllers/ ← par espace + beneficiaire/ + DispositifsController.js ← thin : service → res.locals → res.render() + FoldersController.js + agent/ + APIController.js ← API externe (eyoma), auth par token + models/ ← un fichier par table, schéma inline (colonnes, associations) + blocks/ + identities/ + ref/ + services/ ← logique métier, par domaine + folder/ + beneficiaire/ + eyoma/ + forms/ ← formulaires HTML, par espace + middleware/ + utils/ + validators/ ← Joi + routes/ ← un fichier par espace + api.js + routes.js ← dispatch par hostname +``` + +**Ce qui est déjà bien posé** : les contrôleurs sont fins (service → render), la logique métier est dans les services, les tests existent (162 fichiers, factories, isolation par transaction). **Ce qui manque** : pas de couche DTO, pas de convention pour les routes API front, erreurs en HTML même sur les routes JSON, validation en Joi. + +**Observation sur certigo** : le planner de certigo place déjà contrôleurs et services dans le même dossier, et ses domaines (planner, catalog, crm, elearning) ont des modèles relativement indépendants. C'est du feature-based de fait. Ladom est le cas plus difficile : agent/bénéficiaire/partenaire voient les mêmes données sous des angles différents — le découpage est par vue, pas par domaine. + +## Considered Options + +### Organisation : par type, par feature, ou hybride + +**Par type** (structure actuelle) — `controllers/`, `models/`, `services/` au premier niveau. +- Bon : familier, les aliases igo sont câblés dessus, le code existant (30+ modèles sur ladom, 65 sur certigo) ne bouge pas. +- Mauvais : le code d'un domaine est dispersé dans trois dossiers. Ajouter un domaine API touche `controllers/`, `services/` et potentiellement `models/`. + +**Par feature** — tout un domaine (routes, contrôleur, DTO, service, modèle) dans un seul dossier. +- Bon : cohésion maximale, un domaine est auto-contenu, supprimer un domaine est un `rm -rf`. +- Mauvais : les modèles ORM sont transversaux côté back — `Folder` (20+ associations sur ladom) est utilisé par les dossiers, les documents, l'éligibilité, les paiements. Le rattacher à une feature n'est pas naturel quand 10 services l'importent. Côté front un type est léger et importable ; côté back un modèle ORM est une classe avec des méthodes, des requêtes et des associations. +- Mauvais : incompatible avec les aliases existants sans tout recâbler. + +**Hybride** — les routes API et DTOs sont par domaine, les modèles et services restent par type. +- Bon : ajoute la couche API sans toucher à ce qui fonctionne. +- Bon : sur les projets existants, un alias `@api/` s'ajoute à côté des aliases en place. +- Mauvais : deux logiques de rangement dans le même projet. + +### Emplacement des DTOs + +**Méthode sur le modèle** (`folder.toAPI()`) — simple, mais mélange la responsabilité ORM et sérialisation. Surtout, la sérialisation dépend du consommateur : un dossier vu par l'agent et par le bénéficiaire n'expose pas les mêmes champs. Un `toAPI()` unique ne couvre pas ce cas. + +**Dossier `@dto/` séparé** — rangement par type, comme les modèles. Mais les DTOs n'ont de sens qu'avec leur contrôleur : les co-localiser facilite la lecture et la revue de code. + +**À côté du contrôleur API** — le DTO vit dans le même dossier que le contrôleur qui l'utilise. Chaque espace ou domaine peut avoir sa propre sérialisation du même modèle. + +### Routes API : dans les routes existantes ou alias `@api/` séparé + +**Dans les routes existantes** — les routes JSON cohabitent dans le même fichier que les routes dust. Simple, mais mélange les middlewares (le layout dust ne doit pas s'appliquer aux routes JSON), et la migration vers l'API pure est invisible dans l'arborescence. + +**Alias `@api/` séparé** — les contrôleurs API vivent dans `app/api/`, avec leur propre alias. La migration est visible : `@controllers/` rétrécit, `@api/` grandit. + +## Decision Outcome + +### Options retenues + +- **Hybride** pour les refontes, **par feature** pour les greenfield. +- **DTOs à côté du contrôleur API.** +- **Alias `@api/` séparé** (refonte) / **`@features/` et `@shared/`** (greenfield). + +### Trajectoire refonte (projet existant) + +La structure existante reste. Un alias `@api/` s'ajoute pour les contrôleurs JSON. Les modèles, services, et contrôleurs dust restent en place. + +``` +app/ + api/ ← NOUVEAU — alias @api/ + dossiers/ + dossiers.routes.js + dossiers.controller.js + dossiers.dto.js + beneficiaires/ + beneficiaires.routes.js + beneficiaires.controller.js + beneficiaires.dto.js + controllers/ ← EXISTANT — rétrécit avec la migration + beneficiaire/ + agent/ + models/ ← INCHANGÉ + services/ ← INCHANGÉ + middleware/ + utils/ + config.js + routes.js +``` + +Les projets qui utilisent déjà `module-alias` ajoutent `@api` à côté de leurs aliases existants : + +```json +"_moduleAliases": { + "@api": "app/api", + "@controllers": "app/controllers", + "@models": "app/models", + "@services": "app/services", + "@utils": "app/utils", + "@forms": "app/forms", + "@validators": "app/validators" +} +``` + +### Trajectoire greenfield (igo-next) + +Organisation par feature : chaque domaine regroupe son contrôleur, son DTO, son service et son modèle. Les éléments transversaux (User, tables de référence, email, notifications) vivent dans `shared/`. + +``` +app/ + features/ + dossiers/ + dossiers.routes.js + dossiers.controller.js + dossiers.dto.js + dossiers.service.js + Dossier.js + documents/ + documents.routes.js + documents.controller.js + documents.dto.js + documents.service.js + Document.js + inscriptions/ + ... + shared/ + models/ + services/ + middleware/ + utils/ + config.js + routes.js +``` + +**Pas d'alias dans le squelette greenfield.** Les fichiers d'une feature sont côte à côte (`require('./dossiers.service')`) : l'alias résout la dispersion de l'organisation *par type*, problème que le feature-based n'a pas. Un projet reste libre d'ajouter `module-alias` s'il y tient. + +**Règle d'import entre features** : une feature peut importer un modèle ou un service d'une autre feature (`require('../dossiers/Dossier')`). Les features ne sont pas des silos étanches — l'organisation porte la propriété, pas l'isolation. Si un modèle est importé par la majorité des features, il migre dans `shared/models/`. + +### Anatomie d'un domaine API + +Qu'il vive dans `@api/` (refonte) ou `@features/` (greenfield), un domaine contient trois fichiers minimum : + +**Routes** — déclaration des endpoints : + +```js +const { express } = require('@igojs/server'); +const controller = require('./dossiers.controller'); + +const router = express.Router(); + +router.get('/', controller.index); +router.get('/:id', controller.show); +router.post('/', controller.create); +router.put('/:id', controller.update); + +module.exports = router; +``` + +**Contrôleur** — thin, appelle le service et sérialise via le DTO : + +```js +const FolderService = require('@services/FolderService'); +const dto = require('./dossiers.dto'); + +exports.index = async (req, res) => { + const dossiers = await FolderService.findByApplicant(req.user.id); + res.json(dossiers.map(dto.serialize)); +}; + +exports.show = async (req, res) => { + const dossier = await FolderService.findById(req.params.id); + if (!dossier) return res.status(404).json({ error: 'Dossier non trouvé' }); + res.json(dto.serialize(dossier)); +}; + +exports.create = async (req, res) => { + const dossier = await FolderService.create(req.body); // déjà validé et coercé + res.status(201).json(dto.serialize(dossier)); +}; +exports.create.body = dto.CreerDossier; // la validation s'applique d'elle-même +``` + +Le contrôleur **n'appelle jamais le parse** : un middleware d'`@igojs/server` valide en amont et remplace `req.body` par la valeur validée. Le schéma est attaché au handler — si le handler est monté, sa validation l'est. Voir [Stratégie de validation](strategie-de-validation.md). + +**DTO** — sérialisation sortante + schémas entrants (Zod) : + +```js +const { z } = require('zod'); + +exports.CreerDossier = z.object({ + type: z.string(), + beneficiary_id: z.number().int().positive(), +}); + +exports.Lister = z.object({ + page: z.coerce.number().int().min(1).default(1), + statut: z.enum(['brouillon', 'depose', 'valide']).optional(), +}); + +exports.serialize = (dossier) => ({ + id: dossier.id, + code: dossier.code, + type: dossier.type, + status: dossier.status, + createdAt: dossier.created_at, +}); +``` + +Le DTO est la barrière entre le modèle ORM et l'API, **dans les deux sens** — schémas en entrée, `serialize` en sortie. **Le front ne voit jamais un modèle ORM brut.** Si un domaine grossit, un sous-dossier `dto/` peut accueillir plusieurs sérialiseurs. + +En TypeScript, ces schémas sont aussi la source des types : `z.infer` évite de déclarer la forme deux fois. + +### Montage des routes API + +```js +const dossiersRoutes = require('@api/dossiers/dossiers.routes'); +const beneficiairesRoutes = require('@api/beneficiaires/beneficiaires.routes'); + +module.exports.init = (app) => { + app.use('/api/dossiers', apiMiddleware, dossiersRoutes); + app.use('/api/beneficiaires', apiMiddleware, beneficiairesRoutes); + + app.all('/{*splat}', (req, res, next) => { + // dispatch par hostname existant + }); +}; +``` + +Le `apiMiddleware` vérifie l'authentification par session et garantit que les erreurs sont renvoyées en JSON. + +### Amélioration de `@igojs/server` : error handler API + +Le error handler actuel fait `res.status(500).render('errors/500')` — du HTML. **Sous le préfixe API, tout répond en JSON** : les 500, mais aussi les 404, les erreurs de validation et les `URIError`/`SyntaxError`. Un front React ne doit jamais recevoir une page d'erreur HTML, y compris sur une URL mal tapée. + +Le préfixe est `/api` par défaut, surchargeable (`config.api.prefix`) — comme tous les défauts igo, il est là pour ne pas avoir à le configurer. + +Le crash → mail reste actif, seule la réponse change de format. + +### Format des erreurs : RFC 9457 + +Les erreurs API suivent **[RFC 9457 Problem Details](https://www.rfc-editor.org/rfc/rfc9457)**, avec le type de contenu `application/problem+json`. C'est un standard HTTP : les clients savent le lire, et il évite d'inventer un format maison qu'il faudrait documenter et faire vivre. + +```json +{ + "type": "about:blank", + "title": "Validation failed", + "status": 400, + "errors": [ + { "path": "beneficiary_id", "message": "Expected number, received string" } + ] +} +``` + +`type`, `title` et `status` sont les champs standard ; `errors` est l'extension pour le détail de validation, que le front affiche champ par champ sans transformation. + +### Validation avec Zod — middleware global + +Le middleware est **monté par igo sur le préfixe API**, pas déclaré route par route. Le schéma est attaché au handler : + +```js +exports.create.body = dto.CreerDossier; +exports.index.query = dto.Lister; +``` + +Le middleware valide, remplace la valeur par la sortie du schéma (coercitions et defaults compris), et répond 400 en Problem Details si le schéma rejette. Aucun appel à écrire dans les routes ni dans les contrôleurs. + +Le middleware accepte **tout schéma [Standard Schema](https://standardschema.dev)** — zod, valibot ou arktype. `@igojs/server` dépend de zod pour le squelette, mais sa signature n'y est pas liée. + +### Conventions d'architecture applicative — DDD-lite, pas de cérémonie + +L'organisation existante (contrôleurs fins → services → modèles) recouvre les concepts utiles du DDD sans le formalisme : + +| Concept DDD | Déjà en place | Formaliser ? | +|---|---|---| +| Use cases | Les méthodes de service (`FolderService.create()`) | Non — une classe `CreateFolderUseCase` n'ajouterait qu'une indirection | +| Aggregates | Les services contrôlent l'accès aux modèles | Non — le pattern est là, sans le nom | +| Repositories | Le modèle ORM (`Folder.where()`) | Non — une interface devant l'ORM est une abstraction sans consommateur | +| Anti-corruption layer | Les DTOs | Oui — c'est le seul apport formel de cette décision | + +**Trois conventions à maintenir**, sans les encoder en abstractions : +- Les services restent le point d'entrée de la logique métier — pas de logique dans les contrôleurs. +- Un modèle structurant (Folder, Registration) passe par son service — pas de `Folder.create()` directement dans un contrôleur. +- Les DTOs sont la barrière API — le front ne voit jamais un modèle ORM brut. + +## Consequences + +- Bon : **la migration est visible** — `@controllers/` rétrécit, `@api/` grandit. Quand `@controllers/` est vide, dust est parti. +- Bon : **les services ne changent pas.** C'est la couche la plus volumineuse, et elle n'est pas touchée. +- Bon : **les DTOs empêchent la fuite de champs internes.** Plus de `res.locals.folder = folder` qui expose tout le schéma ORM au front. +- Bon : **le greenfield par feature donne la cohésion** que le back n'avait pas — certigo l'a déjà de fait dans le planner. +- Bon : **le error handler API dans `@igojs/server`** corrige un défaut réel — aujourd'hui une 500 sur une route JSON renvoie du HTML. +- Neutre : **deux structures à connaître** — hybride en refonte, feature en greenfield. L'anatomie d'un domaine API est la même dans les deux cas. +- Mauvais : **les DTOs sont du code à écrire et à maintenir.** Chaque champ ajouté au modèle doit être décidé : exposé ou non. C'est le prix de la barrière. +- Mauvais : **la frontière feature/shared en greenfield demande un jugement** — un modèle importé par trop de features devrait migrer dans `shared/`. Le seuil est subjectif. + +## Confirmation + +Comment on saura, dans six mois, si l'organisation fonctionne : + +- **Proportion de routes API qui passent par un DTO** — si des contrôleurs font `res.json(model)` directement, la barrière est percée. +- **Taille de `@controllers/` vs `@api/`** — mesure de l'avancement de la migration. +- **Nombre de champs du modèle ORM exposés involontairement** — à vérifier en revue de code. +- **En greenfield : taille de `shared/models/` vs modèles dans les features** — si `shared/` grossit plus vite que les features, le découpage n'est pas le bon. Une analyse des 65 modèles de certigo montre un ratio d'environ 50/50 : les domaines à entités propres (elearning, tp, tt, vehicle) se prêtent bien au feature-based, mais le cœur métier (planner, crm) partage massivement. Pour un greenfield plus ciblé, le ratio sera probablement meilleur. + +## More Information + +Cette décision s'articule avec l'[organisation des sources front](organisation-des-sources-front.md) : les DTOs côté back définissent le contrat que les types TypeScript côté front dupliquent. Si les deux dérivent, la validation Zod côté front est le filet de détection. + +L'amélioration du error handler et le middleware de validation Zod sont à implémenter dans `@igojs/server` — ils bénéficient à tous les projets. + +La validation en Joi dans `@validators/` reste en place pour le code existant. Les nouvelles routes API utilisent Zod dans les DTOs. Les deux cohabitent. diff --git a/docs/adr/organisation-des-sources-front.md b/docs/adr/organisation-des-sources-front.md new file mode 100644 index 00000000..52103487 --- /dev/null +++ b/docs/adr/organisation-des-sources-front.md @@ -0,0 +1,220 @@ +# Organisation des sources front + +**Statut** : accepté +**Date** : 2026-08-24 + +## Context and Problem Statement + +La [chaîne de build](chaine-de-build-du-front.md) a posé la coquille : un projet npm frère du back, dans le même dépôt, buildé par Vite. La [technologie](technologie-de-composants-front.md) et le [système de design](systeme-de-design.md) ont fixé React et shadcn/ui sur Tailwind. Reste à décider **comment les fichiers sont organisés à l'intérieur du projet front**, et quelles conventions régissent les composants, les données et le routage. + +C'est un ADR de conventions, pas d'architecture — les décisions structurantes sont prises. Mais les conventions mal posées coûtent cher sur la durée : à 5-6 personnes, une dérive non cadrée se voit en six mois. + +## Principes directeurs + +1. **Organiser par fonctionnalité, pas par type.** La possession est par route (ADR architecture) — la structure du code la reflète. Une feature regroupe ses composants, hooks, appels API et types. +2. **La propriété prime sur le niveau d'abstraction.** Le vocabulaire de l'atomic design (atome, molécule, organisme) sert à discuter en revue de code, pas à nommer des dossiers. Ce qui structure l'arborescence, c'est le partage : partagé entre features → `components/` ; propre à une feature → dans la feature. +3. **Frontière de données explicite.** Un composant qui appelle `useQuery` ou `useMutation` est identifiable par son nom et par son emplacement. Tout le reste est pur — props only. +4. **Pas de complexité pour rien.** Pas de paquet d'état supplémentaire tant que React context ne suffit pas. Pas de Storybook tant que les tests de composants ne sont pas en place. Pas de schéma partagé front/back tant qu'igo n'est pas en TypeScript. + +## Considered Options + +### Organisation des fichiers — par fonctionnalité ou par type + +| | Par type (`components/`, `hooks/`, `pages/` au premier niveau) | Par fonctionnalité (`features/stagiaires/` regroupe tout) | +|---|---|---| +| **Pour** | Familier, plat, pas de jugement sur « où couper » | Tout ce qui touche un écran est au même endroit ; supprimer une feature est un `rm -rf` | +| **Contre** | Les fichiers d'un écran sont mélangés avec tous les autres — tient à 20 fichiers, plus à 200 | Il faut décider ce qui est une « feature » vs ce qui est partagé | + +**Par fonctionnalité retenu.** La possession par route est déjà décidée (ADR architecture) — la structure la reflète. À 5-6 personnes travaillant sur des projets clients différents, chacun travaille dans « son » espace la plupart du temps. + +### Niveau d'abstraction — dossiers atomic design ou vocabulaire seul + +Encoder la hiérarchie atomic design en dossiers (`atoms/`, `molecules/`, `organisms/`, `templates/`) rend le classement obligatoire à chaque fichier. En pratique, la frontière atome/molécule est ambiguë (un champ de formulaire label + input + erreur ?) et les reclassements déplacent des fichiers sans rien changer au comportement. shadcn fournit déjà les atomes dans `ui/`. **Retenu : le vocabulaire en revue de code, la propriété (partagé vs feature) dans l'arborescence.** + +### Routage — React Router ou TanStack Router + +TanStack Router offre un typage des routes à la compilation, mais React Router reste le standard de fait — base installée massive, documentation complète, connu de l'équipe. Le gain de type-safety ne justifie pas le coût d'apprentissage pour 5-6 personnes. **React Router retenu.** + +### État client — React context ou bibliothèque dédiée + +Zustand, Jotai ou Redux ajouteraient une dépendance et un modèle mental supplémentaire. TanStack Query couvre l'état serveur ; ce qui reste côté client pur est léger (préférences, wizard, sidebar). **React context retenu**, avec un seuil d'alerte explicite pour réévaluer (cf. section État client). + +## Decision Outcome + +### Structure de référence + +``` +projet/ + back/ ← igo (inchangé) + front/ + package.json + vite.config.ts + tailwind.config.ts ← thème shadcn + jetons projet + tsconfig.json + index.html + public/ + src/ + main.tsx ← point d'entrée, providers + routes.tsx ← arbre de routes React Router + components/ + ui/ ← shadcn/ui — les atomes, copiés tels quels + layout/ ← coquille de page, sidebar, header + [compositions].tsx ← molécules et organismes partagés entre features + features/ + stagiaires/ + pages/ ← composants de route — INJECTENT les données + sections/ ← blocs autonomes — INJECTENT les données + components/ ← composants d'affichage — PURS + hooks/ ← hooks métier propres à la feature + api.ts ← queries et mutations TanStack Query + types.ts ← types propres à la feature + dispositifs/ + ... + lib/ + api-client.ts ← wrapper fetch typé, base URL, gestion d'erreurs + query-client.ts ← configuration TanStack Query + utils.ts ← helpers partagés + hooks/ ← hooks partagés (useDebounce, useMediaQuery…) + types/ ← types globaux (User, Session…) +``` + +**Une feature légère** (3-5 fichiers) n'a pas besoin de sous-dossiers — un fichier `page.tsx`, un `api.ts` et un `types.ts` à plat suffisent. Les sous-dossiers `pages/`, `sections/`, `components/` apparaissent quand la feature dépasse la dizaine de fichiers. + +### Règle d'injection des données + +**Deux types de fichiers peuvent appeler `useQuery` ou `useMutation`. Le reste est pur.** + +| Emplacement | Peut fetch | Rôle | +|---|:--:|---| +| `features/xxx/pages/` | **Oui** | Composant de route — assemble les sections, peut charger les données de tête | +| `features/xxx/sections/` | **Oui** | Bloc autonome de la page — possède son jeu de données | +| `features/xxx/components/` | **Non** | Composant d'affichage pur — reçoit tout par props | +| `components/` (y compris `ui/`) | **Non** | Composant partagé pur — réutilisable sans dépendance au serveur | + +**Le test de décision : est-ce une section ou un composant ?** + +- Le bloc peut être retiré de la page sans casser le reste → **section** (il possède ses données). +- Retirer le bloc casserait l'affichage d'un voisin, ou le même bloc apparaît dans plusieurs features → **composant pur** (il reçoit ses données par props). + +**Comment ça se traduit dans le code :** + +```tsx +// features/dispositifs/pages/dispositif-page.tsx — ASSEMBLE +function DispositifPage() { + const { id } = useParams() + const { data: dispositif } = useDispositif(id) + return ( + <> + + + + + ) +} + +// features/dispositifs/sections/commentaires-section.tsx — POSSÈDE SES DONNÉES +function CommentairesSection({ dispositifId }: { dispositifId: string }) { + const { data: commentaires } = useCommentaires(dispositifId) + const ajouter = useAjouterCommentaire() + return +} + +// features/dispositifs/components/commentaires-list.tsx — PUR +function CommentairesList({ commentaires, onAjouter }: Props) { + // pas de useQuery, pas de useMutation — props only +} +``` + +**Cas limite : le formulaire.** Un formulaire qui possède sa soumission (`useMutation`) est une section — le nommer `XxxFormSection` rend l'intention visible. Si le même formulaire doit être réutilisé dans une autre feature, il remonte dans `components/` et redevient pur : il reçoit un `onSubmit`. + +**Vérification mécanique.** La règle se vérifie par grep : + +```bash +grep -r "useQuery\|useMutation" src/components/ # doit être vide +grep -r "useQuery\|useMutation" src/features/*/components/ # doit être vide +``` + +### Couche API + +**TanStack Query** pour le cache serveur, les états de chargement, le rafraîchissement et les mutations. C'est la seule dépendance d'état ajoutée — elle est dans la liste des dépendances agnostiques recommandées par l'ADR technologie. + +Les queries et mutations d'une feature vivent dans son `api.ts` : + +```tsx +// features/stagiaires/api.ts +export function useStagiaires(dispositifId: string) { + return useQuery({ + queryKey: ['stagiaires', dispositifId], + queryFn: () => apiClient.get( + `/api/dispositifs/${dispositifId}/stagiaires` + ), + }) +} + +export function useCreerStagiaire() { + return useMutation({ + mutationFn: (stagiaire: CreerStagiairePayload) => + apiClient.post('/api/stagiaires', stagiaire), + }) +} +``` + +Le client HTTP (`lib/api-client.ts`) est un wrapper `fetch` minimal et typé — base URL relative (`/api`), gestion des erreurs HTTP, parsing JSON. Pas d'Axios : `fetch` est natif et suffisant. + +### Routage + +**React Router** (cf. Considered Options). Configuration centralisée dans `routes.tsx`, avec lazy-loading par feature : + +```tsx +// src/routes.tsx +export const routes = createBrowserRouter([ + { + element: , + children: [ + { path: '/stagiaires', lazy: () => import('./features/stagiaires/pages/stagiaires-page') }, + { path: '/dispositifs/:id', lazy: () => import('./features/dispositifs/pages/dispositif-page') }, + ], + }, +]) +``` + +### État client + +**React context et `useReducer`** (cf. Considered Options). Seuil d'alerte : si un context dépasse 5-6 valeurs ou déclenche des re-renders visibles sans rapport, évaluer Zustand. Pas avant. + +### Types front ↔ back + +**Dupliqués côté front**, en TypeScript. igo n'étant pas en TypeScript, un partage structurel n'a pas de sens aujourd'hui. + +- Les types d'une feature vivent dans son `types.ts`. +- Les types transversaux (User, Session, réponses d'API communes) vivent dans `src/types/`. + +**Validation** : Zod côté front pour les entrées utilisateur, conformément à l'[ADR validation](strategie-de-validation.md). Les schémas Zod vivent à côté des types qu'ils décrivent. Zod est utilisable en JavaScript pur — si igo adopte la validation Zod plus tard, les schémas pourront converger sans que le back passe à TypeScript. + +### Storybook + +**Différé.** Le cas d'usage — développer un composant en isolation sans serveur ni données — est réel et exprimé par l'équipe. Mais il se traite d'abord par les tests de composants avec Vitest + Testing Library, qui exercent un composant en isolation avec un coût de maintenance moindre. + +Storybook sera reconsidéré si : +- Le besoin de documentation visuelle dépasse ce que les tests et le rechargement à chaud couvrent. +- Un second développeur exprime le même besoin de développer sans serveur, et les tests de composants ne le satisfont pas en pratique. + +## Consequences + +- Bon : **la structure reflète le métier**, pas l'outillage. Un développeur qui cherche « les commentaires du dispositif » sait où regarder — `features/dispositifs/`. +- Bon : **la frontière de données est visible** dans les noms de fichiers (`XxxSection` fetch, `XxxCard` ne fetch pas) et vérifiable par grep. +- Bon : **supprimer une feature est un `rm -rf`** sur son dossier, plus le retrait de sa route. Pas de chasse aux composants éparpillés dans `components/`, `hooks/`, `api/`. +- Bon : **aucune dépendance d'état supplémentaire.** TanStack Query est la seule addition au-dessus de React. +- Bon : **le vocabulaire atomic design survit** dans les discussions et les revues de code — « ça c'est un organisme, il n'a rien à faire dans `ui/` » — sans imposer d'arborescence rigide. +- Neutre : **les types dupliqués dérivent silencieusement.** La validation Zod attrape les désalignements à l'exécution, pas à la compilation. À réévaluer si igo passe à TypeScript. +- Mauvais : **la frontière page/section demande un jugement** au premier cas ambigu — le formulaire en est l'exemple type. Le test de bon sens est posé, mais chaque développeur le calibrera un peu différemment. Le grep de vérification est le filet. + +## Confirmation + +Comment on saura, dans six mois, si les conventions tiennent : + +- **Proportion de `useQuery`/`useMutation` hors pages et sections** — le grep ci-dessus suffit. Au-dessus de 10-15 %, la règle n'est pas respectée ou pas adaptée. +- **Taille du dossier `components/` partagé vs les features** — si le dossier partagé grossit plus vite que les features, les composants remontent trop tôt et la logique feature se dilue. +- **Temps d'onboarding** sur la structure — un nouveau développeur trouve-t-il ses fichiers sans demander ? +- **Nombre de features dont les sous-dossiers sont vides ou à un seul fichier** — signe que la structure est trop rigide pour la taille réelle des features. + diff --git a/docs/adr/socle-back-nouveaux-projets.md b/docs/adr/socle-back-nouveaux-projets.md new file mode 100644 index 00000000..041d0426 --- /dev/null +++ b/docs/adr/socle-back-nouveaux-projets.md @@ -0,0 +1,213 @@ +# Socle back — nouveaux projets + +**Statut** : instruit — décision au premier greenfield +**Date** : 2026-09-03 +**Portée** : projets greenfield uniquement. Les projets existants restent sur igo — cette décision ne les concerne pas. + +## Context and Problem Statement + +L'[ADR architecture front](architecture-front-de-reference.md) a recentré igo sur ses deux paquets mûrs — le serveur Express et l'ORM. La couche vue (dust) et la couche composants (`@igojs/component`) passent en maintenance. Le framework restant est plus petit, plus stable, et plus facile à évaluer. + +La question est : **pour un projet neuf, part-on sur ce socle allégé (igo-next), adopte-t-on un framework de marché (NestJS), ou garde-t-on le serveur igo avec un ORM de marché ?** + +L'analyse comparative de `@igojs/db` face aux ORM TypeScript du marché (Prisma, Drizzle, MikroORM, TypeORM, Kysely) permet d'instruire les trois options. + +### Ce que l'expérience interne établit + +| Source | Ce qu'elle dit | +|---|---| +| **10 ans d'igo en production** | Le serveur et l'ORM sont stables. Express n'a pas changé de façon fondamentale depuis sa création (2010). | +| **`@igojs/db` vs MikroORM** | L'ORM igo est plus simple à utiliser, cache nativement, les transactions isolées pour les tests marchent mieux (MikroORM ne lock pas ses migrations, transaction isolation plus complexe à mettre en place). | +| **funecap `api-ceremonie`** sur NestJS + MikroORM | 3 personnes connaissent NestJS. La modularisation est appréciée (séparation API/workers). Le boilerplate est plus lourd mais absorbé par le LLM. | +| **L'assistance LLM sur igo** | Structurellement plus faible — un modèle a vu quasi zéro code igo. Atténuable par une skill dédiée qui documente les conventions. | +| **Analyse comparative ORM (sept. 2026)** | Aucun ORM du marché n'offre le même ensemble cache natif + test isolation + migration lock + pagination optimisée. Mais tous offrent TypeScript, un écosystème plus large, et un meilleur support LLM. Détail en [More Information](#more-information). | + +## Considered Options + +### A. igo-next — Express + `@igojs/db` + +Le socle allégé : igo sans dust, sans `@igojs/component`, sans webpack, sans forms. Express + ORM + conventions, avec les améliorations décidées par les autres ADR (TypeScript, Zod, error handler JSON, Vitest). + +**Ce qu'il faut construire avant le premier greenfield :** + +| Évolution | Effort | ADR de référence | +|---|---|---| +| Retirer dust, component, webpack, forms de l'export | Faible | — | +| Squelette API-first (`skel/api`) | Moyen | [Organisation sources back](organisation-des-sources-back.md) | +| TypeScript (`allowJs: true` + `.d.ts` sur l'API publique) | Moyen | — | +| Middleware validation Zod | Faible | [Organisation sources back](organisation-des-sources-back.md) | +| Error handler JSON sur les routes API | Faible | [Organisation sources back](organisation-des-sources-back.md) | +| Support Vitest (`dev.vitest()`) | Faible | [Stratégie de test back](strategie-de-test-back.md) | +| Skill LLM documentant les conventions igo | Moyen | — | + +- Bon : **les trois acquis ORM sont préservés** — cache, test isolation, migration lock. Aucun concurrent n'offre cet ensemble. +- Bon : **Express = stabilité long terme.** 16 ans, API quasi figée. +- Bon : **coût d'apprentissage nul.** L'équipe connaît les conventions. +- Bon : **simplicité.** Handler Express + service + modèle. Pas de décorateurs, pas de DI. +- Mauvais : **pas de TypeScript sur l'ORM.** Le code applicatif passe en TS, mais les requêtes DB restent non typées. C'est la lacune la plus visible au quotidien. +- Mauvais : **bus factor** sur l'ORM — une seule personne en a la maîtrise profonde. +- Mauvais : **pas d'écosystème communautaire.** Chaque feature manquante est un chantier interne. +- Mauvais : **assistance LLM plus faible**, même avec une skill. +- Mauvais : **le travail d'évolution doit être fait avant le premier greenfield.** + +### B. NestJS + ORM de marché + +On quitte igo. Framework TypeScript-first avec modules, injection de dépendances, décorateurs, écosystème riche. ORM à choisir séparément — Drizzle ou Prisma sont les candidats les plus crédibles (voir [comparatif](#comparatif-détaillé-des-orm)). + +- Bon : **TypeScript de bout en bout**, framework et ORM. +- Bon : **modularisation native.** Séparation API/workers naturelle. +- Bon : **écosystème intégré** — Passport, Swagger, Bull, WebSocket, GraphQL. `npm install` + un décorateur. +- Bon : **documentation massive, communauté large, LLM très performant.** +- Bon : **3 personnes le connaissent** via funecap. +- Bon : **recrutement** — NestJS est un mot-clé reconnu. +- Mauvais : **perte des trois acquis ORM igo.** Le cache natif, le test isolation simple et le migration lock ne se retrouvent pas tels quels. Drizzle a un cache récent et pas de migration lock. Prisma a un lock mais pas de cache natif gratuit. Le test isolation demande du setup manuel partout. +- Mauvais : **la cérémonie NestJS.** Module + contrôleur + service + DTO + décorateurs par route CRUD. +- Mauvais : **deux stacks back dans l'agence.** Projets existants sur igo, nouveaux sur NestJS. À 5-6, le context-switching pèse. +- Mauvais : **stabilité relative.** NestJS sur Express = une couche d'abstraction supplémentaire. Si NestJS tombe, on retombe sur Express — exactement là où igo est déjà. +- Mauvais : **la DI est un outil de grande équipe.** À 5-6, elle ajoute de l'indirection sans résoudre un problème qu'on a. + +### C. igo/server + ORM de marché + +On garde `@igojs/server` (Express + conventions igo) mais on remplace `@igojs/db` par un ORM du marché. Le serveur reste le même — routes, middleware, config, i18n, mailer. Seule la couche données change. + +**Ce que ça implique concrètement :** + +| Évolution | Effort | +|---|---| +| Tout le travail igo-next de l'option A (sauf la skill ORM) | Identique à A | +| Intégration de l'ORM choisi dans le squelette igo | Moyen | +| Adaptation de `dev.test()` pour le test isolation avec le nouvel ORM | Moyen à élevé | +| Réécriture des conventions de modèle / service | Moyen | + +- Bon : **on gagne TypeScript sur les requêtes DB** — le principal manque d'igo/db. +- Bon : **Express + conventions igo** — la simplicité du serveur est préservée. Pas de DI, pas de décorateurs, pas de modules NestJS. +- Bon : **écosystème et LLM** de l'ORM de marché sur la couche données. +- Bon : **une seule stack serveur** pour toute l'agence (les projets existants restent sur igo complet, les nouveaux sur igo/server + ORM marché). +- Bon : **bus factor réduit** sur la couche données — un ORM maintenu par une communauté. +- Mauvais : **perte des trois acquis ORM igo.** Même constat que l'option B — le cache, le test isolation et le migration lock doivent être reconstruits ou acceptés comme manques. +- Mauvais : **couche hybride.** Un serveur igo avec un ORM tiers crée une combinaison que personne d'autre n'utilise. Pas de documentation de cette combinaison, pas de retour d'expérience communautaire. Le LLM connaît l'ORM mais pas l'assemblage. +- Mauvais : **le `dev.test()` d'igo est construit sur `@igojs/db`.** L'adapter à un ORM tiers demande un travail non trivial — c'est le principal couplage. +- Mauvais : **l'argument « igo/server sans igo/db » pose la question de ce que igo apporte encore.** Si le serveur est juste Express + quelques conventions, la valeur ajoutée d'igo se réduit à de la config et du scaffolding — ce qu'un squelette NestJS ou un template Express fait aussi. + +## Decision Outcome + +**igo-next (option A) recommandé comme socle par défaut.** + +### Ce qui a orienté la recommandation + +1. **Les trois acquis ORM sont un différenciateur concret.** Aucun ORM du marché n'offre nativement l'ensemble cache + test isolation simple + migration lock. Le poids de cet argument **diminue** si l'équipe n'utilise pas le cache Redis ou si les tests n'exploitent pas le rollback par transaction. + +2. **L'absence de TypeScript sur l'ORM est le principal risque.** Le marché est TypeScript-first. Ajouter des `.d.ts` sur l'API publique de `@igojs/db` atténuerait le problème ; ne rien faire rendrait la comparaison intenable à 2-3 ans. + +3. **L'option C ne tient pas l'examen.** Elle cumule les inconvénients : on perd les acquis ORM, on crée un assemblage non documenté, on casse `dev.test()`, et la valeur résiduelle d'igo/server seul ne justifie pas le coût. Si on lâche l'ORM, autant prendre NestJS qui apporte un vrai écosystème en échange. + +4. **Express reste le choix le plus stable.** 16 ans, API quasi figée. NestJS est une couche d'abstraction supplémentaire par-dessus. + +5. **La skill LLM comble l'écart d'assistance.** Une skill sur un framework propriétaire sera toujours en retrait par rapport à un ORM vu dans des millions de projets, mais le mécanisme fonctionne. + +### Le vrai pivot : TypeScript sur `@igojs/db` + +Le comparatif fait apparaître que la décision igo-next vs NestJS se joue de plus en plus sur **un seul axe** : le type safety des requêtes DB. Les trois acquis ORM justifient de rester — mais seulement si l'écart TypeScript ne se creuse pas. + +Deux chemins pour réduire cet écart : + +| Chemin | Effort | Résultat | +|---|---|---| +| `.d.ts` sur l'API publique de `@igojs/db` (Model, Query, Schema) | Moyen | Autocomplétion et vérification sur les appels. Pas de type safety sur les résultats de requêtes. | +| Réécriture de `@igojs/db` en TypeScript avec inférence des types de requêtes | Élevé | Parité avec Drizzle/Prisma sur le type safety. Investissement significatif. | + +Le premier chemin est un prérequis réaliste. Le second est un investissement dont le coût doit être évalué contre le bénéfice — si l'effort dépasse celui d'adopter un ORM de marché, l'argument s'inverse. + +### Quand NestJS + ORM marché devient le meilleur choix + +La recommandation igo-next n'est pas un verrou. NestJS se justifie si : + +- **Le projet a besoin de modularisation de déploiement** — API, workers, cron déployés séparément. +- **Le projet n'utilise pas le cache Redis ni le test isolation d'igo** — les acquis ORM ne pèsent plus. +- **L'équipe qui porte le projet connaît NestJS et pas igo.** +- **Les `.d.ts` sur `@igojs/db` n'ont pas été livrés** — l'écart TypeScript est resté tel quel. + +La décision se prend **projet par projet**, pas une fois pour toutes. + +### Prérequis : les évolutions igo-next + +La recommandation igo-next **suppose que le travail d'évolution est fait** avant le premier greenfield. Les `.d.ts` sur l'API publique de l'ORM s'ajoutent à la liste précédente comme prérequis. + +### Investissement structurant : la skill LLM pour igo + +Indépendante du choix de socle, cette skill bénéficie à **tous les projets** — existants et greenfield : + +- Documentation des conventions igo (Model, Query, Schema, associations, cache, config). +- Patterns de test (dev.agent, dev.test, Factory, transactions). +- Patterns de routes et middleware. +- Erreurs courantes et leurs solutions. + +## Consequences + +- Bon : **un seul socle back pour toute l'agence**, dans le cas par défaut. +- Bon : **les trois acquis ORM sont préservés** — cache, test isolation, migration lock. +- Bon : **la skill LLM bénéficie à l'existant** — les 5 projets en production sur igo s'améliorent. +- Neutre : **NestJS n'est pas fermé** — il reste éligible avec des critères explicites. +- Neutre : **l'option C (igo/server + ORM marché) est écartée** — elle n'apporte pas assez pour justifier le coût de l'assemblage. +- Mauvais : **le travail d'évolution est un investissement** — squelette, TypeScript, skill, et maintenant `.d.ts` sur l'ORM. +- Mauvais : **le bus factor sur l'ORM reste**, atténué mais pas résolu. +- Mauvais : **l'écart TypeScript est un risque à surveiller** — si les `.d.ts` ne sont pas livrés, la recommandation s'affaiblit. + +## Confirmation + +**Cette décision est instruite, pas tranchée.** Elle sera actée au premier greenfield. + +À douze mois du premier greenfield : +- **Le squelette igo-next existe-t-il et est-il utilisable ?** Si non, NestJS gagne par défaut. +- **Les `.d.ts` sur `@igojs/db` sont-ils en place ?** Si non, l'écart TypeScript n'a pas été comblé et l'argument principal pour igo s'affaiblit. +- **La skill LLM est-elle en place et efficace ?** Si non, l'écart d'assistance reste. +- **L'équipe a-t-elle un avis après avoir utilisé le squelette ?** Le retour terrain vaut plus que l'analyse. + +## More Information + +C'est l'**axe 3** du [cadre de décision](../cadre-decision-stack-front.md), identifié dès le début comme différé. Les axes 1 et 2 (front) sont tranchés ; celui-ci est instruit et attend son terrain d'application. + +### Comparatif détaillé des ORM + +Analyse réalisée en septembre 2026 sur les sources officielles et les retours communautaires de chaque ORM. Les ORM évalués : **Prisma** (v8, ~17M downloads/semaine), **Drizzle** (v0.45, ~12M), **MikroORM** (v7, ~460k), **TypeORM** (v1.0, ~5M), **Kysely** (v0.29, ~16M — query builder pur, pas un ORM). + +#### Grille comparative + +| Axe | `@igojs/db` | Prisma | Drizzle | MikroORM | TypeORM | Kysely | +|---|---|---|---|---|---|---| +| **Cache natif** | **OUI** — Redis, version-based, JOIN-aware, stats | Payant (Accelerate) | OUI — récent, provider-agnostic | PARTIEL — mémoire, TTL 1s, invalidation manuelle | OUI — Redis/DB, invalidation manuelle | NON | +| **Test isolation (rollback)** | **OUI** — intégré, 1 ligne | Community (jest-prisma) | Community (drizzle-orm-test) | OUI mais complexe (surtout NestJS) | Community | OUI — API transaction manuelle | +| **Migration lock** | **OUI** — advisory lock | OUI — avec bugs connus | **NON** — bug ouvert depuis 2023 | **NON** | **NON** | OUI | +| **Pagination optimisée** | **OUI** — COUNT/IDS/FULL auto | Cursor + offset | Offset + cursor manuel | OUI — cursor + subquery auto | Offset seul | Offset seul | +| **Scopes** | **OUI** — default + named + unscope | PARTIEL — via extensions | Beta | **OUI** — Filters | NON (community) | PARTIEL — composable | +| **TypeScript** | **NON** | **OUI** — best-in-class | **OUI** — sans codegen | **OUI** — Loaded | PARTIEL — QB non typé | **OUI** — best-in-class | +| **Relations** | PARTIEL — belongs_to, has_many | **OUI** — polymorphique v8 | OUI | **OUI** — polymorphique v7 | **OUI** | NON (query builder) | +| **Migrations up/down** | PARTIEL — up only, pas de down | PARTIEL — down manuel | **NON** — pas de down | OUI | **OUI** — auto-gen + down | OUI | +| **Seeds** | **OUI** — natif, CLI, bloqué en prod | PARTIEL — hook configurable | NON | **OUI** — SeedManager | NON (community) | NON | +| **Hooks/lifecycle** | PARTIEL — before only | PARTIEL — via extensions | NON (community) | **OUI** — cycle complet | **OUI** — 11 hooks | PARTIEL — plugins query-level | +| **Soft deletes** | NON | NON (community) | NON (community) | Community | **OUI** — natif | NON (community) | +| **Bulk insert** | NON | OUI — createMany | OUI | OUI — insertMany | OUI (mais save() piège) | OUI | +| **Transactions publiques** | NON (test only) | OUI | OUI | OUI | OUI | OUI | +| **Express / NestJS** | Express seul | Les deux | Les deux | Les deux (officiel NestJS) | Les deux (officiel NestJS) | Les deux | +| **Communauté** | Interne | ~47k★, 17M/sem | ~36k★, 12M/sem | ~9k★, 460k/sem | ~37k★, 5M/sem | ~14k★, 16M/sem | +| **Assistance LLM** | Faible (skill nécessaire) | Excellente | Bonne | Modérée | Excellente | Bonne | + +#### Profil de chaque ORM concurrent + +**Prisma** — Le plus populaire. Type safety best-in-class grâce au client généré. Écosystème le plus riche. Mais : code generation obligatoire, pas de cache natif gratuit (Accelerate est payant), DSL dédié (Prisma Schema Language), les requêtes complexes tombent sur `$queryRaw`. Migration down manuelle. Financé ($56M levés, 134 employés). + +**Drizzle** — Le challenger. SQL-first, léger (~7 KB), type-safe sans code generation. Cache natif récent (Upstash). Mais : pas de migration lock (bug ouvert), pas de migrations down, pas de hooks en core, pas de soft deletes en stable. 2000+ issues ouvertes avec des questions sur la santé du projet. Pas encore en v1. + +**MikroORM** — Le plus proche de Doctrine/Hibernate. Unit of Work, Identity Map, Data Mapper. Type safety forte (`Loaded`). Relations complètes, hooks complets. Mais : courbe d'apprentissage raide, pas de migration lock, test isolation complexe (confirmé sur funecap), communauté plus petite, maintenu par une seule personne. Pas de cache comparable. + +**TypeORM** — Le vétéran, relancé avec v1.0. Cache Redis natif, 11 hooks, soft deletes natifs, relations complètes. Mais : QueryBuilder non typé, pas de migration lock, `save()` est un piège de performance (2N queries pour N entités), maintenance historiquement instable (période 2022-2024). + +**Kysely** — Pas un ORM, un query builder pur. Type safety excellente. Pas de relations, pas de hooks entité, pas de soft deletes. Pertinent si on veut construire sa propre couche ORM par-dessus — mais c'est exactement le travail qu'on cherche à éviter. + +#### Résumé pour la décision + +L'ORM igo n'est pas en retard sur tout — il est en avance sur le cache, le test isolation et le migration lock. Mais il est en retard sur TypeScript, les relations, les transactions publiques, les hooks et l'écosystème. Le marché ne propose pas de remplacement drop-in qui serait meilleur partout : chaque concurrent gagne sur certains axes et perd sur d'autres. + +Le scénario **« on prend Drizzle ou Prisma et tout est résolu »** ne tient pas : on gagne TypeScript et l'écosystème, mais on perd le cache natif, le test isolation simple, et (pour Drizzle) le migration lock. Ce sont des régressions concrètes sur des fonctionnalités utilisées quotidiennement. + +Le scénario **« on reste sur igo/db tel quel »** ne tient pas non plus à moyen terme : l'absence de TypeScript sur les requêtes DB est un écart qui se creuse chaque année. diff --git a/docs/adr/strategie-de-test-back.md b/docs/adr/strategie-de-test-back.md new file mode 100644 index 00000000..54142923 --- /dev/null +++ b/docs/adr/strategie-de-test-back.md @@ -0,0 +1,142 @@ +# Stratégie de test back + +**Statut** : accepté +**Date** : 2026-08-24 + +## Context and Problem Statement + +Contrairement au front, le back **a déjà une culture de test** : 162 fichiers de test sur ladom, couvrant les services (75), les contrôleurs (58), les modèles (5), les utilitaires (6) et les validators (1). L'outillage est solide — Mocha comme runner, `dev.agent` pour les requêtes HTTP simulées, `Factory.js` pour les données de test, et surtout **l'isolation par transaction rollbackée** fournie par `@igojs/db`, qui permet de tester contre la vraie base sans pollution entre tests. + +Ce qui change avec le virage API : les contrôleurs dust faisaient `res.render()` — difficile à asserter. Les contrôleurs API font `res.json()` — trivial à tester. Une couche apparaît (les DTOs), la validation passe de Joi à Zod, et les greenfield pourraient tourner sur Vitest plutôt que Mocha. + +La question n'est pas de reconstruire — c'est de **valider l'existant, combler les trous, et poser les conventions pour la couche API**. + +## Considered Options + +### Runner : garder Mocha ou migrer vers Vitest + +**Mocha** — 162 fichiers de test tournent dessus, l'isolation par transaction est câblée sur ses hooks, l'équipe le connaît. + +**Vitest** — cohérence avec le front, mode watch plus rapide, configuration partagée avec Vite. Mais migrer 162 fichiers sans valeur ajoutée immédiate, et adapter les hooks de transaction d'igo. + +**Retenu : Mocha sur les projets existants, Vitest sur les greenfield.** Pas de migration forcée. `@igojs/server` fournira un `dev.vitest()` à côté de `dev.test()` pour les projets qui choisissent Vitest (même API de hooks, adaptation faible). + +### Niveau de test : unitaire pur (tout mocké) ou intégration avec la base + +**Tout mocker** (services mockés dans les tests de contrôleurs, base mockée dans les tests de services) — rapide, isolé, mais donne une fausse confiance : le mock passe, la vraie requête échoue. + +**Intégration avec la vraie base, isolée par transaction** — teste le vrai SQL, les vraies contraintes, les vraies jointures. L'isolation par rollback donne la vitesse du mock sans son mensonge. + +**Retenu : intégration avec la base par défaut.** L'isolation par transaction d'`@igojs/db` est l'atout principal de la stack de test — c'est ce qui rend les tests d'intégration aussi rapides que des unitaires. Les mocks ne se justifient que pour les dépendances externes (API tierces, SMTP, services cloud). + +### Tests de DTOs et validation Zod : isolés ou via le contrôleur + +**Tests isolés** du DTO (`serialize(folder)` renvoie les bons champs) et du schéma Zod (`schema.parse(badInput)` rejette). + +**Via le contrôleur** — `agent.get('/api/dossiers/1')` vérifie le JSON retourné (couvre le DTO), `agent.post('/api/dossiers', { body: {} })` vérifie la 400 (couvre la validation). + +**Retenu : via le contrôleur.** Un test d'intégration couvre le DTO et la validation par construction, sans test supplémentaire. Exception : un DTO avec de la logique (calculs, agrégations) mérite un test unitaire dédié. + +## Decision Outcome + +### Deux niveaux de test, pas trois + +| Niveau | Ce qu'on teste | Comment | Quand | +|---|---|---|---| +| **Unitaire** | Logique pure — algorithmes, règles métier à branches multiples, calculs | Service ou util appelé directement, assertions sur le retour | Quand la logique branche | +| **Intégration** | Le câblage complet — route → contrôleur → DTO → service → base | `dev.agent` avec la vraie base, transaction rollbackée | **Tout contrôleur API** | + +**Pas de niveau intermédiaire mocké.** Pas de test de contrôleur avec service mocké, pas de test de service avec base mockée. L'isolation par transaction fait le travail du mock, en testant le vrai code. + +Les mocks ne servent que pour les **dépendances externes** : API tierces (eyoma, FranceConnect, Pennylane), SMTP, services cloud. Tout ce qui est interne (base, services, modèles) tourne en réel. + +### Règle : qu'est-ce qui doit être testé + +**Tout nouveau contrôleur dans `@api/` a un test d'intégration.** Le test fait une requête HTTP via `dev.agent`, vérifie le JSON retourné (forme, champs exposés, champs absents) et l'état en base après l'opération. + +**Un test d'intégration de contrôleur API couvre au minimum :** +- Le cas nominal — requête valide, réponse attendue. +- Le cas d'erreur de validation — body invalide, 400 avec message structuré. +- Le cas d'accès refusé — pas de session ou mauvais rôle, 401/403. +- Le cas entité absente — id inexistant, 404. + +**Les services sont testés unitairement quand ils contiennent du branchement** — conditions métier, calculs, règles d'éligibilité. Un service qui ne fait que `Folder.where(...).first()` est couvert par le test du contrôleur. + +**Sur le code existant** : les 162 fichiers de test restent. La règle s'applique au code nouveau. Quand on modifie un service existant, on ajoute un test pour la modification. + +### Ce que couvre un test d'intégration API — exemple + +```js +describe('GET /api/dossiers/:id', () => { + + it('should return the serialized folder', async () => { + const applicant = await Factory.createApplicant(); + const folder = await Factory.createFolder({ applicant_id: applicant.id, type: 'agp' }); + + const res = await agent.get(`/api/dossiers/${folder.id}`, { + session: { applicant_id: applicant.id } + }); + + assert.strictEqual(res.statusCode, 200); + const body = JSON.parse(res.body); + assert.strictEqual(body.id, folder.id); + assert.strictEqual(body.type, 'agp'); + assert.strictEqual(body.legacy_id, undefined); // champ interne non exposé + assert.strictEqual(body.applicant_id, undefined); // clé technique non exposée + }); + + it('should return 404 for unknown folder', async () => { + const res = await agent.get('/api/dossiers/999999', { + session: { applicant_id: 1 } + }); + assert.strictEqual(res.statusCode, 404); + }); +}); +``` + +Le test vérifie à la fois le contenu retourné (le DTO fonctionne) et l'absence de champs internes (le DTO protège). Pas besoin de test de DTO séparé. + +### Factories + +Le `Factory.js` existant continue de servir. Pour les greenfield sur Vitest, le même pattern s'applique — des fonctions de création qui insèrent en base et retournent l'objet, dans la transaction du test. + +### Quand les tests tournent + +| Moment | Ce qui tourne | Bloque | +|---|---|---| +| En développement | Mocha (ou Vitest) en mode watch sur les fichiers modifiés | Non | +| Avant commit (hook) | Les tests touchés par le diff | Le commit | +| Sur PR (CI) | Suite complète + E2E Playwright sur les parcours critiques | Le merge | + +### Évolution de `@igojs/server` pour le support Vitest + +| Composant | Ce qui change | Effort | +|---|---|---| +| `dev.vitest()` | Adaptateur des hooks de transaction pour Vitest (`beforeEach`/`afterEach`, même API) | Faible | +| `dev.agent` | À vérifier — probablement rien, c'est du HTTP pur | Faible | +| Setup file | Un `vitest.setup.js` équivalent au `init.js` actuel | Faible | + +L'isolation par transaction rollbackée est le vrai atout d'igo pour les tests. Elle doit fonctionner à l'identique sur Mocha et Vitest. + +## Consequences + +- Bon : **la stratégie valide l'existant** au lieu de le remettre en cause. Les 162 fichiers de test, les factories, l'isolation par transaction — tout reste. +- Bon : **les contrôleurs API sont plus faciles à tester que les contrôleurs dust** — du JSON à asserter au lieu du HTML à parser. +- Bon : **l'intégration avec la vraie base attrape les bugs que les mocks cachent** — contraintes SQL, jointures, colonnes renommées. +- Bon : **les tests de contrôleur couvrent le DTO et la validation** sans test séparé — moins de code de test, plus de couverture réelle. +- Neutre : **deux runners cohabitent** (Mocha sur l'existant, Vitest en greenfield). C'est un compromis pragmatique — la migration forcée aurait un coût sans valeur. +- Mauvais : **`@igojs/server` doit fournir `dev.vitest()`** avant le premier greenfield. Effort faible mais nécessaire. +- Mauvais : **l'absence de mocks rend les tests dépendants d'une base fonctionnelle.** En CI, il faut une base de test. C'est déjà le cas aujourd'hui — pas de régression. + +## Confirmation + +Comment on saura, dans douze mois, si la stratégie fonctionne : + +- **Proportion de contrôleurs `@api/` avec un test d'intégration** — cible 100 % sur le code nouveau. +- **Nombre de bugs de production liés à la sérialisation** (champ manquant, champ interne fuité) — mesure directe de l'efficacité des tests de DTO via contrôleur. +- **Temps d'exécution de la suite de tests** — si elle dépasse 2-3 minutes, investiguer les tests les plus lents (requêtes non isolées, factories trop lourdes). +- **Nombre de tests qui cassent sans raison métier** (tests fragiles) — mesure de la qualité des tests, pas de la couverture. + +## More Information + +La couche `@api/` avec DTOs détermine ce qu'on teste et comment : le test d'intégration d'un contrôleur API couvre le DTO et la validation par construction. Les conventions d'architecture applicative (services comme point d'entrée, DTOs comme barrière, DDD-lite sans formalisme) sont documentées dans l'ADR organisation des sources back. diff --git a/docs/adr/strategie-de-test-front.md b/docs/adr/strategie-de-test-front.md new file mode 100644 index 00000000..9a025efb --- /dev/null +++ b/docs/adr/strategie-de-test-front.md @@ -0,0 +1,175 @@ +# Stratégie de test front + +**Statut** : accepté +**Date** : 2026-08-24 + +## Context and Problem Statement + +La testabilité est pondérée **5** dans l'ADR d'architecture — c'est le critère, avec le coût de framework, qui a tranché en faveur du front à composants. Aujourd'hui, **aucun test de composant n'existe côté front**. Le seul filet est une poignée de tests E2E Playwright, trop lourds pour être nombreux, qui tiennent lieu de tests unitaires et de composants. + +L'[organisation des sources](organisation-des-sources-front.md) facilite la mise en œuvre : les composants purs (props only) se testent sans mock, les sections se testent avec une API simulée, et la structure par feature place les tests à côté du code qu'ils vérifient. + +La question n'est pas « faut-il tester » — c'est tranché. C'est **quoi tester, à quel niveau, avec quels outils, et dans quel ordre**. + +## Principes directeurs + +1. **Tester le comportement, pas l'implémentation.** Un test vérifie ce que l'utilisateur voit et ce qui se passe quand il agit — pas la structure interne du composant, pas le nombre de renders, pas l'arbre de hooks. +2. **Chaque niveau a son rôle — pas de duplication.** Si un comportement est couvert par un test de composant, il n'a pas besoin d'un E2E. L'objectif est de faire **redescendre** la vérification au niveau le plus bas qui la porte. +3. **Progressif.** L'équipe part de zéro. La stratégie doit produire de la valeur dès le premier test, pas après trois semaines de mise en place. +4. **Collocated.** Les tests vivent à côté du code qu'ils testent — dans la feature, pas dans un dossier `__tests__/` racine. + +## Considered Options + +### Runner de tests : Vitest vs Jest + +Jest fonctionne mais exige sa propre configuration de transforms, de résolution de modules et de mocks — un deuxième pipeline à côté de Vite. **Vitest retenu** : même configuration, même résolution, rechargement à chaud en mode watch, et API quasi identique à Jest. + +### Simulation d'API : MSW vs mock du apiClient + +Mocker le wrapper `fetch` est plus rapide à écrire mais couple les tests à l'implémentation du client HTTP. **MSW retenu** : le code de production tourne tel quel, un changement de wrapper ne casse pas les tests, et les handlers servent aussi en mode développement sans serveur (cf. « Outils » ci-dessous). + +### Objectif de couverture : pourcentage de lignes vs règle par fichier + +Un seuil global (80 % de lignes) pousse à écrire des tests de remplissage sur du code trivial. **Règle par fichier retenue** : la mesure utile est la proportion de fichiers `pages/` et `sections/` ayant un `.test.tsx` — elle cible l'effort là où les bugs de câblage apparaissent. + +### Storybook d'emblée vs différé + +Storybook permet de développer un composant en isolation sans serveur. Vitest + Testing Library couvre le même besoin (monter un composant avec des props, vérifier le rendu) avec moins d'infrastructure. MSW couvre le mode développement sans serveur. **Différé** : à reconsidérer quand la base de tests composants sera en place et si le besoin de documentation visuelle persiste. + +## Decision Outcome + +### Les quatre niveaux de test + +| Niveau | Outil | Ce qu'il vérifie | Vitesse | Priorité | +|---|---|---|---|---| +| **Unitaire** | Vitest | Logique pure : utils, hooks custom, schémas Zod | ~1 ms | Quand la logique branche | +| **Composant** | Vitest + Testing Library | Un composant rendu avec des props — affichage et interactions | ~10-50 ms | **Haute — c'est la zone morte** | +| **Feature** | Vitest + Testing Library + MSW | Une section ou page complète, API simulée au niveau réseau | ~50-200 ms | **Haute** | +| **E2E** | Playwright | Un parcours utilisateur complet, navigateur réel | ~1-10 s | Chemins critiques seulement | + +**La priorité est aux niveaux composant et feature.** C'est la zone morte d'aujourd'hui, et c'est là que le ratio valeur/coût est le meilleur. Le but est d'inverser la pyramide actuelle : peu d'E2E ciblés, beaucoup de tests rapides. + +### Correspondance avec l'organisation des sources + +| Zone du code | Type de test | Ce qu'on mocke | +|---|---|---| +| `components/ui/` (shadcn) | **Pas testés par l'équipe** — code tiers copié | — | +| `components/` (compositions partagées) | **Composant** — rendu avec props | Rien — le composant est pur | +| `features/xxx/components/` | **Composant** — idem | Rien — le composant est pur | +| `features/xxx/sections/` | **Feature** — rendu avec API simulée | Les appels réseau (MSW) | +| `features/xxx/pages/` | **Feature** — vérifie l'assemblage | Les appels réseau (MSW) | +| Parcours critiques transverses | **E2E** — navigateur réel | Rien | + +La séparation composants purs / sections de l'ADR organisation rend la stratégie mécanique : **pur → pas de mock ; section/page → MSW.** + +### Règle : qu'est-ce qui doit être testé + +**Tout nouveau fichier dans `pages/` ou `sections/` est accompagné d'au moins un test.** C'est là que les données entrent et que les bugs de câblage apparaissent. Pas de dérogation. + +Les composants purs dans `components/` sont testés **quand ils contiennent du comportement** : logique conditionnelle, interactions utilisateur, états dérivés. Un composant qui ne fait que rendre des props dans du JSX ne justifie pas un test dédié — le test de la section qui l'utilise le couvre. + +Les utilitaires, hooks custom et schémas Zod sont testés **quand ils contiennent un branchement**. Un `formatDate()` linéaire ne justifie pas un test. Un `parseReponseApi()` avec des cas d'erreur, si. + +**Sur le code existant** : pas de rétro-écriture. La règle s'applique au code nouveau. Quand on modifie du code existant, on ajoute un test pour la modification — règle du boy-scout. + +### Ce qu'un test de composant vérifie — et ne vérifie pas + +**Vérifie :** +- Ce qui est affiché pour des props données — rendu attendu, cas vide, cas d'erreur, cas limite. +- Ce qui se passe quand l'utilisateur agit — clic, saisie, soumission. +- Les callbacks reçus par props — `onSubmit` appelé avec les bonnes valeurs. + +**Ne vérifie pas :** +- Le style — couleur, taille, espacement. C'est le travail de la revue visuelle. +- La structure interne — nombre de `div`, ordre des hooks, nombre de re-renders. +- Les composants shadcn (`ui/`) — testés en amont par le projet shadcn. + +### Ce qu'un test E2E couvre + +Les **parcours critiques**, définis par leur impact métier : inscription d'un stagiaire, soumission d'un dossier, validation d'une étape. Chaque parcours est un scénario bout en bout, du clic à la vérification en réponse. + +**Pas de E2E pour valider un composant en isolation.** Règle de pouce : si le test peut tourner sans navigateur, il ne doit pas être un E2E. + +Convention existante maintenue : **les POMs exposent des locators, les assertions restent dans les fichiers de test.** + +### Outils + +**Vitest** — retenu pour les raisons exposées en Considered Options. + +**React Testing Library** — teste le DOM tel que l'utilisateur le voit. Requêtes par rôle (`getByRole`), par texte (`getByText`), par label (`getByLabelText`). **Pas de requêtes par sélecteur CSS ni par `data-testid` sauf quand aucune requête accessible ne convient.** L'accessibilité devient un sous-produit des tests. + +**MSW (Mock Service Worker)** — intercepte les appels `fetch` au niveau réseau pour les tests de feature. Les mêmes handlers servent aussi en **mode développement sans serveur**. Voir Considered Options pour le comparatif avec le mock du `apiClient`. + +**Playwright** — déjà en place pour les E2E, conservé. Seule addition envisagée : `axe-playwright` pour attraper les régressions d'accessibilité sur les parcours critiques. + +### Où vivent les tests + +Collocated, suffixe `.test.tsx` (ou `.test.ts` pour les unitaires) : + +``` +features/dispositifs/ + sections/ + commentaires-section.tsx + commentaires-section.test.tsx ← test de feature (MSW) + components/ + commentaire-card.tsx + commentaire-card.test.tsx ← test de composant (si comportement) + api.ts + types.ts +``` + +Les fixtures et factories partagées vivent dans `src/test/` : + +```tsx +// src/test/factories.ts +export function creerStagiaire(surcharges?: Partial): Stagiaire { + return { id: '1', nom: 'Doe', prenom: 'Jane', ...surcharges } +} +``` + +Les handlers MSW partagés vivent dans `src/test/handlers/` — un fichier par domaine d'API, réutilisable entre tests et mode développement sans serveur. + +Les E2E Playwright restent dans leur arborescence existante, séparés du code source. + +### Quand les tests tournent + +| Moment | Ce qui tourne | Bloque | +|---|---|---| +| En développement | Vitest en mode watch sur les fichiers modifiés | Non | +| Avant commit (hook pre-commit) | `vitest related` — les tests touchés par le diff | Le commit | +| Sur PR (CI) | Suite Vitest complète + Playwright sur les parcours critiques | Le merge | + +### Accessibilité + +Deux niveaux, du moins cher au plus exigeant : + +1. **Testing Library par construction.** Les requêtes par rôle (`getByRole('button')`, `getByLabelText('Nom')`) échouent si le composant n'expose pas les rôles ARIA attendus. C'est le filet de base, et il ne coûte rien de plus que d'écrire les tests correctement. +2. **axe-playwright sur les E2E.** Un appel `checkA11y()` ajouté aux parcours critiques existants attrape les violations WCAG sans écrire de test supplémentaire. Coût d'ajout : une ligne par test. + +Aucun des deux ne remplace un audit RGAA, mais ils empêchent les régressions les plus courantes de passer. + +## Consequences + +- Bon : **la testabilité, critère à 5, devient effective.** Le premier test de composant peut s'écrire en une heure, sur un composant pur avec des props — pas de configuration serveur, pas de base de données. +- Bon : **les tests de composant couvrent ce que les E2E couvraient mal** — le comportement d'un composant dans ses cas limites, en millisecondes au lieu de secondes. +- Bon : **les requêtes par rôle de Testing Library forcent l'accessibilité** — un composant non accessible est un composant difficile à tester par cette méthode. +- Bon : **MSW sert deux usages** — les tests de feature et le développement sans serveur — ce qui réduit l'argument pour Storybook sans le fermer. +- Bon : **pas de couverture chiffrée imposée** — voir Considered Options pour le raisonnement. +- Neutre : **l'investissement initial est faible** — Vitest, Testing Library et MSW se configurent en une demi-journée. Le coût est dans l'apprentissage, pas dans l'outillage. +- Mauvais : **la règle « tout nouveau page/section a un test » ralentit les premières semaines**, le temps que l'équipe acquière le réflexe. C'est le coût d'entrée assumé. +- Mauvais : **les handlers MSW sont du code à maintenir.** Chaque endpoint simulé doit refléter le contrat de l'API. Si l'API change et que le handler ne suit pas, le test passe mais la feature est cassée. La validation Zod côté front est le second filet. + +## Confirmation + +Comment on saura, dans douze mois, si la stratégie fonctionne : + +- **Proportion de fichiers `pages/` et `sections/` avec un `.test.tsx` associé** — mesure directe de la règle. Cible : 100 % sur le code nouveau. +- **Part de la couverture qui ne repose plus sur l'E2E** — l'objectif est d'inverser la pyramide. +- **Temps d'exécution de la suite Vitest** — si elle dépasse 30 secondes, les tests sont trop couplés ou trop lourds. +- **Nombre de bugs de production qui auraient été attrapés par un test de composant** — mesure rétrospective sur les incidents. + +## More Information + +La séparation pages/sections/composants purs de l'ADR organisation des sources front correspond directement aux niveaux de test : pur → pas de mock, section/page → MSW. + +Les tests de régression visuelle (comparaison de captures d'écran par Playwright) restent une option ouverte. Storybook est différé (cf. Considered Options). diff --git a/docs/adr/strategie-de-validation.md b/docs/adr/strategie-de-validation.md new file mode 100644 index 00000000..46f19a10 --- /dev/null +++ b/docs/adr/strategie-de-validation.md @@ -0,0 +1,56 @@ +# Stratégie de validation + +**Statut** : accepté +**Date** : 2026-08-20 + +## Context and Problem Statement + +Le passage à un échange JSON — décidé dans [Format d'échange front/back](format-echange-front-back.md) — rend inopérante la validation actuelle d'igo, qui est *orientée formulaire* : les erreurs sont exposées par formulaire sous `form.errors[name]` et rendues par le template. Hors de ce cadre, la seule pratique existante est la validation à la main par contrôleur (`FolderEventsValidator.js` dans ladom), qui ne passe pas l'échelle d'une API. + +Il faut donc décider où valide-t-on, avec quel mécanisme, et si les règles sont partagées entre client et serveur. + +## Considered Options + +1. **Validation client + validation serveur au niveau API**, mécanisme dédié dans `@igojs/server`. +2. **Validation client uniquement**, l'API faisant confiance à son front. +3. **Validation serveur uniquement**, le client se contentant d'afficher les erreurs retournées. +4. **Contraintes déclarées sur le modèle de persistance** (`@igojs/db`), l'API validant contre le modèle de base. +5. **Schéma unique obligatoirement partagé** entre client et serveur. + +## Decision Outcome + +**Option 1 retenue : valider aux deux niveaux, avec des rôles distincts, via un middleware dans `@igojs/server`.** + +- **Côté client, quand c'est possible** : validation de surface — format, obligatoire, longueur, cohérence entre champs — pour un retour immédiat sans aller-retour réseau. C'est un agrément d'usage. +- **Côté serveur, au niveau de l'API, systématiquement.** C'est une frontière de sécurité, jamais une redondance. Elle s'applique même lorsque l'API est privée et consommée uniquement par le front de l'agence. +- **La validation métier — non surfacique — reste côté serveur.** Le front doit savoir afficher un retour d'erreur serveur pour ces cas : c'est le fonctionnement normal, pas un mode dégradé. +- **Le middleware vit dans `@igojs/server`**, valide la requête contre un **schéma DTO déclaré indépendant du modèle de persistance**, et renvoie des **erreurs structurées** que le front affiche sans transformation. Rien n'est ajouté à `@igojs/db`. +- **Le format d'erreur est [RFC 9457 Problem Details](https://www.rfc-editor.org/rfc/rfc9457)** (`application/problem+json`), et non un format maison. Un standard HTTP se documente tout seul et se lit par n'importe quel client ; le détail par champ passe par l'extension `errors`. +- **Le middleware est global, pas déclaré route par route.** igo le monte sur le préfixe API ; le schéma est attaché au handler (`controller.create.body = dto.CreerDossier`). Rien à écrire dans les routes — l'équivalent JavaScript du `ValidationPipe` global de NestJS, dont les décorateurs `class-validator` supposent TypeScript et `emitDecoratorMetadata`. +- **La signature accepte tout schéma [Standard Schema](https://standardschema.dev)** — zod, valibot, arktype. Le squelette livre zod ; le framework n'est pas lié à ce choix. +- **Le typage TypeScript découle des mêmes schémas**, sans les redéclarer (`z.infer`), via des `.d.ts` sur l'API publique. Les projets JavaScript ne voient aucune différence : les `.d.ts` ne sont jamais chargés à l'exécution. +- **Le partage des schémas est autorisé, jamais obligatoire.** Un schéma isomorphe (zod, déjà présent dans ladom pour les schémas d'extraction OCR) permet de déclarer une fois et d'utiliser des deux côtés — à retenir quand ça simplifie. + +### Pourquoi les autres ont été écartées + +- **Option 2** — une API non validée n'est pas une API de qualité ; elle ne peut pas dépendre de la bonne conduite de son client, même privé. +- **Option 3** — impose un aller-retour réseau pour tout retour de validation. Pénalisant sur les parcours mobiles en réseau dégradé, qui sont le cas d'usage dominant côté bénéficiaire. +- **Option 4** — coupler le contrat d'API au modèle de persistance interdit que le modèle exposé au client diffère du modèle de base, alors que cette séparation est la pratique recommandée en architecture hexagonale. Un DTO n'est pas un enregistrement. +- **Option 5** — dupliquer une règle de surface n'est pas un anti-pattern quand c'est justifié, et c'est parfois plus simple à maintenir qu'un partage. En faire une obligation ajouterait de la contrainte sans bénéfice garanti. + +## Consequences + +- Bon : `@igojs/server` porte déjà `FormHandler` — la validation d'API est **l'évolution d'une capacité existante** dans le paquet le plus mature, pas une brique nouvelle. +- Bon : un schéma DTO isomorphe ouvre trois bénéfices d'un seul geste — validation déclarée, inférence des types TypeScript pour le front, et dérivation d'un contrat OpenAPI. + +- Neutre : **ce coût est indépendant de l'architecture front retenue** — il échoit dès que le serveur expose du JSON, et se chiffre une fois, non par écran. **Ce n'est donc un argument ni pour ni contre une architecture front.** + +- Mauvais : c'est un coût de framework à payer avant de pouvoir exposer sérieusement du JSON. + +## More Information + +Cette décision découle de [Format d'échange front/back](format-echange-front-back.md) : sans le passage au JSON, la validation orientée formulaire d'igo resterait en place et il n'y aurait rien à trancher ici. + +**État de l'art constaté** : ladom porte `zod ^4.3.6` (schémas d'extraction OCR), `joi ^18.0.2` et deux entrées dans `app/validators/`. certigo n'a ni l'un ni l'autre. + +L'outillage de génération OpenAPI depuis un schéma zod repose sur des bibliothèques tierces dont l'état reste à vérifier — à instruire dans la recherche externe avant de s'y engager. diff --git a/docs/adr/strategie-observabilite.md b/docs/adr/strategie-observabilite.md new file mode 100644 index 00000000..49451dfa --- /dev/null +++ b/docs/adr/strategie-observabilite.md @@ -0,0 +1,248 @@ +# Observabilité + +**Statut** : proposé +**Date** : 2026-09-04 +**Portée** : tous les projets — existants et greenfield, front et back. + +## Context and Problem Statement + +L'agence n'a pas de stratégie d'observabilité. Ce qui existe s'est construit par accumulation : + +| Couche | Ce qu'on a | Ce qui manque | +|---|---|---| +| **Back** | Crash → pm2 restart → mail (avec throttle) | Logs structurés, métriques, alerting configurable | +| **Front** | Rien | Tout — un écran blanc est invisible | +| **Infra OVH** | Métriques de base via le panel | Pas d'alerting, pas de corrélation applicative | +| **Infra bare metal** | Rien | Tout | + +Le passage du front en SPA React aggrave le problème : les erreurs se produisent dans le navigateur, hors de portée du crash → mail. + +**Hors périmètre** : les logs d'audit (conformité), l'analytics d'usage (produit). + +### Ce qu'on veut observer + +Quatre piliers, par ordre de priorité : + +**1. Erreurs** (critique) — capturer les exceptions front (JS, réseau) et back (exceptions, rejets de promesse, erreurs services tiers — API partenaires, SMTP, stockage). Avec : source maps résolues, déduplication, breadcrumbs, corrélation front/back, suivi par release. + +**2. Logs structurés** (haute) — format JSON avec attributs standardisés (`level`, `timestamp`, `service`, `version`, `env`, `requestId`, `httpStatus`, `userId`, `duration`). Centralisés, cherchables, rétention 7-15 jours en prod / 3 jours en staging. Pas de données sensibles. + +Ce qu'on logue : requêtes HTTP, erreurs applicatives, requêtes SQL lentes, démarrage/arrêt du service, événements métier significatifs, appels services tiers. + +**3. Métriques et dashboards** (haute) — temps de réponse par route (P50/P95/P99), taux d'erreur HTTP par route, requêtes SQL lentes, Web Vitals front (LCP, CLS, INP — sites grand public uniquement), uptime. La plateforme doit aussi permettre des métriques métier custom (compteurs, histogrammes) — le contenu varie par projet. Métriques infra (CPU, RAM, disque) en nice-to-have, surtout pour le bare metal. + +**4. Alerting** (haute) — par seuil, pas unitaire. Pic d'erreurs, régression de performance, service down, nouvelle erreur critique. Canaux : Teams (principal) + mail (backup). Configurable par projet. + +### Ce qui change dans le code (indépendant de l'outil) + +| Évolution | Effort | +|---|---| +| Logger structuré JSON (remplace `console.log`) — module dans `@igojs/server` | Faible | +| `reportError()` front — une fonction, un fichier | Faible | +| Error Boundary racine React | Faible | +| Gestion explicite des états loading/error/data dans les sections front | Convention | +| Centralisation des logs HTTP (nginx/LB existants, ou middleware applicatif si corrélation fine) | Faible à moyen | +| Suppression progressive du crash → mail | Moyen | + +## Considered Options + +### A. Sentry (Team) + +Plateforme spécialisée error tracking + performance monitoring. ~26 $/mois (plan Team, 50k erreurs, utilisateurs illimités). Free tier : 5k erreurs/mois, 1 utilisateur. + +| Pilier | Couverture | +|---|---| +| Erreurs | **OUI** — best-in-class. Source maps, dédup, breadcrumbs, release tracking, corrélation front/back. SDK Express + React en ~25 lignes. | +| Logs | **OUI** — GA depuis sept. 2025. 5 GB inclus, $0.50/GB au-delà. Rétention 14 jours (Team). Liés aux traces. | +| Métriques | **OUI** — custom metrics (compteurs, gauges, distributions), APM par route (P50/P95). | +| Alerting Teams | **OUI** — intégration native (app Teams, pas webhook). Assign/resolve depuis Teams. | +| Infra bare metal | **NON** — pas de monitoring CPU/RAM/disque. | +| Services managés (MySQL, Redis OVH) | **NON** — Sentry est strictement applicatif. Pas de métriques DB, pas de métriques Redis. | +| Métriques métier | **PARTIEL** — custom metrics disponibles mais pas de dashboarding flexible type Grafana. | +| Web Vitals | **OUI** — LCP/CLS/INP automatiques via browserTracingIntegration. | + +- Bon : **setup le plus simple.** `Sentry.init()` côté back et front, c'est prêt. +- Bon : **error tracking sans équivalent.** Dédup, breadcrumbs, release tracking, session replay — aucun concurrent ne fait aussi bien sur les erreurs. +- Bon : **logs + métriques couverts** depuis 2025, ce qui n'était pas le cas avant. +- Bon : **coût prévisible et modeste** — ~26-50 $/mois pour notre usage. +- Mauvais : **pas de monitoring infra ni de services managés.** Le bare metal, le MySQL et le Redis OVH restent sans rien. Sentry ne voit que ce que le code applicatif voit. +- Mauvais : **dashboarding limité** par rapport à Grafana — pas de PromQL, pas de dashboards custom avancés. + +### B. Grafana Cloud + +Plateforme complète : logs (Loki), métriques (Prometheus/Mimir), dashboards, infra, et erreurs (Faro + Loki). Free tier généreux : 10k séries métriques, 50 GB logs, 50 GB traces, 3 utilisateurs, 14 jours de rétention. + +| Pilier | Couverture | +|---|---| +| Erreurs | **PARTIEL** — Grafana Faro capture les erreurs front avec source maps. Mais pas de dédup automatique, pas d'inbox "issues", pas de session replay, pas de release tracking. Les erreurs back remontent via les logs (Loki) et les traces (Tempo) — il faut les chercher, elles ne remontent pas toutes seules. | +| Logs | **OUI** — Loki, JSON natif, LogQL. Moins puissant qu'Elasticsearch pour la recherche full-text, mais largement suffisant pour des logs structurés. | +| Métriques | **OUI** — Prometheus/Mimir, PromQL, dashboards Grafana. Best-in-class pour le dashboarding custom. | +| Alerting Teams | **OUI** — intégration webhook Teams, acknowledge/resolve depuis Teams. | +| Infra bare metal | **OUI** — Grafana Alloy (un seul binaire par serveur, remplace node_exporter + Promtail). | +| Services managés (MySQL, Redis OVH) | **OUI** — 150+ intégrations. MySQL (80+ métriques, dashboards pré-construits), Redis, PostgreSQL. Connexion directe aux endpoints OVH via Alloy. Pas d'intégration OVH dédiée, mais les intégrations standard fonctionnent. | +| Métriques métier | **OUI** — compteurs Prometheus custom, dashboards Grafana dédiés. | +| Web Vitals | **OUI** — Grafana Faro, LCP/CLS/INP automatiques. | + +- Bon : **une seule plateforme** pour tout — pas deux outils à maintenir, un seul endroit pour l'alerting. +- Bon : **couvre les 4 piliers**, y compris l'infra bare metal et les services managés OVH. +- Bon : **150+ intégrations** — MySQL, Redis, PostgreSQL avec dashboards et alertes pré-configurés. Les services managés OVH sont monitorés via leurs endpoints standard. +- Bon : **free tier généreux** — probablement 0-30 $/mois pour notre volume. +- Bon : **dashboarding sans limite.** Grafana est la référence pour les dashboards custom et les métriques métier. +- Bon : **self-hostable** si besoin (LGTM stack open source). +- Bon : **Alloy** résout le bare metal en un seul agent (métriques + logs). +- Mauvais : **error tracking moins riche que Sentry.** Pas de dédup, pas d'inbox, pas de session replay. On voit les erreurs dans les logs, mais il faut les chercher — pas d'alerte "nouvelle erreur jamais vue" sans config manuelle. +- Mauvais : **setup plus complexe.** OpenTelemetry pour le back, Faro pour le front, Alloy sur chaque serveur, PromQL/LogQL à apprendre. Compter une journée de setup vs une heure pour Sentry. +- Mauvais : **courbe d'apprentissage.** PromQL, LogQL, config Alloy, construction de dashboards. + +### D. Datadog + +Plateforme tout-en-un. ~300-500 $/mois pour 5 projets (infra + APM + logs + RUM). Free tier : 5 hosts, 1 jour de rétention. + +| Pilier | Couverture | +|---|---| +| Erreurs | **OUI** — error tracking intégré à APM et RUM, source maps, dédup, session replay. | +| Logs | **OUI** — ingestion + indexation, recherche, rétention configurable. | +| Métriques | **OUI** — APM par route, custom metrics, dashboards auto-générés. | +| Alerting Teams | **OUI** — intégration native Teams. | +| Infra bare metal | **OUI** — Datadog Agent, métriques système complètes. | +| Services managés (MySQL, Redis OVH) | **OUI** — intégrations natives, dashboards pré-construits. | +| Métriques métier | **OUI** — custom metrics, dashboards flexibles. | +| Web Vitals | **OUI** — RUM, LCP/CLS/INP. | + +- Bon : **tout est intégré** — erreurs, logs, métriques, APM, infra, services managés, RUM, dans une seule plateforme. +- Bon : **UX la plus polie** — onboarding rapide, dashboards auto-générés. +- Bon : **setup simple** — `dd-trace` en 2-4 lignes, agent en une commande. +- Bon : **intégrations services managés** natives avec dashboards pré-construits. +- Mauvais : **cher.** ~300-500 $/mois pour 5 projets. 3 600-6 000 $/an. +- Mauvais : **chaque feature est un compteur séparé** — infra, APM, logs, RUM, custom metrics. Le coût est imprévisible et tend à monter. +- Mauvais : **pas de free tier exploitable** — 5 hosts, 1 jour de rétention. +- Mauvais : **surdimensionné** pour une agence de 5-6 personnes avec 5 projets. + +### E. Elastic Cloud (ELK serverless) + +Elastic APM + Kibana + Elasticsearch. Version serverless (pas de cluster à gérer). ~50-100 $/mois pour 5 projets. + +| Pilier | Couverture | +|---|---| +| Erreurs | **PARTIEL** — Elastic APM capture les erreurs mais error tracking basique (grouping limité, pas d'inbox type Sentry). | +| Logs | **OUI** — Elasticsearch, recherche full-text la plus puissante du panel. | +| Métriques | **OUI** — APM, custom metrics, dashboards Kibana. | +| Alerting Teams | **PARTIEL** — webhook via Power Automate (connecteurs O365 dépréciés). | +| Infra bare metal | **OUI** — Elastic Agent / Metricbeat. | +| Services managés (MySQL, Redis OVH) | **OUI** — Metricbeat avec modules MySQL, Redis, PostgreSQL. | +| Métriques métier | **OUI** — custom metrics, dashboards Kibana. | +| Web Vitals | **OUI** — Elastic RUM, LCP/CLS/INP. | + +- Bon : **tout-en-un** — erreurs, logs, métriques, infra, services managés. +- Bon : **recherche full-text puissante** — Elasticsearch est la référence. +- Bon : **coût modéré** en serverless (~50-100 $/mois). +- Bon : **infra et services managés couverts** via Elastic Agent / Metricbeat. +- Mauvais : **Kibana est moins intuitif** que Grafana ou Sentry pour les dashboards. +- Mauvais : **intégration Teams dégradée** — les connecteurs O365 sont dépréciés, il faut passer par Power Automate. +- Mauvais : **quelqu'un doit devenir "la personne Elastic"** — la courbe d'apprentissage est raide sans expérience ELK. +- Mauvais : **pas d'expérience interne** sur la stack Elastic. + +## Decision Outcome + +**Grafana Cloud (option B) recommandé.** Datadog et Elastic écartés — Datadog est trop cher (~300-500 $/mois), Elastic n'apporte rien de plus et personne ne connaît la stack. + +### Ce qui oriente la recommandation + +1. **Les intégrations services managés font la différence.** MySQL et Redis OVH sont monitorés via les endpoints standard, avec 80+ métriques et des dashboards pré-construits. Sentry ne voit que le code applicatif — la DB et le cache sont des boîtes noires. + +2. **Le bare metal passe de zéro à couvert.** Grafana Alloy est un seul binaire par serveur qui collecte métriques + logs. Sentry ne fait pas ça du tout. + +3. **Une seule plateforme, un seul alerting.** Erreurs, logs, métriques infra, métriques DB — tout remonte au même endroit, les alertes Teams partent d'un seul outil. + +4. **Le free tier couvre notre volume.** 10k séries métriques, 50 GB logs, 3 utilisateurs, 14 jours de rétention — probablement suffisant sans passer au payant. + +5. **Le dashboarding custom est sans équivalent** pour les métriques métier (temps de traitement d'un dossier, etc.) — c'est un besoin récurrent qu'on gère mal aujourd'hui. + +### Le compromis assumé + +L'error tracking de Grafana est **moins riche que Sentry** : pas de dédup automatique des erreurs, pas d'inbox "issues", pas de session replay, pas de release tracking. Les erreurs remontent via les logs (Loki) et les traces (Tempo) — on peut configurer des alertes sur les logs d'erreur, mais il faut construire cette mécanique au lieu de l'avoir out-of-the-box. + +C'est un compromis acceptable parce que : +- Les erreurs **remontent quand même** — via les logs structurés et l'alerting Grafana. +- Le volume d'erreurs sur nos projets est gérable — on n'est pas sur un SaaS à 100k utilisateurs où la dédup est critique. +- Le gain sur les autres piliers (infra, services managés, dashboards) compense largement. + +| | Sentry (écarté) | Grafana Cloud (retenu) | +|---|---|---| +| **Error tracking** | Best-in-class | PARTIEL — logs + alertes, pas de dédup/inbox | +| **Logs** | 5 GB inclus | 50 GB free, Loki | +| **Métriques/dashboards** | Limité | Best-in-class (Prometheus + Grafana) | +| **Infra bare metal** | NON | OUI (Alloy) | +| **Services managés** | NON | OUI (MySQL, Redis, 150+ intégrations) | +| **Alerting Teams** | Natif | Webhook | +| **Setup** | ~1 heure | ~1 journée | +| **Coût** | ~26-50 $/mois | ~0-30 $/mois | + +### Trajectoire + +| Phase | Ce qui se passe | +|---|---| +| **1. Grafana Cloud + Faro** | Compte Grafana Cloud, SDK Faro front, OpenTelemetry back. Alerting Teams sur les erreurs. | +| **2. Logger structuré** | Module JSON dans `@igojs/server`, logs centralisés dans Loki. | +| **3. Infra + services managés** | Alloy sur le bare metal, intégrations MySQL/Redis OVH. Dashboards par projet. | +| **4. Cible** | Crash → mail retiré. Error handler `@igojs/server` capture sans `process.exit(1)`. | + +## Consequences + +- Bon : **une plateforme unique** pour les 4 piliers — erreurs, logs, métriques, alerting. +- Bon : **le bare metal et les services managés OVH sont couverts** — aujourd'hui ils sont dans le noir. +- Bon : **le front passe de zéro visibilité à un filet de sécurité réel.** +- Bon : **dashboards custom** pour les métriques métier par projet. +- Bon : **coût maîtrisé** — free tier probablement suffisant, ~0-30 $/mois. +- Mauvais : **error tracking moins riche que Sentry** — pas de dédup, pas d'inbox, pas de session replay. Compromis accepté. +- Mauvais : **courbe d'apprentissage** — PromQL, LogQL, config Alloy, construction de dashboards. Compter une journée de setup initiale. +- Mauvais : **le logger structuré est un chantier** sur les projets existants. +- Mauvais : **la suppression du crash → mail** demande une évolution d'`@igojs/server`. + +## Confirmation + +Dans six mois : +- **Temps moyen entre apparition d'un bug et sa détection** — cible : < 24h (aujourd'hui : infini côté front). +- **Les logs sont-ils centralisés et cherchables ?** +- **Le bare metal est-il monitoré ?** +- **Le crash → mail est-il encore le seul filet back ?** Si oui, la phase 4 n'a pas été atteinte. + +## More Information + +### Détail des besoins par pilier + +Les besoins complets sont détaillés dans le Context. Résumé pour référence rapide : + +**Erreurs** — source maps, dédup, breadcrumbs, corrélation front/back, suivi par release. Services tiers inclus (API partenaires, SMTP, stockage). + +**Logs** — JSON, centralisés, 7-15 jours prod / 3 jours staging. Attributs : `level`, `timestamp`, `service`, `version`, `env`, `requestId`, `httpStatus`, `userId`, `duration`. + +**Métriques** — P50/P95/P99 par route, taux d'erreur HTTP, SQL lentes, Web Vitals (grand public), uptime. Métriques métier custom par projet. Infra bare metal en nice-to-have. + +**Alerting** — seuils, pas unitaire. Teams principal, mail backup. Configurable par projet. + +### Grille comparative des plateformes + +| Pilier | Sentry | Grafana Cloud | Datadog | Elastic Cloud | +|---|---|---|---|---| +| **Erreurs front** | **Best-in-class** | PARTIEL (Faro, pas de dédup) | OUI | OUI (APM) | +| **Erreurs back** | **OUI** | PARTIEL (logs/traces) | OUI | OUI (APM) | +| **Logs structurés** | OUI (5 GB inclus) | **OUI** (Loki, 50 GB free) | OUI | **OUI** (Elasticsearch) | +| **Métriques/APM** | OUI | **OUI** (Prometheus) | **OUI** | OUI | +| **Dashboards custom** | Limité | **Best-in-class** | OUI | OUI (Kibana) | +| **Alerting Teams** | OUI (natif) | OUI (webhook) | OUI (natif) | OUI (webhook) | +| **Web Vitals** | OUI | OUI (Faro) | OUI (RUM) | OUI (RUM) | +| **Infra bare metal** | **NON** | **OUI** (Alloy) | OUI (Agent) | OUI (Agent) | +| **Services managés (MySQL, Redis)** | **NON** | **OUI** (150+ intégrations, dashboards pré-construits) | OUI (intégrations natives) | OUI (Metricbeat) | +| **Métriques métier** | PARTIEL | **OUI** | OUI | OUI | +| **Setup** | ~1 heure | ~1 journée | ~2 heures | ~1 journée | +| **Coût /mois (5 projets)** | ~26-50 $ | ~0-30 $ | ~300-500 $ | ~50-100 $ | +| **Free tier** | 5k erreurs, 1 user | 10k séries, 50 GB logs, 3 users | 5 hosts, 1 jour | 14 jours trial | + +### Error Boundary et gestion des erreurs React + +Un Error Boundary racine redirige vers une page d'erreur générique. Les erreurs visibles au quotidien sont les **erreurs réseau** — chaque section qui appelle `useQuery` gère explicitement loading, error et data. Le composant `` est partagé dans `components/`. + +### Trajectoire de suppression du crash → mail + +Le crash → mail reste en parallèle pendant les phases 1-3. La phase 4 (cible) demande une évolution du error handler d'`@igojs/server` : les erreurs sont capturées et remontées à la plateforme choisie **sans tuer le process**. Le `process.exit(1)` actuel est un effet de bord utilisé comme alerte, pas un choix d'architecture. diff --git a/docs/adr/systeme-de-design.md b/docs/adr/systeme-de-design.md new file mode 100644 index 00000000..c5bfa1fb --- /dev/null +++ b/docs/adr/systeme-de-design.md @@ -0,0 +1,129 @@ +# Système de design + +**Statut** : **accepté** — décision prise le 21 août 2026 +**Date** : 2026-08-21 +**Décideurs** : l'équipe et la direction + +## Context and Problem Statement + +Le choix d'un système de design a été isolé de celui de la [technologie de composants](technologie-de-composants-front.md) parce qu'il n'obéit pas aux mêmes critères : c'est d'abord une décision de produit et d'identité visuelle. Mais les deux se contraignent, et il faut savoir dans quel sens. + +Deux contraintes de contexte, établies par la recherche du 20 août 2026 : + +- **Le DSFR est un critère de projet, pas de socle.** Ses Modalités d'Utilisation en interdisent l'usage hors administration et hors domaine `.gouv.fr` : le projet privé de l'agence ne peut pas l'utiliser, même par choix esthétique. Il ne pèse donc que sur un projet (ladom). +- **L'accessibilité, elle, est devenue un critère de socle.** Le décret n° 2023-931 est en vigueur depuis le 28 juin 2025 et couvre le commerce électronique, les services bancaires et le transport. L'exonération vise les entités de moins de dix personnes — donc le client, pas l'agence. + +## Decision Drivers + +- **Neutralité visuelle** : le DS doit se thématiser par client, sans imposer une identité reconnaissable. +- **Couverture fonctionnelle** : virtualisation, transitions, tableaux, sélecteurs riches, upload, tooltips et modales. +- **Maintenance réelle** — première main ou portage communautaire, nombre de mainteneurs, cadence. +- **Licence, et sa stabilité dans le temps.** +- **Accessibilité livrée**, pas seulement annoncée. +- **Coût de montée de version** : un DS est appelé depuis chaque composant, donc une majeure de portée composant se paie partout. + +## Considered Options + +**Le vrai choix porte sur une approche, pas sur un produit.** Le produit suit. Et l'arbitrage se résume à une tension : **l'étendue du prêt-à-porter contre la cohérence d'un seul système de jetons.** + +Trois approches, toutes cohérentes. Tailwind est déjà en place sur les projets existants, donc il est présent dans les trois. + +### A. Système de design stylé, plus Tailwind pour nos composants + +*Produits : MUI et son extension MUI X · Mantine · Ant Design* + +- Bon : **le chemin le plus rapide au premier écran livré.** MUI donne une soixantaine de composants immédiatement, et son extension payante couvre les trous que personne d'autre ne couvre gratuitement — tableau de données complet, sélecteurs de dates, graphiques. +- Bon : **la charge d'habillage est réellement externalisée**, ce qui est l'objectif de départ. Quelqu'un d'autre maintient les animations, les tooltips, l'accessibilité des composants. +- Bon : **les éditeurs soutiennent officiellement la combinaison.** MUI publie une page d'intégration Tailwind 4 avec `enableCssLayer` et un ordre de couches explicite ; Ant Design documente le même mécanisme. Ce n'est pas du bricolage. +- Bon : sur les critères de montée de version, MUI livre **des codemods pour plus de quarante composants**. +- Neutre : la friction historique — les styles du DS gagnant sur les classes utilitaires — **est résolue par les couches en cascade**. Toute recette à base de `!important` global est antérieure au moteur v4. +- Neutre : le socle de configuration est à écrire une fois et à ranger dans le gabarit de démarrage. Non trivial, mais non répétitif. +- Mauvais : **deux systèmes de jetons, et le pont est du code maison.** Aucun éditeur ne publie de fichier `@theme` prêt à importer : l'équipe écrit et maintient elle-même le mappage entre les jetons du DS et ceux de Tailwind. Ce code **casse silencieusement quand le DS renomme un jeton**, à chaque majeure et sur chaque projet. C'est le coût réel de cette approche. +- Mauvais : **deux vocabulaires visuels dans le même écran.** Un composant du DS et un composant maison en Tailwind n'auront pas la même échelle d'espacement ni la même palette sans ce pont. Le symptôme n'est pas une panne, c'est une incohérence diffuse. +- Mauvais : **MUI est marqué.** Il implémente Material Design ; la thématisation change les jetons, pas la grammaire visuelle. Pour du travail multi-clients, le désapprentissage du look Material est un poste récurrent. +- Mauvais : **Mantine est le plus mal placé des trois sur cet axe précis** — culturellement le plus proche de Tailwind, mais aucune page d'intégration, aucune mention du moteur v4, et sa réponse officielle se limite à « désactivez le preflight ». Or s'en passer coûte le lissage inter-navigateurs et les styles de base des titres, listes et formulaires, à reprendre soi-même. +- Mauvais : Ant Design porte une identité visuelle très reconnaissable, et Mantine repose sur **un seul mainteneur npm**. + +### B. Système de design bâti sur Tailwind + +*Produits : shadcn/ui · éventuellement complété par Tailwind Plus pour le balisage et les mises en page* + +- Bon : **un seul système de jetons, par construction.** shadcn définit ses jetons en propriétés personnalisées puis les expose à Tailwind par `@theme inline` — **le pont fait partie du code livré**, il n'est pas à écrire ni à maintenir. C'est l'inverse exact du principal défaut de l'approche A. +- Bon : **neutralité totale** — le code vit dans votre dépôt, donc aucune identité d'entreprise à effacer, et une identité repartable à zéro par client. +- Bon : **coût de montée de version nul sur le code copié** — il n'y a pas de dépendance runtime pour la couche visuelle. *Attention à ne pas surétendre l'argument : la primitive de bas niveau qui fournit le comportement reste, elle, une dépendance npm ordinaire, avec ses majeures.* +- Bon : **hors de portée d'un changement de licence futur.** Après l'archivage de PrimeReact en juin 2026, ce n'est pas théorique. +- Bon : le dépôt le plus vivant du panel — 67 auteurs distincts sur les cent derniers commits. +- Bon : **c'est le même socle que l'approche C**, à un niveau de préhabillage supérieur. Base UI recommande lui-même shadcn pour du prêt-à-porter. +- Neutre : la couverture est bonne mais moins large qu'une suite complète — une soixantaine d'éléments dont tableau de données et combobox, contre MUI plus MUI X. +- Neutre : Tailwind Plus se **compose** avec, il ne concurrence pas. Sa licence est la seule du champ taillée pour une agence — « unlimited End Products for unlimited Clients », 849 € une fois pour 25 personnes. +- Mauvais : **le code copié devient le vôtre, et les correctifs amont ne redescendent pas.** À dix ou vingt projets clients, c'est là qu'est le point de décision : la charge que vous vouliez externaliser revient partiellement. +- Mauvais : **la primitive sous-jacente n'est pas nommée** par la documentation. Or c'est d'elle que vient tout le comportement — clavier, focus, rôles ARIA, annonces au lecteur d'écran — donc **l'accessibilité héritée en dépend entièrement**, et elle reste une dépendance npm à monter de version. Si la primitive est Radix, on hérite aussi de son point de gouvernance : quatre auteurs distincts, et des comptes de publication sous contrôle d'une entreprise unique. +- Mauvais : **la couche interactive de Tailwind Plus est figée** — son paquet de comportement n'a rien publié depuis janvier 2026. À évaluer comme une collection de balisage, sans compter sur son interactif. + +### C. Socle sans habillage, plus habillage Tailwind maison + +*Produits : Base UI · react-aria-components · Ark UI* + +- Bon : **la neutralité maximale**, et aucune question de cohabitation — pas de réinitialisation concurrente, pas d'ordre d'injection, pas de double jeu de jetons. +- Bon : **la meilleure accessibilité du champ, et la seule étayée.** react-aria est le seul candidat de toute l'étude à décrire un protocole de test — « extensively tested using many popular screen readers and devices » — là où les autres se contentent d'annoncer. +- Bon : **coût de montée de version quasi nul** — react-aria n'a publié aucune majeure depuis décembre 2023. +- Neutre : Base UI a changé de nom de paquet ; toute comparaison citant l'ancien est périmée. +- Mauvais : **elle rend intégralement la charge d'habillage**, c'est-à-dire précisément ce que l'agence veut externaliser. C'est le motif qui a écarté un candidat à l'axe précédent. +- Mauvais : elle ne s'amortit qu'en construisant un socle interne réutilisé — **c'est-à-dire en refaisant un framework maison.** Exactement ce que l'agence quitte. +- Mauvais : ni tableau de données, ni upload avec progression livrés. + +### Briques qui se greffent sur n'importe laquelle des trois + +- **La virtualisation ne justifie aucun achat** : TanStack Virtual est en MIT et publié dans le mois. +- **Le tableau de données** est le seul trou qui puisse justifier une brique payante. Six grilles commerciales sont vivantes. +- **Attention à AG Grid** : sa licence développeur **ne suffit pas** à livrer au client. Sous-licencier exige un module complémentaire distinct **dont le prix n'est pas affiché**. +- **Point aveugle de tous les contrats commerciaux lus** : aucun ne dit si les développeurs du client sont couverts **quand il reprend la maintenance**. À prévoir dans le contrat de prestation. + +## Decision Outcome + +**Approche B retenue : shadcn/ui sur Tailwind**, avec **TanStack Table** pour les tableaux de données. + +**Ce qui a décidé, dans cet ordre :** + +- **Les compétences et la préférence en place.** Plusieurs personnes de l'équipe connaissent déjà shadcn et le préfèrent à MUI. C'est le même critère qui a décidé la technologie de composants, et il vient de première main. +- **Tailwind est déjà en place sur les projets existants.** C'est la seule approche à coût d'adaptation nul : le pont entre les jetons du système de design et ceux de Tailwind **fait partie du code livré**, il n'est ni à écrire ni à maintenir. C'est le seul poste de l'approche A que personne ne facture mais que quelqu'un paie, à chaque majeure et sur chaque projet. +- **Le travail est multi-clients.** shadcn n'a aucune identité d'entreprise à effacer, là où MUI impose la grammaire visuelle de Material — que la thématisation ne change pas. +- **L'équipe n'a pas de designer dédié**, et c'est ce qui écarte l'approche C tout en rendant B viable : un socle sans habillage laisserait devant une page blanche, alors que shadcn livre des habillages de départ corrects. +- **Le besoin de tableaux est couvert gratuitement.** Le registre shadcn porte un tableau de données bâti sur TanStack Table, en MIT. Trier, filtrer et paginer ne justifie aucun achat, pas plus que la virtualisation. + +**Ce qui retournerait la décision, énoncé pour qu'on le reconnaisse le jour venu :** un volume significatif d'écrans à **grille vraiment riche** — export tableur, épinglage de colonnes, regroupement, tableaux croisés. TanStack Table ne le fait pas, et le construire coûterait plus qu'une licence. **Mais l'asymétrie joue en faveur de B** : ajouter une grille riche plus tard reste un achat ciblé, alors qu'adopter MUI aujourd'hui engagerait toute la couche visuelle. + +### Consequences + +- **Copier par projet, et accepter la divergence.** La tentation naturelle sera de ranger les composants dans un paquet interne partagé entre projets — et **ce serait recréer la couche maison que l'agence quitte**, avec son mainteneur unique et sa dette de version. Les clients étant différents et leurs identités visuelles distinctes, un composant qui dérive légèrement entre deux projets ne coûte rien. Maintenir un socle commun que personne ne possède à plein temps, si. +- **Le code copié devient celui de l'agence**, donc les correctifs amont ne redescendent pas. C'est le prix assumé de l'approche, et il croît avec le nombre de projets. +- **Un seul système de jetons**, exposé à Tailwind par le code livré. C'est l'acquis principal, et il faut le préserver : ne pas introduire un second système de thématisation à côté. +- **Tailwind Plus se compose** si l'équipe veut du balisage et des mises en page en volume — 849 € une fois pour 25 personnes, et **la seule licence du champ explicitement taillée pour une agence**, « unlimited End Products for unlimited Clients ». À évaluer comme une collection de balisage : **sa couche interactive est figée depuis janvier 2026.** +- **Aucun achat n'est nécessaire au démarrage.** Ni pour la virtualisation, ni pour les tableaux, ni pour le socle. +- **L'accessibilité reste à auditer par projet.** Aucun candidat du champ ne publie de rapport de conformité, et le système de design ne livre pas la conformité du site — il réduit la dette structurelle. + +## Confirmation + +**Une vérification avant la première ligne de code, et elle prend deux minutes : quelle bibliothèque de bas niveau shadcn utilise.** + +Le code que shadcn copie est une couche visuelle posée sur une bibliothèque qui fournit le comportement — navigation au clavier, gestion du focus, rôles ARIA, annonces au lecteur d'écran. Sa documentation ne la nomme pas. Il suffit d'ouvrir un composant du registre, par exemple `dialog.tsx` ou `select.tsx`, et de lire ses imports. + +**Vérifié le 24 août 2026 : c'est Radix UI** (`@radix-ui/react-*`). L'accessibilité héritée vient de Radix. La gouvernance Radix est sous contrôle de WorkOS — quatre auteurs distincts, comptes de publication liés à une entreprise unique. **Une veille sur les correctifs d'accessibilité shadcn doit être en place** pour les appliquer manuellement aux projets — sans quoi « accepter la divergence » glisse vers « ignorer les correctifs ». + +**Un point à instruire sur le projet public, et il n'est pas mince** : le DSFR est du CSS global avec sa propre réinitialisation et ses propres jetons. Sur ce projet, **le problème des deux systèmes de jetons revient**, cette fois entre le DSFR et Tailwind. C'est la seule cohabitation CSS réelle du dossier, et elle est circonscrite à un projet. + +À douze mois : le nombre de composants réécrits par divergence entre projets — s'il explose, la décision de copier par projet était mauvaise et il faudra assumer un paquet partagé. + +## More Information + +Cette décision est **contrainte par** la [technologie de composants front](technologie-de-composants-front.md) : dix candidats en React, sept en Vue, quatre en Svelte. + +### Décision non prise, notée pour mémoire : un DS igo en web components + +**Ce serait le bon usage de Lit** — un DS livré en custom elements fonctionne dans n'importe quel hôte, et l'étanchéité du Shadow DOM y devient une qualité. + +**Écarté, et pas sur la probabilité de réutilisation** : cela recréerait une couche maison **sur l'habillage**, là où la rétrospective a mesuré le péage du framework maison. La part réutilisable d'un DS est le comportement, non les tokens qui portent la marque de chaque client — et ce comportement existe déjà, maintenu par d'autres. + +**Friction technique** : Tailwind est du CSS global et un shadow root ne le reçoit pas ; il faut adopter la feuille dans chaque composant, ce qui défait le modèle de Tailwind. Coût non mesuré. + +**Condition sous laquelle ce choix se retourne** : le même DS servant trois projets longs ou plus, avec des écarts entre clients uniquement au niveau des tokens. Une version étroite reste envisageable sans engager de couche — quelques widgets transverses en custom elements. diff --git a/docs/adr/technologie-de-composants-front.md b/docs/adr/technologie-de-composants-front.md new file mode 100644 index 00000000..9f1db5dd --- /dev/null +++ b/docs/adr/technologie-de-composants-front.md @@ -0,0 +1,181 @@ +# Technologie de composants front + +**Statut** : **accepté** — décision prise le 21 août 2026 +**Date** : 2026-08-21 +**Décideurs** : l'équipe et la direction + +## Context and Problem Statement + +L'architecture est décidée : front à composants buildé en assets statiques, consommant une API JSON exposée par le socle maison, livré en un artefact, assets servis par nginx. Rendu serveur, BFF et front à process propre sont écartés. Reste à choisir **dans quelle technologie les composants sont écrits**, pour trois à cinq ans, dans une agence de 5-6 personnes. + +Contrainte héritée de l'architecture : **la possession se fait par route ou par sous-espace.** Une route donnée est servie soit entièrement par igo et dust, soit entièrement par le nouveau front — **jamais les deux mélangés dans une même page.** La cohabitation avec les 1 669 templates dust est donc durable, mais elle se joue **entre pages**, non dans une page. + +Une recherche technique en dix dimensions a été menée les 20 et 21 août 2026. Elle n'établit pas d'ordre entre les deux premiers candidats. + +## Decision Drivers + +Pondérations arrêtées en atelier le 19 août 2026, révisées par les décideurs le 21. Échelle 0-5 où **5 = un mauvais résultat peut à lui seul écarter un candidat**. + +| Poids | Critère | Ce que la recherche établit | +|:--:|---|---| +| 5 | Capacité réactive | **Ne discrimine pas** — la porte éliminatoire l'établit pour les trois | +| 5 | Testabilité des composants côté client | **Ne discrimine pas.** Poids maintenu : c'est une exigence, pas un départage | +| 5 | Écosystème de l'habillage | **Discriminant** | +| 4,5 | Effort de montée de version | **Discriminant** sur deux des trois modes d'effort — réécrire nos composants, adapter nos appels, remplacer une brique abandonnée — **et pas dans le même ordre** | +| 4-5 | Expérience développeur, sur la durée | **Non instruit.** Mesurable en interne | +| 4 | Recrutement et onboarding *(fusionnés)* | **Discriminant** | +| 4 | Facilité de détection d'un problème et observabilité | **Discriminant faiblement** — ~70 % du besoin est agnostique | +| 4 | Facilité de diagnostic et de réparation | **Discriminant faiblement** — les trois sont également aveugles à l'asynchrone | +| 4 | TypeScript | **Discriminant faiblement** — dans le bruit si l'agence reste sur TypeScript 6 | +| 3,5 | Robustesse de gouvernance du cœur | **Discriminant** | +| 3 | Exploitation — poids de bundle | **Discriminant** — facteur 3,3 entre les extrêmes | +| 3 | Documentation et communauté · compétences en place | Compétences : 4 personnes sur 6 sur React | + +**Deux critères invoqués en discussion qui ne peuvent pas servir.** La **convergence React web / React Native** est un argument d'amortissement, donc de portefeuille : avec un seul projet mobile il est neutre ou défavorable, et la couche de partage d'UI est la moins bien livrée du dossier. La **qualité de l'assistance LLM** n'est pas instruite publiquement et ne peut être invoquée dans aucun sens. + +## Considered Options + +**Finalistes : React · Vue · Svelte.** Le filtre éliminatoire — monter une racine dans une page rendue par le serveur, un sous-espace à la fois — **est trivialement satisfait par les six candidats initiaux.** Il ne coupe personne, et il aurait fallu le formuler ainsi plus tôt : la recherche l'a instruit dans une acception plus exigeante — plusieurs racines indépendantes saupoudrées dans une page que le framework ne possède pas — qui n'est pas le besoin de l'agence. + +Trois candidats sont écartés sur l'écosystème de l'habillage, le critère pondéré 5 qui a motivé le changement d'architecture : + +| Écarté | Motif | +|---|---| +| **Preact** | Aucun paquet vivant dans **six des sept** catégories d'habillage — l'upload excepté, Uppy étant bâti sur Preact. Son écosystème est celui de React vu par `compat`, avec un blocage de plage démontré et **aucune preuve d'exécution en 2026** | +| **Solid** | Sa réponse à l'absence de bibliothèque complète est *headless*, donc elle **rend à l'agence la charge d'habillage qu'elle veut externaliser**. Facteur d'autobus de 1 sur le cœur, majeure en RC dans la fenêtre | +| **Lit** | **Désalignement de modèle** : seul candidat bâti sur les web components, donc à isolation Shadow DOM imposée par le navigateur. Comme la possession est par route, l'objection ne porte pas sur le CSS existant — elle porte sur le fait qu'**une bibliothèque tierce injectant son propre CSS global** (FilePond, Uppy, AG Grid) ne peut pas styler l'intérieur d'un composant, ce qui vaut même sur une page vierge. Y renoncer coûte la composition par slots. Sa virtualisation est par ailleurs sans publication depuis treize mois, sous préfixe `labs`. *(Retenir un kit de web components — Web Awesome, Vaadin, tous deux sur `lit ^3` — revient à adopter ce modèle par l'habillage.)* | + +## Pros and Cons of the Options + +### React + +- Bon : seul pipeline de formation français en bootcamp, et le vivier le plus large — plus de mille offres contre 400 et une cinquantaine. **Les créations de projets vont dans le même sens et ne s'inversent pas** : environ treize fois celles de Vue et cinq fois celles de Svelte. +- Bon : dix systèmes de design majeurs vivants, et la bibliothèque DSFR la plus profonde. +- Bon : la distribution de contributeurs la plus plate des trois, adossée depuis février 2026 à une fondation à huit membres platine. +- Bon : **la possibilité de différer une montée de version** — correctifs publiés sur les mineures antérieures, avec une étiquette de rétroportage dédiée. +- Bon : plancher d'API dures le plus bas des trois — aucune dépendance non polyfillable. *Critère non pondéré, à intégrer si l'agence le veut.* +- Bon : aucun vérificateur TypeScript tiers, donc le gain de vélocité de TypeScript 7 y arrive d'abord. +- Bon : **un cycle de nettoyage rejoué en développement**, qui révèle sans test un abonnement non défait ou un effet qui double-soumet. +- Bon : 4 personnes sur 6 connaissent la stack. +- Mauvais : **le runtime le plus lourd — environ 60 Ko gzip**, contre 25 et 18. Sur un public mobile en outre-mer à terminaux datés, c'est 35 à 42 Ko de surcoût fixe par sous-espace servi. +- Mauvais : **l'écosystème où les faux amis sont les plus gros.** Plusieurs paquets à dizaines de millions de téléchargements hebdomadaires sont gelés depuis deux à quatre ans, dont celui du panneau glissant — l'un des trois besoins nommés. Choisir React impose d'acquérir **la curation comme compétence**. +- Mauvais : les transitions passent par une bibliothèque tierce à deux auteurs, là où Vue et Svelte les ont dans le cœur. +- Mauvais : seul des trois à ne pas publier ses définitions TypeScript. +- Mauvais : le moins outillé des trois pour être compris des agents de code. + +### Vue + +- Bon : **transitions dans le cœur**, sans dépendance tierce, sur la catégorie qui a motivé le changement. +- Bon : **l'effort de montée de version le plus faible sur le cœur** — dernière majeure en 2020, et la prochaine version n'est qu'une mineure. +- Bon : **la meilleure réponse à « pourquoi ça ne s'est pas rendu »** — l'énumération des dépendances effectivement suivies désigne la cause. +- Bon : environ 25 Ko gzip, deux fois et demie plus léger que React. +- Bon : le mieux documenté des trois sur l'adoption progressive dans une application existante. +- Bon : trois systèmes de design complets indigènes, dont React n'a pas d'équivalent à porter. +- Bon : franchit le seuil du vivier suffisant, avec un marché junior propre et aucune prime de salaire. +- Bon : bibliothèque DSFR réelle, adossée à une administration. +- Neutre : `vue-tsc` est requis pour vérifier les templates et bloqué sur TypeScript 6 — mais un chemin d'architecture est validé, et la dépréciation de l'outil est envisagée. +- Mauvais : **le cœur le plus concentré des trois** — près de la moitié des commits humains sur une seule personne, et le créateur hors du premier rang des contributeurs. +- Mauvais : **le financement de Vue en 2026 est inconnu.** C'est un trou, pas une conclusion : l'activité du dépôt est correcte. +- Mauvais : la prochaine mineure stagne en pré-publication depuis huit mois, et **le blog officiel n'a rien annoncé depuis septembre 2024**. +- Mauvais : deux références que la mémoire collective désigne encore sont à écarter comme socle. +- Mauvais : `shadcn-vue` et `reka-ui` sont des portages communautaires publiés par le même compte unique. +- Mauvais : aucun cursus français ne le prend comme framework principal ; l'offre est en formation continue courte. + +### Svelte + +- Bon : **les transitions les plus complètes du panel, dans le cœur**, y compris au montage et au démontage de la racine. +- Bon : **le plus léger — environ 18 Ko gzip**, facteur 3,3 contre React. +- Bon : **le seul à permettre une migration incrémentale** — l'ancienne syntaxe reste acceptée et les deux styles cohabitent composant par composant. +- Bon : les deux erreurs de réactivité les plus fréquentes remontent en avertissements du compilateur, **sans aucune configuration**. +- Bon : le mieux outillé des trois pour être compris des agents de code. +- Neutre : l'objection de rupture 3 → 5 est largement désamorcée par le mode de compatibilité. +- Neutre : le risque SvelteKit 3 ne s'applique probablement pas — dans cette architecture le routage reste serveur. **Non vérifié.** +- Mauvais : **le vivier de recrutement ne franchit pas le seuil**, et aucun cursus français n'a été trouvé. Choisir Svelte, c'est décider de former en interne, indéfiniment. +- Mauvais : **la couverture par les DS majeurs est la plus étroite** — quatre actifs dérivant de deux socles, et Tailwind Plus l'exclut explicitement. +- Mauvais : **aucune bibliothèque DSFR.** Le DSFR se ferait à la main sur le vanilla, avec le risque non instruit que son JS auto-initialisé se dispute la propriété du DOM. +- Mauvais : **son système de design a enchaîné trois majeures en dix-sept mois**, précisément là où le code est appelé partout. +- Mauvais : **aucun rétroportage de correctif** — pour un correctif il faut monter à la dernière mineure, et la cadence est élevée, donc subie. +- Mauvais : **plus d'inspecteur d'arbre maintenu**, et sa frontière d'erreur ne capture ni les gestionnaires d'événements ni l'asynchrone. +- Mauvais : deux bugs silencieux documentés sans détection — un état lu après un `await` n'est pas suivi, et la déstructuration d'un état réactif casse la réactivité. +- Mauvais : le support Webpack est en maintenance minimale. +- Mauvais : l'upload est le seul trou de sa grille d'habillage. + +## Decision Outcome + +### Matrice repondérable + +Notes de performance de 0 à 5, distinctes du poids. + +| Critère | Poids | React | Vue | Svelte | +|---|:--:|:--:|:--:|:--:| +| Écosystème de l'habillage | 5 | **5** | 4,5 | 3,5 | +| Testabilité des composants côté client | 5 | **4** | **4** | 3,5 | +| Effort de montée de version | 4,5 | **4,5** | **4,5** | 3,5 | +| Recrutement et onboarding | 4 | **5** | 4,25 | 1,75 | +| Facilité de détection et observabilité | 4 | **4,5** | 4 | 3 | +| Facilité de diagnostic et de réparation | 4 | **4** | **4** | 3,5 | +| TypeScript | 4 | **4,5** | 4 | 3,5 | +| Robustesse de gouvernance du cœur | 3,5 | **4,5** | 3,5 | 3,5 | +| Exploitation — poids de bundle | 3 | 2,5 | 4 | **4,5** | +| Compétences en place | 3 | **5** | 2 | 2 | +| Expérience développeur, sur la durée | 4-5 | non instruit | non instruit | non instruit | +| Capacité réactive | 5 | *ne discrimine pas* | *ne discrimine pas* | *ne discrimine pas* | +| **Total sur 200** | **40** | **175,5** | **158,0** | **129,5** | +| **En pourcentage** | | **87,8 %** | **79,0 %** | **64,8 %** | + +**Quatre mises en garde de lecture, sans lesquelles la matrice se lit de travers :** + +- **Un score affiché n'est pas un verdict.** React et Vue sont co-admissibles ; l'ordre dépend des poids, et il s'inverse sur les seuls critères techniques. +- **Poids et pouvoir discriminant sont deux choses distinctes.** Un critère peut peser 5 et ne pas séparer les candidats — c'est le cas de la testabilité et de la capacité réactive. Cela signifie « ça compte énormément, et les trois le servent », pas « il faut baisser le poids ». **Baisser un poids pour rendre l'arithmétique plus tranchante serait falsifier les priorités.** +- **Les notes de détection et de diagnostic sont resserrées volontairement** : environ 70 % de ces deux besoins repose sur de l'outillage agnostique identique aux trois. Les étaler serait faux. +- **Sur « pourquoi ça ne s'est pas rendu », React n'a pas été instruit.** C'est un trou de couverture, pas une preuve d'infériorité. + +**Sensibilité.** Le poids de l'effort de montée de version ne déplace rien — 87,7 % à 4 contre 87,8 % à 5 — React et Vue y étant ex æquo. **C'est le poids des octets qui décide** : le ramener de 5 à 3 porte l'écart React/Vue de 6,9 à 8,8 points de pourcentage, les octets étant le principal contrepoids technique à React. + +**Technologie retenue : React.** + +**Ce qui a décidé, dans cet ordre :** + +- **Les compétences en place.** Quatre personnes sur six la pratiquent au quotidien, et le projet mobile en React Native élargit cette base. Partir ailleurs, c'est former quatre à six personnes au lieu de deux — une asymétrie qu'un critère pondéré 3 ne capture pas. +- **Le vivier et le pipeline de formation.** Seul des trois à avoir des bootcamps français qui en produisent des juniors, et le vivier le plus large. Sur un critère pondéré 4 où l'attractivité est décrite en interne comme « un vrai challenge », c'est le seul écart qui se traduise mécaniquement en délai d'onboarding. +- **La gouvernance du cœur.** La distribution de contributeurs la plus plate des trois, adossée depuis février 2026 à une fondation à huit membres platine, et la seule des trois à publier des correctifs sur les mineures antérieures — donc la seule qui permette de différer une montée de version. +- **La profondeur DSFR**, sur le projet public. + +**Ce que la matrice ajoute, et ce qu'elle n'ajoute pas.** Elle donne 87,8 % contre 79,0 % et 64,8 %, ce qui est cohérent avec ce qui précède — mais l'écart dépend des poids, et il s'inverse sur les seuls critères techniques. **La décision ne repose pas sur le score.** Elle repose sur les quatre faits ci-dessus, dont trois sont de première main. + +**Deux faiblesses sciemment acceptées**, et c'est le principal apport de l'étude : + +- **Le runtime le plus lourd du panel** — environ 60 Ko gzip contre 25 et 18 — sur un public mobile en outre-mer à terminaux datés. D'où le budget d'octets en intégration continue, en conséquence. +- **L'écosystème où les faux amis sont les plus gros.** Plusieurs paquets à dizaines de millions de téléchargements hebdomadaires sont gelés depuis deux à quatre ans, dont celui du panneau glissant. D'où la liste de dépendances validée comme premier livrable. + +**Ce que l'étude n'a pas changé, et il faut le dire** : c'est le candidat que l'équipe aurait retenu spontanément. Ce qu'elle a produit n'est pas la réponse, c'est **ce qu'il faut surveiller à partir du premier jour.** + +### Consequences + +Vraies quelle que soit l'option retenue, donc utilisables dès maintenant. + +- **La liste de dépendances validée et datée, avec sa règle de révision, est le premier livrable** — avant la première ligne de code. Dix paquets vérifiés montrent que le classement par téléchargements désigne des paquets morts, et un modèle reproduit ce corpus mort avec assurance : c'est donc à la fois une hygiène humaine et **le garde-fou de l'assistance LLM**. +- **Un budget d'octets par page, mesuré en intégration continue.** Le public mobile en outre-mer le justifie seul. +- **Privilégier les dépendances agnostiques du framework** là où elles existent et sont maintenues : `zod`, le cœur TanStack, `motion`, `@floating-ui/dom`, Uppy. Ce sont les briques les mieux adossées du dossier. +- **Le RGAA 5 est annoncé pour fin 2026**, donc un changement de référentiel tombera pendant la durée de vie du choix. + +## Confirmation + +**Les cinq mesures préalables identifiées par la recherche ont été écartées par les décideurs.** Le choix n'étant pas serré — 8,8 points d'écart — et portant sur la technologie que quatre personnes sur six pratiquent déjà, elles auraient augmenté la confiance sans changer la réponse. + +**Deux conséquences à assumer** : l'expérience développeur, pondérée 4-5, reste **non instruite** ; et la grille virtualisée de mille tuiles — le cas d'usage réellement mesuré par l'agence — reste couverte de façon vérifiée par **aucun candidat**. + +Comment on saura, dans douze mois, si la décision était bonne : + +- **Le compte de tests de composants**, aujourd'hui nul sur un critère pondéré 5. +- **La part de couverture qui ne repose plus sur l'end-to-end** — l'enjeu est de faire redescendre la vérification au niveau du composant, pas d'ajouter des tests lourds. +- **Le budget d'octets par page**, tenu ou non. +- **Le nombre d'interventions imputables à une dépendance morte** — mesure directe de l'efficacité de la liste validée. + +## More Information + +Cette décision **découle de** [Architecture front de référence](architecture-front-de-reference.md) : la possession par route et l'exclusion du rendu serveur en viennent. Elle **contraint** le [Système de design](systeme-de-design.md) : dix candidats en React, sept en Vue, quatre en Svelte. + +Le rapport de recherche (dans le dépôt front-stack-study) porte les mesures, les sources datées et la passe adverse. **Il est daté du 20 août 2026 et doit être rafraîchi au-delà de février 2027.** + +**Deux affirmations à refuser si elles apparaissent en réunion** : des chiffres de génération de code par framework présentés comme issus d'un benchmark académique, introuvables dans la source ; et le chiffre de « 50 000 € d'amende » sur l'accessibilité, non corroboré par le texte officiel. diff --git a/docs/cadre-decision-stack-front.md b/docs/cadre-decision-stack-front.md new file mode 100644 index 00000000..e738f9ec --- /dev/null +++ b/docs/cadre-decision-stack-front.md @@ -0,0 +1,104 @@ +--- +titre: Cadre de décision — stack front de référence +décision: Choisir la stack front par défaut de l'agence pour les projets dont on a le build et le run +échéance: septembre 2026 +horizon: 3 à 5 ans +statut: décidé — front React en assets statiques, un artefact, habillage shadcn/ui, buildé sur le serveur +date: 2026-08-21 +révision: v7 — ADR back ajoutés, axe 3 instruit, feuille de route du socle +--- + +# Cadre de décision — stack front de référence + +**Ce document ne décide rien.** Il porte le périmètre de l'étude, l'index des preuves et l'ordre des décisions. Les arbitrages sont dans les ADR, qui sont la seule source à citer. + +## Périmètre + +| | | +|---|---| +| **Décision** | La stack front **par défaut** de l'agence, pour les projets dont elle assure **le build et le run** | +| **Exceptions** | Les projets de **build seul** peuvent recevoir une stack imposée (cas funecap) | +| **Horizon** | 3 à 5 ans · **décision prête en septembre 2026** — prête, pas exécutée : les travaux démarrent au go et au budget client | +| **Non décidé** | Aucune migration d'existant n'est engagée. Chaque refonte se décide ensuite, projet par projet, en cohabitation durable avec les 1 669 templates dust | +| **Portefeuille** | ladom, certigo, un projet mobile React Native, les projets futurs. funecap en sort : build seul, sans run | +| **Équipe** | 5-6 personnes : 3 ladom · 2 funecap · 1 certigo · 1 dirigeant transverse, qui porte igo | +| **Paramètres posés** | TypeScript souhaitable mais non bloquant · SEO et web perf non critiques · bascule vers du JSON plutôt que des formulaires · **pas de complexité pour rien** | +| **Attention** | Les deux refontes qui motivent l'échéance **ne sont pas confirmées**. La décision doit être bonne pour l'agence même si aucune ne se fait | + +## Les axes de décision + +Les axes suffixés « bis » dépendent de celui qu'ils suivent. Entre les axes principaux, la dépendance est plus faible qu'il n'y paraît : seul l'axe 1 devait être tranché en premier. + +| | Axe | Décide | État | +|:--:|---|---|---| +| **1** | **Architecture front** — igo + `@igojs/component`, ou front à composants en assets statiques | [Architecture front de référence](adr/architecture-front-de-reference.md) | **Accepté** — front à composants en assets statiques, un artefact | +| **1 bis** | **Chaîne de build** — où vit la source du front, où tourne son build | [Chaîne de build du front](adr/chaine-de-build-du-front.md) | **Accepté** — un dépôt par projet, front en projet npm frère, build sur le serveur | +| **2** | **Technologie de composants** | [Technologie de composants front](adr/technologie-de-composants-front.md) | **Accepté — React**, le 21 août 2026 | +| **2 bis** | **Système de design** — décision produit, contrainte par l'axe 2 | [Système de design](adr/systeme-de-design.md) | **Accepté — shadcn/ui sur Tailwind**, le 21 août 2026 | +| **3** | **Socle back**, nouveaux projets uniquement | [Socle back — nouveaux projets](adr/socle-back-nouveaux-projets.md) | **Instruit** — igo-next recommandé, décision au premier greenfield | + +**Décisions de mise en œuvre front** : + +| Sujet | État | +|---|---| +| [Organisation des sources front](adr/organisation-des-sources-front.md) — structure par feature, frontière de données, routage | **Accepté** le 24 août 2026 | +| [Stratégie de test front](adr/strategie-de-test-front.md) — Vitest + Testing Library + MSW, règles de couverture | **Accepté** le 24 août 2026 | +| [Observabilité](adr/strategie-observabilite.md) — Grafana Cloud recommandé, 4 piliers (erreurs, logs, métriques, alerting) | **Proposé** le 4 sept. 2026 | + +**Décisions de mise en œuvre back** : + +| Sujet | État | +|---|---| +| [Organisation des sources back](adr/organisation-des-sources-back.md) — `@api/` pour les refontes, features pour les greenfield, DTOs | **Proposé** le 24 août 2026 | +| [Stratégie de test back](adr/strategie-de-test-back.md) — intégration avec la vraie base, Mocha existant / Vitest greenfield | **Accepté** le 24 août 2026 | + +**Plan d'exécution** : [Feuille de route du socle igo](feuille-de-route-socle-igo.md) — séquence les évolutions en 5 phases. + +**Décisions déjà prises**, indépendantes de l'axe 1 : + +- [Format d'échange front/back](adr/format-echange-front-back.md) : JSON, pas de fragments HTML. +- [Stratégie de validation](adr/strategie-de-validation.md) : validation client *et* serveur, middleware dans `@igojs/server`, schémas partagés optionnels. Découle de la précédente. + +Les ADR ne sont pas numérotés : ils portent le nom de ce sur quoi ils tranchent. Un numéro n'encoderait ici aucune chronologie utile — ils tiennent sur deux jours — et l'ordre de dépendance n'est pas linéarisable de façon stable. Chaque ADR porte sa date. + +**Candidat ADR identifié, hors périmètre de cette étude** : l'empaquetage du back — Docker contre pm2. Aucun lien avec la décision front. + +## Index des preuves + +Ce sur quoi les ADR s'appuient. Chaque ligne porte le chiffre qui a compté. + +| Source | Ce qu'elle établit | +|---|---| +| [Inventaire du besoin de réactivité](inventaire-besoin-reactivite.md) | **~85 % de la surface réactive exige un modèle d'état**, ~15 % se contentent d'une mise à jour partielle. Classement des 126 modules jQuery par motif. Inclut le test inverse sur funecap | +| [Rétrospective — coût de framework](retrospective-cout-framework.md) | **~1 écran sur 3** déclenche du travail de framework ou un contournement. Le péage se déclenche sur **l'habillage**, pas sur la logique. Coût passé : quelques jours à quelques semaines, engagés | +| [Atelier équipe du 19/08](atelier-equipe-20260819.md) | **Pondérations arrêtées par l'équipe.** Frictions quotidiennes chiffrées. Cinq besoins abandonnés faute d'outillage | +| Démo du 20/08 — POC React | Espace stagiaire de certigo porté en **moins d'une journée**, SCORM inclus, aucun process supplémentaire. Next.js et BFF écartés par l'équipe | +| Sources igo, `@igojs/component` 6.1.1 | Socle réactif **complet** : composition, listes par clé, état partagé, événements parent↔enfant. Manquent le typage, les transitions, la testabilité applicative. **8 releases du 21/05 au 17/06/2026** | +| Infra `ovh-ladom2` | nginx sert les assets (`try_files $uri @app`) ; igo ne les sert pas. `pm2 delete` → `pm2 start` ouvre une fenêtre d'indisponibilité. Six environnements | +| Diagnostic ladom *(via `research/`)* | Public mobile dominant en outre-mer, connectivité contrainte, terminaux anciens. Aucun analytics : pas de photo T0 | + +## Hors comparatif — la continuité du socle + +**Le socle est partagé par tous les clients, et sa maîtrise est inégale** : plusieurs personnes peuvent le maintenir, une seule en a la maîtrise fine. L'assistance par LLM abaisse le coût de reprise. Le risque de continuité est donc réel mais modéré, et il se traite **quelle que soit l'issue de la décision d'architecture** — en élargissant la maîtrise, ou en réduisant la surface du socle. + +Tenu hors de la matrice de décision : c'est un sujet d'organisation, pas de technologie. + +## Ce qui reste + +1. **Valider les décisions auprès de l'équipe et de la direction** — l'architecture recentre igo sur l'API et l'ORM et met `@igojs/component` en maintenance ; la technologie retenue est React, habillée par shadcn/ui sur Tailwind. +2. **Identifier la bibliothèque de bas niveau utilisée par shadcn** — ouvrir un composant du registre et lire ses imports, deux minutes. L'accessibilité héritée et la dépendance réellement prise en dépendent. +3. **Instruire la cohabitation DSFR / Tailwind** sur le projet public, seule cohabitation CSS réelle du dossier. +4. **Garde-fou de réversibilité** : première mise en œuvre sur un périmètre abandonnable, un espace isolé par sous-domaine. +5. **Mesurer ce que consomme le build du front sur le serveur** — mémoire et temps, comparés au `webpack-prod` actuel. C'est ce chiffre qui dira si le build doit passer en CI tout de suite. + +## Note de méthode + +Ce dossier a été révisé une dizaine de fois, et **chaque révision est venue d'un fait apporté par l'équipe, non du code** : certigo comme terrain réel d'`@igojs/component`, la mise à jour du framework, le contournement des transitions, l'hybride comme héritage et non comme choix, les pondérations de l'atelier, le POC, la topologie nginx réelle. + +Trois erreurs à retenir comme mise en garde pour la prochaine étude : + +- Une absence de capacité conclue en cherchant le vocabulaire d'autres frameworks dans la documentation d'igo — alors que la preuve était dans l'usage, côté certigo. +- Une préférence architecturale inférée d'un « ce n'est pas un problème ». +- Des arguments d'axe 2 — compétences en place, convergence des paradigmes — glissés à plusieurs reprises dans l'analyse d'axe 1. + +Dans les trois cas : un silence comblé par une hypothèse au lieu d'être marqué comme tel. diff --git a/docs/feuille-de-route-socle-igo.md b/docs/feuille-de-route-socle-igo.md new file mode 100644 index 00000000..76f712a2 --- /dev/null +++ b/docs/feuille-de-route-socle-igo.md @@ -0,0 +1,64 @@ +# Feuille de route du socle igo + +**Date** : 2026-08-24 +**Portée** : évolutions du framework igo — serveur, ORM, outillage de test +**Ce document séquence, il ne décide pas.** Les arbitrages sont dans les ADR. + +## Deux trajectoires, un seul socle + +| | Refonte front (projet existant) | Greenfield (projet neuf) | +|---|---|---| +| Le back | igo actuel, inchangé structurellement | igo-next — version allégée, API-first | +| Ce qui change | Ajout de routes API JSON (`app/api/`) à côté des routes dust | Pas de dust, pas de component, pas de webpack | +| Prérequis | Conventions API + error handler JSON + validation Zod | Tout ce qui précède + squelette + TypeScript + skill LLM | + +Les améliorations sont cumulatives : ce qui sert aux refontes sert aussi aux greenfield. + +## Phase 1 — Maintenant (avant la première refonte front) + +| Action | Quoi | Effort | +|---|---|---| +| **`@igojs/component` en maintenance** | Acte explicite — les 6 écrans certigo sont supportés, pas étendus | Décision | +| **Convention de routes API** | Les routes JSON vivent dans `app/api/` — pas d'alias imposé par igo | Convention | +| **Réponses d'erreur JSON** | Sous le préfixe API, tout répond en JSON au format RFC 9457 — 500, 404 et erreurs de validation | Faible | +| **Middleware de validation Zod** | Middleware global monté par igo ; le schéma est attaché au handler, rien à écrire dans les routes | Faible | +| **Déclarations TypeScript** | `.d.ts` sur l'API publique — les schémas Zod deviennent la source des types, sans impact sur les projets JS | Moyen | +| **Squelettes API** | `skel/api` (JS) et `skel/api-ts` (TypeScript), à côté de `skel/tailwind` | Moyen | + +## Phase 2 — Première refonte front + +| Action | Quoi | Effort | +|---|---|---| +| **DTOs sur les routes API** | Chaque contrôleur API sérialise via un DTO — le front ne voit jamais un modèle ORM brut | Progressif | +| **TypeScript progressif** | `allowJs: true` dans le projet applicatif, nouveaux fichiers en `.ts` | Moyen (config) | +| **Grafana Cloud + Faro** | SDK Faro côté front, OpenTelemetry côté back, alerting Teams sur les erreurs | Faible | +| **Logger structuré** | Module JSON dans `@igojs/server`, logs centralisés dans Loki | Moyen | + +## Phase 3 — Avant le premier greenfield + +| Action | Quoi | Effort | +|---|---|---| +| **igo-next publié** | Nouvelle majeure : retirer dust, component, webpack, forms de l'export | Faible | +| **Déclarations TypeScript sur l'ORM** | `.d.ts` sur l'API publique de `@igojs/db` — celles d'`@igojs/server` sont livrées en phase 1 | Moyen | +| **Support Vitest** | `dev.vitest()` — adaptateur des hooks de transaction pour Vitest | Faible | +| **Skill LLM pour igo** | Documentation des conventions, patterns ORM, utilitaires de test, erreurs courantes | Moyen | + +## Phase 4 — Premier greenfield + +| Action | Quoi | Effort | +|---|---|---| +| **Évaluer igo-next vs NestJS** | Sur le projet concret, avec les critères de l'ADR socle back | Décision | +| **Retour terrain** | L'équipe valide ou corrige les conventions après le premier projet | Retex | + +## Phase 5 — Quand le crash → mail est remplacé + +| Action | Quoi | Effort | +|---|---|---| +| **Retirer le crash → mail** | L'alerting Grafana est le seul filet, crash → mail supprimé | Faible | +| **Supprimer `process.exit(1)`** | Le error handler capture sans tuer le process — meilleur pattern | Moyen (à tester soigneusement) | + +## Ce qui n'est pas séquencé + +- **Migration Joi → Zod sur l'existant** — pas planifiée. Les nouvelles routes utilisent Zod, les anciennes gardent Joi. Les deux cohabitent. +- **Migration Mocha → Vitest sur l'existant** — pas planifiée. Mocha reste sur les projets existants, Vitest sur les greenfield. +- **Migration des templates dust** — projet par projet, au rythme des refontes. Pas de big bang. From 95863fba2a1352dd4ed18e8e2ea99a5ba67bbcaf Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Mon, 7 Sep 2026 11:00:43 +0200 Subject: [PATCH 02/80] feat(server): routes API JSON, validation Zod et erreurs RFC 9457 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les routes montées via app.api() vivent sous config.api.prefix ('/api' par défaut) et répondent en JSON quoi qu'il arrive — 500, 404, JSON malformé et erreurs de validation. Un front React ne reçoit plus de page HTML. La validation est globale : le schéma est attaché au handler (controller.create.body = dto.CreateBook), igo enveloppe les handlers au démarrage. Rien à écrire dans les routes. Les routes à corps sans schéma sont signalées au boot, sans jamais bloquer le démarrage. Express 5 impose deux détours, tous deux vérifiés : - req.query est un getter : une affectation directe échoue en silence, d'où Object.defineProperty pour propager la valeur coercée. - le chemin de montage d'un routeur n'est plus lisible, d'où app.api() qui donne le préfixe à igo par construction. La signature accepte tout schéma Standard Schema (zod, valibot, arktype). dev.agent expose res.data — res.json() sérialise via res.send(), sans quoi chaque test devrait parser res.body lui-même. Co-Authored-By: Claude Opus 5 (1M context) --- package-lock.json | 12 ++- packages/server/index.js | 4 + packages/server/package.json | 3 +- packages/server/src/api/index.js | 35 ++++++ packages/server/src/api/problem.js | 48 +++++++++ packages/server/src/api/validate.js | 100 ++++++++++++++++++ packages/server/src/config.js | 3 + packages/server/src/connect/errorhandler.js | 23 +++- packages/server/src/dev/test/agent.js | 13 +++ packages/server/src/routes.js | 11 +- packages/server/test/ApiTest.js | 85 +++++++++++++++ packages/server/test/api/problemTest.js | 59 +++++++++++ packages/server/test/api/validateTest.js | 63 +++++++++++ .../project/app/api/books/books.controller.js | 25 +++++ .../test/project/app/api/books/books.dto.js | 18 ++++ .../project/app/api/books/books.routes.js | 13 +++ packages/server/test/project/app/routes.js | 2 + 17 files changed, 511 insertions(+), 6 deletions(-) create mode 100644 packages/server/src/api/index.js create mode 100644 packages/server/src/api/problem.js create mode 100644 packages/server/src/api/validate.js create mode 100644 packages/server/test/ApiTest.js create mode 100644 packages/server/test/api/problemTest.js create mode 100644 packages/server/test/api/validateTest.js create mode 100644 packages/server/test/project/app/api/books/books.controller.js create mode 100644 packages/server/test/project/app/api/books/books.dto.js create mode 100644 packages/server/test/project/app/api/books/books.routes.js diff --git a/package-lock.json b/package-lock.json index 4293905a..79576c4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11989,6 +11989,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", @@ -12180,7 +12189,8 @@ "webpack": "^5.109.2", "webpack-cli": "^7.2.2", "webpack-dev-server": "^6.0.0", - "winston": "^3.19.0" + "winston": "^3.19.0", + "zod": "^4.5.4" }, "bin": { "igo": "cli/igo.js" diff --git a/packages/server/index.js b/packages/server/index.js index e4efef09..b39532c9 100644 --- a/packages/server/index.js +++ b/packages/server/index.js @@ -3,6 +3,8 @@ const config = require('./src/config'); const cache = require('./src/cache'); const logger = require('./src/logger'); +const problem = require('./src/api/problem'); + const server = { app: require('./src/app'), cache, @@ -13,6 +15,8 @@ const server = { logger, mailer: require('./src/mailer'), Form: require('./src/forms/Form'), + problem: problem.problem, + sendProblem: problem.send, }; module.exports = server; diff --git a/packages/server/package.json b/packages/server/package.json index b1c418f0..74f49900 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -56,7 +56,8 @@ "webpack": "^5.109.2", "webpack-cli": "^7.2.2", "webpack-dev-server": "^6.0.0", - "winston": "^3.19.0" + "winston": "^3.19.0", + "zod": "^4.5.4" }, "peerDependencies": { "autoprefixer": "^10.4.0", diff --git a/packages/server/src/api/index.js b/packages/server/src/api/index.js new file mode 100644 index 00000000..69924415 --- /dev/null +++ b/packages/server/src/api/index.js @@ -0,0 +1,35 @@ + +const config = require('../config'); +const logger = require('../logger'); +const problem = require('./problem'); +const validate = require('./validate'); + +const mounted = []; + +// app.api('/dossiers', router) mounts under config.api.prefix. igo owns the +// prefix so a project never repeats it, and knows which routers are APIs — +// Express 5 keeps a mount path only as an opaque matcher. +module.exports.init = (app) => { + mounted.length = 0; + + app.api = (path, ...handlers) => { + const mountPath = config.api.prefix + path; + mounted.push({ path: mountPath, router: handlers[handlers.length - 1] }); + app.use(mountPath, ...handlers); + return app; + }; +}; + +// Called once every route is mounted: wraps the handlers that declare a schema +// and reports the API routes that take a body without one. +module.exports.wire = () => { + const unvalidated = mounted.flatMap(({ path, router }) => + validate.apply(router).map(route => `${path}${route}`) + ); + + if (unvalidated.length) { + logger.warn(`igo: ${unvalidated.length} API route(s) without validation schema (${unvalidated.join(', ')})`); + } +}; + +module.exports.problem = problem; diff --git a/packages/server/src/api/problem.js b/packages/server/src/api/problem.js new file mode 100644 index 00000000..71067ae9 --- /dev/null +++ b/packages/server/src/api/problem.js @@ -0,0 +1,48 @@ + +const config = require('../config'); + +const CONTENT_TYPE = 'application/problem+json'; + +const TITLES = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 422: 'Unprocessable Content', + 500: 'Internal Server Error', +}; + +// A request is served as JSON when it targets the API prefix, or when the +// client asked for JSON and cannot render a dust page anyway. +const isApiRequest = (req) => { + const prefix = config.api?.prefix; + const path = req.path || req.url || ''; + if (prefix && (path === prefix || path.startsWith(prefix + '/'))) { + return true; + } + return !!req.headers?.accept?.includes('application/json'); +}; + +// RFC 9457 Problem Details +const problem = (status, { title, detail, errors, type } = {}) => { + const body = { + type: type || 'about:blank', + title: title || TITLES[status] || 'Error', + status, + }; + if (detail) { + body.detail = detail; + } + if (errors) { + body.errors = errors; + } + return body; +}; + +const send = (res, status, options) => { + res.status(status); + res.setHeader('Content-Type', CONTENT_TYPE); + return res.json(problem(status, options)); +}; + +module.exports = { isApiRequest, problem, send, CONTENT_TYPE }; diff --git a/packages/server/src/api/validate.js b/packages/server/src/api/validate.js new file mode 100644 index 00000000..86265f7b --- /dev/null +++ b/packages/server/src/api/validate.js @@ -0,0 +1,100 @@ + +const problem = require('./problem'); + +const SOURCES = ['body', 'query', 'params']; +const WITH_BODY = ['post', 'put', 'patch']; + +// Any Standard Schema implementation (zod, valibot, arktype) exposes ~standard. +const isSchema = (value) => + !!value && (typeof value === 'object' || typeof value === 'function') && '~standard' in value; + +// Schemas are attached to the handler itself: if the handler is mounted, its +// validation is too — no name to keep in sync, nothing to write in the routes. +// exports.create.body = dto.CreerDossier; +const schemasOf = (handler) => { + if (typeof handler !== 'function') { + return null; + } + let schemas = null; + for (const source of SOURCES) { + if (isSchema(handler[source])) { + schemas = schemas || {}; + schemas[source] = handler[source]; + } + } + return schemas; +}; + +// Express 5 exposes req.query through a getter: assigning to it fails silently. +const replace = (req, source, value) => { + if (source === 'query') { + Object.defineProperty(req, 'query', { value, writable: true, configurable: true }); + return; + } + req[source] = value; +}; + +const issuesOf = (result) => result.issues.map((issue) => ({ + path: (issue.path || []).map(segment => segment?.key ?? segment).join('.'), + message: issue.message, +})); + +// Wraps a handler so its schemas are applied before it runs. +const wrap = (handler, schemas) => { + const validated = async (req, res, next) => { + try { + for (const [source, schema] of Object.entries(schemas)) { + const result = await schema['~standard'].validate(req[source]); + if (result.issues) { + return problem.send(res, 400, { title: 'Validation failed', errors: issuesOf(result) }); + } + replace(req, source, result.value); + } + } catch (err) { + return next(err); + } + return handler(req, res, next); + }; + Object.assign(validated, handler); + return validated; +}; + +const eachRoute = (router, fn) => { + for (const layer of router.stack || []) { + if (layer.route) { + fn(layer.route); + } else if (layer.handle?.stack) { + eachRoute(layer.handle, fn); + } + } +}; + +// Walks an API router once at boot and wraps every handler that declares a +// schema. Returns the routes that take a body without declaring one. +module.exports.apply = (router) => { + const unvalidated = []; + + eachRoute(router, (route) => { + let validatedRoute = false; + + route.stack.forEach((layer) => { + const schemas = schemasOf(layer.handle); + if (schemas) { + layer.handle = wrap(layer.handle, schemas); + validatedRoute = true; + } + }); + + if (validatedRoute) { + return; + } + Object.keys(route.methods) + .filter(method => WITH_BODY.includes(method)) + .forEach(method => unvalidated.push(`${method.toUpperCase()} ${route.path}`)); + }); + + return unvalidated; +}; + +module.exports.schemasOf = schemasOf; +module.exports.isSchema = isSchema; diff --git a/packages/server/src/config.js b/packages/server/src/config.js index 9b405d01..516a5930 100644 --- a/packages/server/src/config.js +++ b/packages/server/src/config.js @@ -32,6 +32,9 @@ module.exports.init = function() { config.urlencoded = { limit: '10mb', extended: true }; config.json = { limit: '10mb' }; + // routes under this prefix answer in JSON, never in HTML + config.api = { prefix: '/api' }; + config.i18n = { whitelist: [ 'en', 'fr' ], preload: [ 'en', 'fr' ], diff --git a/packages/server/src/connect/errorhandler.js b/packages/server/src/connect/errorhandler.js index 34650253..03cea20e 100644 --- a/packages/server/src/connect/errorhandler.js +++ b/packages/server/src/connect/errorhandler.js @@ -20,9 +20,12 @@ * * Special cases: * - URIError (malformed URL): returns 404 - * - SyntaxError (invalid JSON): returns 500 + * - SyntaxError (invalid JSON): returns 500, or 400 on an API request * - Both are client errors and don't trigger email notifications * + * API requests (see src/api/problem.js) always get RFC 9457 JSON, never a + * rendered dust page. + * * Email throttling: * - To prevent email spam during crash loops, emails are throttled per error type * - If the same error triggers 3+ emails within 1 minute, that error is blocked for 5 minutes @@ -40,6 +43,7 @@ const os = require('os'); const config = require('../config'); const logger = require('../logger'); const mailer = require('../mailer'); +const problem = require('../api/problem'); const asyncLocalStorage = new AsyncLocalStorage(); @@ -184,10 +188,13 @@ const sendCrashEmail = (subject, body, errorKey) => { // Handle errors that occur during HTTP requests const handle = (err, req, res) => { + // an API client cannot render a dust page: it always gets JSON back + const isApi = problem.isApiRequest(req); + // Client errors - don't send emails if (err instanceof URIError) { if (!res.headersSent) { - res.status(404).render('errors/404'); + isApi ? problem.send(res, 404) : res.status(404).render('errors/404'); } return; } @@ -195,7 +202,12 @@ const handle = (err, req, res) => { // body-parser JSON only; other SyntaxErrors fall through to logging. if (err instanceof SyntaxError && err.type === 'entity.parse.failed') { if (!res.headersSent) { - res.status(500).render('errors/500'); + // malformed JSON is the client's mistake, and only an API client sends it + if (isApi) { + problem.send(res, 400, { detail: 'Malformed JSON body' }); + } else { + res.status(500).render('errors/500'); + } } return; } @@ -217,6 +229,11 @@ const handle = (err, req, res) => { sendCrashEmail(`Crash: ${err}`, formatMessage(req, err), String(err)); // Send response + if (isApi) { + // the stack is a debugging aid outside production, never a client contract + return problem.send(res, 500, config.env === 'production' ? {} : { detail: err.message }); + } + if (config.env === 'production') { return res.status(500).render('errors/500'); } diff --git a/packages/server/src/dev/test/agent.js b/packages/server/src/dev/test/agent.js index a075f470..6515b046 100644 --- a/packages/server/src/dev/test/agent.js +++ b/packages/server/src/dev/test/agent.js @@ -111,6 +111,19 @@ const mockResponse = () => { resolveResponse(res); }; + // res.json() serializes through res.send(), so API tests would each have to + // parse res.body themselves. Not named `json`: that would shadow Express's + // own res.json() and break every controller that calls it. + Object.defineProperty(res, 'data', { + get() { + try { + return JSON.parse(res.body); + } catch { + return undefined; + } + } + }); + return { res, done }; }; diff --git a/packages/server/src/routes.js b/packages/server/src/routes.js index a136a757..aa6e8d80 100644 --- a/packages/server/src/routes.js +++ b/packages/server/src/routes.js @@ -1,13 +1,22 @@ -const config = require('./config'); +const config = require('./config'); +const api = require('./api'); +const problem = require('./api/problem'); const routes = require(config.projectRoot + '/app/routes'); // module.exports.init = function(app) { + api.init(app); + routes.init(app); + api.wire(); + // 404 app.all(/.*/, (req, res) => { + if (problem.isApiRequest(req)) { + return problem.send(res, 404); + } res.status(404).render('errors/404'); }); }; diff --git a/packages/server/test/ApiTest.js b/packages/server/test/ApiTest.js new file mode 100644 index 00000000..918e05f8 --- /dev/null +++ b/packages/server/test/ApiTest.js @@ -0,0 +1,85 @@ +require('./init'); + +const assert = require('assert'); +const agent = require('@igojs/server').dev.agent; + +describe('API', function() { + + describe('validation', function() { + + it('should pass a valid body through', async () => { + const res = await agent.post('/api/books', { body: { title: 'Dune', pages: 412 } }); + assert.strictEqual(res.statusCode, 201); + assert.deepStrictEqual(res.data, { id: 1, title: 'Dune', pages: 412 }); + }); + + it('should reject an invalid body with a problem document', async () => { + const res = await agent.post('/api/books', { body: { title: 'Dune', pages: 'many' } }); + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(res.data.status, 400); + assert.strictEqual(res.data.title, 'Validation failed'); + assert.deepStrictEqual(res.data.errors.map(e => e.path), ['pages']); + }); + + it('should report every invalid field', async () => { + const res = await agent.post('/api/books', { body: {} }); + assert.strictEqual(res.statusCode, 400); + assert.deepStrictEqual(res.data.errors.map(e => e.path).sort(), ['pages', 'title']); + }); + + it('should coerce query params to their schema type', async () => { + const res = await agent.get('/api/books?page=3'); + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(res.data.page, 3); + assert.strictEqual(res.data.typeofPage, 'number'); + }); + + it('should apply query defaults when the param is absent', async () => { + const res = await agent.get('/api/books'); + assert.strictEqual(res.data.page, 1); + }); + + it('should reject an invalid query param', async () => { + const res = await agent.get('/api/books?status=burned'); + assert.strictEqual(res.statusCode, 400); + assert.deepStrictEqual(res.data.errors.map(e => e.path), ['status']); + }); + + it('should leave a route without schema untouched', async () => { + const res = await agent.post('/api/books/bulk', { body: { anything: true } }); + assert.strictEqual(res.statusCode, 200); + assert.deepStrictEqual(res.data, { ok: true }); + }); + }); + + describe('errors', function() { + + it('should answer 404 in JSON under the api prefix', async () => { + const res = await agent.get('/api/nope'); + assert.strictEqual(res.statusCode, 404); + assert.strictEqual(res.data.status, 404); + assert.strictEqual(res.data.title, 'Not Found'); + }); + + it('should still render HTML for a non-api 404', async () => { + const res = await agent.get('/nope'); + assert.strictEqual(res.statusCode, 404); + assert.strictEqual(res.data, undefined); + }); + + it('should answer JSON when the client asks for it', async () => { + const res = await agent.get('/nope', { headers: { accept: 'application/json' } }); + assert.strictEqual(res.statusCode, 404); + assert.strictEqual(res.data.status, 404); + }); + }); + + describe('routing', function() { + + it('should mount the router under the api prefix', async () => { + const res = await agent.get('/api/books/7'); + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(res.data.id, 7); + }); + }); +}); diff --git a/packages/server/test/api/problemTest.js b/packages/server/test/api/problemTest.js new file mode 100644 index 00000000..13cd6743 --- /dev/null +++ b/packages/server/test/api/problemTest.js @@ -0,0 +1,59 @@ +require('../init'); + +const assert = require('assert'); +const config = require('@igojs/server').config; +const problem = require('@igojs/server/src/api/problem'); + +describe('api/problem', function() { + + describe('isApiRequest', function() { + + it('should recognize the api prefix', () => { + assert(problem.isApiRequest({ path: '/api/books', headers: {} })); + assert(problem.isApiRequest({ path: '/api', headers: {} })); + }); + + it('should not mistake a path that merely starts with the prefix', () => { + assert(!problem.isApiRequest({ path: '/apidocs', headers: {} })); + }); + + it('should recognize a client asking for json', () => { + assert(problem.isApiRequest({ path: '/books', headers: { accept: 'application/json' } })); + }); + + it('should leave a regular page request alone', () => { + assert(!problem.isApiRequest({ path: '/books', headers: { accept: 'text/html' } })); + assert(!problem.isApiRequest({ path: '/books', headers: {} })); + }); + + it('should follow a custom prefix', () => { + const initial = config.api.prefix; + config.api.prefix = '/v1'; + try { + assert(problem.isApiRequest({ path: '/v1/books', headers: {} })); + assert(!problem.isApiRequest({ path: '/api/books', headers: {} })); + } finally { + config.api.prefix = initial; + } + }); + }); + + describe('problem', function() { + + it('should build an RFC 9457 document', () => { + assert.deepStrictEqual(problem.problem(404), { + type: 'about:blank', title: 'Not Found', status: 404 + }); + }); + + it('should carry detail and errors when given', () => { + const doc = problem.problem(400, { title: 'Validation failed', detail: 'nope', errors: [{ path: 'a' }] }); + assert.strictEqual(doc.detail, 'nope'); + assert.deepStrictEqual(doc.errors, [{ path: 'a' }]); + }); + + it('should fall back to a generic title', () => { + assert.strictEqual(problem.problem(418).title, 'Error'); + }); + }); +}); diff --git a/packages/server/test/api/validateTest.js b/packages/server/test/api/validateTest.js new file mode 100644 index 00000000..11c41a38 --- /dev/null +++ b/packages/server/test/api/validateTest.js @@ -0,0 +1,63 @@ +require('../init'); + +const assert = require('assert'); +const express = require('express'); +const { z } = require('zod'); + +const validate = require('@igojs/server/src/api/validate'); + +const handler = (schemas = {}) => { + const fn = (req, res) => res.end(); + Object.assign(fn, schemas); + return fn; +}; + +describe('api/validate', function() { + + describe('schemasOf', function() { + + it('should find the schemas attached to a handler', () => { + const schema = z.object({ a: z.string() }); + const schemas = validate.schemasOf(handler({ body: schema, query: schema })); + assert.deepStrictEqual(Object.keys(schemas).sort(), ['body', 'query']); + }); + + it('should ignore a handler without schemas', () => { + assert.strictEqual(validate.schemasOf(handler()), null); + }); + + it('should ignore a property that is not a schema', () => { + assert.strictEqual(validate.schemasOf(handler({ body: { a: 1 } })), null); + }); + }); + + describe('apply', function() { + + it('should report body routes without a schema', () => { + const router = express.Router(); + router.post('/bulk', handler()); + assert.deepStrictEqual(validate.apply(router), ['POST /bulk']); + }); + + it('should not report a route that declares a schema', () => { + const router = express.Router(); + router.post('/', handler({ body: z.object({ a: z.string() }) })); + assert.deepStrictEqual(validate.apply(router), []); + }); + + it('should not report routes that carry no body', () => { + const router = express.Router(); + router.get('/', handler()); + router.delete('/:id', handler()); + assert.deepStrictEqual(validate.apply(router), []); + }); + + it('should walk nested routers', () => { + const nested = express.Router(); + nested.post('/deep', handler()); + const router = express.Router(); + router.use('/nested', nested); + assert.deepStrictEqual(validate.apply(router), ['POST /deep']); + }); + }); +}); diff --git a/packages/server/test/project/app/api/books/books.controller.js b/packages/server/test/project/app/api/books/books.controller.js new file mode 100644 index 00000000..4d313465 --- /dev/null +++ b/packages/server/test/project/app/api/books/books.controller.js @@ -0,0 +1,25 @@ + +const dto = require('./books.dto'); + +exports.index = (req, res) => { + res.json({ page: req.query.page, typeofPage: typeof req.query.page, status: req.query.status }); +}; +exports.index.query = dto.ListBooks; + +exports.create = (req, res) => { + res.status(201).json(dto.serialize({ id: 1, ...req.body })); +}; +exports.create.body = dto.CreateBook; + +exports.show = (req, res) => { + res.json(dto.serialize({ id: Number(req.params.id), title: 'Dune', pages: 412 })); +}; + +exports.boom = () => { + throw new Error('boom in api'); +}; + +// no schema: the boot-time warning must report it +exports.bulk = (req, res) => { + res.json({ ok: true }); +}; diff --git a/packages/server/test/project/app/api/books/books.dto.js b/packages/server/test/project/app/api/books/books.dto.js new file mode 100644 index 00000000..c8b3c5ff --- /dev/null +++ b/packages/server/test/project/app/api/books/books.dto.js @@ -0,0 +1,18 @@ + +const { z } = require('zod'); + +exports.CreateBook = z.object({ + title: z.string().min(1), + pages: z.number().int().positive(), +}); + +exports.ListBooks = z.object({ + page: z.coerce.number().int().min(1).default(1), + status: z.enum(['draft', 'published']).optional(), +}); + +exports.serialize = (book) => ({ + id: book.id, + title: book.title, + pages: book.pages, +}); diff --git a/packages/server/test/project/app/api/books/books.routes.js b/packages/server/test/project/app/api/books/books.routes.js new file mode 100644 index 00000000..3a28bed7 --- /dev/null +++ b/packages/server/test/project/app/api/books/books.routes.js @@ -0,0 +1,13 @@ + +const express = require('express'); +const controller = require('./books.controller'); + +const router = express.Router(); + +router.get('/', controller.index); +router.post('/', controller.create); +router.post('/bulk', controller.bulk); +router.get('/boom', controller.boom); +router.get('/:id', controller.show); + +module.exports = router; diff --git a/packages/server/test/project/app/routes.js b/packages/server/test/project/app/routes.js index dc9a801a..76f9cded 100644 --- a/packages/server/test/project/app/routes.js +++ b/packages/server/test/project/app/routes.js @@ -98,4 +98,6 @@ module.exports.init = function(app) { res.json({ flash: res.locals.flash }); }); + app.api('/books', require('./api/books/books.routes')); + }; From bdf945b316a6cc8cd09b31d8eb85f70a22d29588 Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Mon, 7 Sep 2026 11:03:13 +0200 Subject: [PATCH 03/80] =?UTF-8?q?feat(server):=20d=C3=A9clarations=20TypeS?= =?UTF-8?q?cript=20sur=20l'API=20publique?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les schémas Zod deviennent la source des types : ApiHandler<{ body: typeof CreateBook }> donne req.body typé, coercitions comprises, sans redéclarer la forme. app.api() est ajouté à l'interface Express.Application. Les projets JavaScript ne voient aucune différence — un .d.ts n'est jamais chargé à l'exécution, et @standard-schema/spec est une dépendance de types. test/types/expect-errors.ts épingle les erreurs qui doivent se déclencher : si l'inférence retombe sur `any`, tsc signale les @ts-expect-error inutilisés et typesTest échoue. Sans ça, une déclaration cassée passerait inaperçue. Co-Authored-By: Claude Opus 5 (1M context) --- package-lock.json | 111 ++++++++------------ package.json | 3 + packages/server/index.d.ts | 101 ++++++++++++++++++ packages/server/package.json | 1 + packages/server/src/api/handler.d.ts | 38 +++++++ packages/server/src/api/problem.d.ts | 31 ++++++ packages/server/test/api/typesTest.js | 28 +++++ packages/server/test/types/expect-errors.ts | 16 +++ packages/server/test/types/tsconfig.json | 12 +++ packages/server/test/types/valid.ts | 26 +++++ 10 files changed, 298 insertions(+), 69 deletions(-) create mode 100644 packages/server/index.d.ts create mode 100644 packages/server/src/api/handler.d.ts create mode 100644 packages/server/src/api/problem.d.ts create mode 100644 packages/server/test/api/typesTest.js create mode 100644 packages/server/test/types/expect-errors.ts create mode 100644 packages/server/test/types/tsconfig.json create mode 100644 packages/server/test/types/valid.ts diff --git a/package-lock.json b/package-lock.json index 79576c4f..6e4d885c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,14 @@ ], "devDependencies": { "@eslint/js": "^10.0.1", + "@types/express": "^5.0.6", + "@types/node": "^26.4.1", "eslint": "^10.9.0", "globals": "^17.11.0", "husky": "^9.1.7", "lint-staged": "^17.3.0", "mocha": "^11.8.0", + "typescript": "^5.9.3", "vitepress": "^1.6.4" } }, @@ -2652,6 +2655,12 @@ "text-hex": "1.0.x" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -2704,21 +2713,20 @@ "license": "MIT" }, "node_modules/@types/express": { - "version": "4.17.25", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", - "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "license": "MIT", "dependencies": { "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "^1" + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" } }, "node_modules/@types/express-serve-static-core": { - "version": "4.19.8", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", - "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", "license": "MIT", "dependencies": { "@types/node": "*", @@ -2808,19 +2816,13 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "license": "MIT" - }, "node_modules/@types/node": { - "version": "25.9.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", - "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "version": "26.4.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz", + "integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==", "license": "MIT", "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" + "undici-types": "~8.3.0" } }, "node_modules/@types/qs": { @@ -2854,23 +2856,12 @@ } }, "node_modules/@types/serve-static": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", - "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", "license": "MIT", "dependencies": { "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "<1" - } - }, - "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.6", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", - "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "license": "MIT", - "dependencies": { - "@types/mime": "^1", "@types/node": "*" } }, @@ -11055,6 +11046,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/uid-safe": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", @@ -11075,9 +11080,9 @@ "peer": true }, "node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "license": "MIT" }, "node_modules/unist-util-is": { @@ -12161,6 +12166,7 @@ "dependencies": { "@igojs/db": "^6.2.4", "@igojs/dust": "^6.2.4", + "@standard-schema/spec": "^1.1.0", "assets-webpack-plugin": "^7.1.1", "compression": "1.8.1", "concurrently": "^10.0.5", @@ -12206,39 +12212,6 @@ "sass": "^1.0.0" } }, - "packages/server/node_modules/@types/express": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", - "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^5.0.0", - "@types/serve-static": "^2" - } - }, - "packages/server/node_modules/@types/express-serve-static-core": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", - "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "packages/server/node_modules/@types/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", - "license": "MIT", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*" - } - }, "packages/server/node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", diff --git a/package.json b/package.json index 497ad783..908640bb 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,14 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/express": "^5.0.6", + "@types/node": "^26.4.1", "eslint": "^10.9.0", "globals": "^17.11.0", "husky": "^9.1.7", "lint-staged": "^17.3.0", "mocha": "^11.8.0", + "typescript": "^5.9.3", "vitepress": "^1.6.4" }, "lint-staged": { diff --git a/packages/server/index.d.ts b/packages/server/index.d.ts new file mode 100644 index 00000000..ac86b6f3 --- /dev/null +++ b/packages/server/index.d.ts @@ -0,0 +1,101 @@ +import type { Express, RequestHandler, Router } from 'express'; + +import type { ProblemDocument, ProblemOptions } from './src/api/problem'; + +export type { ApiHandler } from './src/api/handler'; +export type { ProblemDocument, ProblemError, ProblemOptions } from './src/api/problem'; + +declare global { + namespace Express { + interface Application { + /** + * Mounts an API router under `config.api.prefix` ('/api' by default). + * + * app.api('/books', require('./api/books/books.routes')); // -> /api/books + * + * Routers mounted this way answer in JSON on every error, and their + * handlers' schemas are applied automatically. + */ + api(path: string, ...handlers: Array): Application; + } + } +} + +export interface ApiConfig { + prefix: string; +} + +export interface Config { + env: string; + httpport: number | string; + projectRoot: string; + api: ApiConfig; + databases: string[]; + [key: string]: unknown; +} + +export declare const app: Express & { + configure(): Promise; + run(configured?: () => void, started?: () => void): Promise; +}; + +export declare const config: Config; + +export declare function problem(status: number, options?: ProblemOptions): ProblemDocument; +export declare function sendProblem(res: import('express').Response, status: number, options?: ProblemOptions): import('express').Response; + +export interface TestResponse { + statusCode: number; + body: string; + headers: Record; + redirectUrl?: string; + /** The response body parsed as JSON, or undefined when it is not JSON. */ + readonly data: any; +} + +export interface TestRequestOptions { + body?: unknown; + query?: Record; + params?: Record; + headers?: Record; + cookies?: Record; + session?: Record; + hostname?: string; +} + +export declare const dev: { + test(): void; + agent: { + send(url: string, options?: TestRequestOptions & { method?: string }): Promise; + get(url: string, options?: TestRequestOptions): Promise; + post(url: string, options?: TestRequestOptions): Promise; + put(url: string, options?: TestRequestOptions): Promise; + patch(url: string, options?: TestRequestOptions): Promise; + delete(url: string, options?: TestRequestOptions): Promise; + }; + webpackConfig: unknown; +}; + +export declare const cache: { + get(namespace: string, key: string): Promise; + put(namespace: string, key: string, value: unknown, ttl?: number): Promise; + del(namespace: string, key: string): Promise; + fetch(namespace: string, key: string, fn: () => Promise, ttl?: number): Promise; + incr(namespace: string, key: string): Promise; + flushall(): Promise; +}; + +export declare const logger: { + error(...args: unknown[]): void; + warn(...args: unknown[]): void; + info(...args: unknown[]): void; + debug(...args: unknown[]): void; +}; + +export declare const mailer: { + send(template: string, options: Record): Promise; +}; + +export { default as express } from 'express'; +export declare const i18next: typeof import('i18next').default; +export declare const Form: any; diff --git a/packages/server/package.json b/packages/server/package.json index 74f49900..4b7de37f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -28,6 +28,7 @@ "dependencies": { "@igojs/db": "^6.2.4", "@igojs/dust": "^6.2.4", + "@standard-schema/spec": "^1.1.0", "assets-webpack-plugin": "^7.1.1", "compression": "1.8.1", "concurrently": "^10.0.5", diff --git a/packages/server/src/api/handler.d.ts b/packages/server/src/api/handler.d.ts new file mode 100644 index 00000000..bc9965c7 --- /dev/null +++ b/packages/server/src/api/handler.d.ts @@ -0,0 +1,38 @@ +import type { Request, Response, NextFunction } from 'express'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; + +type Infer = S extends StandardSchemaV1 ? StandardSchemaV1.InferOutput : never; + +/** + * An API handler whose request is shaped by the schemas attached to it. + * + * const create: ApiHandler<{ body: typeof CreateBook }> = (req, res) => { + * req.body.pages; // number, coerced and validated + * }; + * create.body = CreateBook; + * + * The schemas are read by igo at boot: declaring them here only mirrors, for + * the type checker, what the runtime already does. + */ +export interface ApiHandler< + Schemas extends { + body?: StandardSchemaV1; + query?: StandardSchemaV1; + params?: StandardSchemaV1; + } = {} +> { + ( + req: Request< + Schemas['params'] extends StandardSchemaV1 ? Infer : Record, + unknown, + Schemas['body'] extends StandardSchemaV1 ? Infer : unknown, + Schemas['query'] extends StandardSchemaV1 ? Infer : Record + >, + res: Response, + next: NextFunction + ): void | Promise; + + body?: Schemas['body']; + query?: Schemas['query']; + params?: Schemas['params']; +} diff --git a/packages/server/src/api/problem.d.ts b/packages/server/src/api/problem.d.ts new file mode 100644 index 00000000..0a0d6681 --- /dev/null +++ b/packages/server/src/api/problem.d.ts @@ -0,0 +1,31 @@ +import type { Request, Response } from 'express'; + +/** RFC 9457 Problem Details document. */ +export interface ProblemDocument { + type: string; + title: string; + status: number; + detail?: string; + errors?: ProblemError[]; +} + +export interface ProblemError { + path: string; + message: string; +} + +export interface ProblemOptions { + type?: string; + title?: string; + detail?: string; + errors?: ProblemError[]; +} + +export declare const CONTENT_TYPE: 'application/problem+json'; + +/** True when the request targets the API prefix, or asks for JSON. */ +export declare function isApiRequest(req: Pick): boolean; + +export declare function problem(status: number, options?: ProblemOptions): ProblemDocument; + +export declare function send(res: Response, status: number, options?: ProblemOptions): Response; diff --git a/packages/server/test/api/typesTest.js b/packages/server/test/api/typesTest.js new file mode 100644 index 00000000..79e7f92e --- /dev/null +++ b/packages/server/test/api/typesTest.js @@ -0,0 +1,28 @@ +const assert = require('assert'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const PROJECT = path.join(__dirname, '..', 'types', 'tsconfig.json'); + +// The .d.ts files are only exercised by a type checker: without this, a broken +// declaration would ship unnoticed. test/types/expect-errors.ts pins the +// errors that must fire — if inference degrades to `any`, tsc reports the +// @ts-expect-error directives as unused and this fails. +describe('api/types', function() { + this.timeout(60000); + + it('should typecheck the declarations against a TypeScript consumer', () => { + let tsc; + try { + tsc = require.resolve('typescript/bin/tsc'); + } catch { + return this.skip(); + } + + try { + execFileSync(process.execPath, [tsc, '-p', PROJECT], { encoding: 'utf8', stdio: 'pipe' }); + } catch (err) { + assert.fail(`tsc reported errors:\n${err.stdout || err.message}`); + } + }); +}); diff --git a/packages/server/test/types/expect-errors.ts b/packages/server/test/types/expect-errors.ts new file mode 100644 index 00000000..d53282bb --- /dev/null +++ b/packages/server/test/types/expect-errors.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; +import type { ApiHandler } from '../../index'; + +const CreateBook = z.object({ title: z.string(), pages: z.number() }); + +// Each line below must be rejected by tsc: typesTest.js asserts on the codes. +export const create: ApiHandler<{ body: typeof CreateBook }> = (req, res) => { + // @ts-expect-error pages is a number, not a string + const wrongType: string = req.body.pages; + // @ts-expect-error subtitle is not part of the schema + const missing = req.body.subtitle; + // @ts-expect-error title is a string, not a number + req.body.title = 42; + res.json({ wrongType, missing }); +}; +create.body = CreateBook; diff --git a/packages/server/test/types/tsconfig.json b/packages/server/test/types/tsconfig.json new file mode 100644 index 00000000..19fc208e --- /dev/null +++ b/packages/server/test/types/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["*.ts"] +} diff --git a/packages/server/test/types/valid.ts b/packages/server/test/types/valid.ts new file mode 100644 index 00000000..bd854f9a --- /dev/null +++ b/packages/server/test/types/valid.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; +import type { ApiHandler } from '../../index'; + +const CreateBook = z.object({ + title: z.string().min(1), + pages: z.number().int().positive(), +}); + +const ListBooks = z.object({ + page: z.coerce.number().int().min(1).default(1), + status: z.enum(['draft', 'published']).optional(), +}); + +export const create: ApiHandler<{ body: typeof CreateBook }> = (req, res) => { + const title: string = req.body.title; + const pages: number = req.body.pages; + res.status(201).json({ title, pages }); +}; +create.body = CreateBook; + +export const index: ApiHandler<{ query: typeof ListBooks }> = (req, res) => { + const page: number = req.query.page; + const status: 'draft' | 'published' | undefined = req.query.status; + res.json({ page, status }); +}; +index.query = ListBooks; From 9ffd02e2b26e7fa4028c3905e3654b6994c4c902 Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Mon, 7 Sep 2026 11:12:54 +0200 Subject: [PATCH 04/80] feat(server): squelettes api et api-ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit igo create --skel=api|api-ts, à côté de tailwind resté par défaut. Les deux livrent le même domaine d'exemple — modèle ORM, DTO, contrôleur, routes, migration et tests d'intégration couvrant les quatre cas de l'ADR (nominal, validation, 404, champs exposés). Ni dust, ni webpack, ni scss. api-ts démontre que les schémas Zod suffisent à typer req.body et req.query : aucune interface n'est maintenue en double. Il tourne sous tsx en dev et compile vers dist/ ; le typecheck remplace eslint, qui ne sait pas lire du TS sans typescript-eslint. Les deux squelettes ont été générés, installés et exécutés contre une vraie base : 7 tests passent de chaque côté, et le build TS sert l'API. Co-Authored-By: Claude Opus 5 (1M context) --- packages/server/cli/create.js | 11 ++- packages/server/index.d.ts | 22 ++++- packages/server/skel/api-ts/README.md | 78 +++++++++++++++ packages/server/skel/api-ts/_.gitignore | 8 ++ packages/server/skel/api-ts/_.mocharc.json | 12 +++ packages/server/skel/api-ts/app.ts | 4 + .../api-ts/app/api/books/books.controller.ts | 61 ++++++++++++ .../skel/api-ts/app/api/books/books.dto.ts | 41 ++++++++ .../skel/api-ts/app/api/books/books.routes.ts | 14 +++ packages/server/skel/api-ts/app/config.ts | 6 ++ .../server/skel/api-ts/app/models/Book.ts | 28 ++++++ packages/server/skel/api-ts/app/routes.ts | 17 ++++ .../skel/api-ts/locales/en/translation.json | 3 + packages/server/skel/api-ts/package.json | 26 +++++ .../server/skel/api-ts/sql/20260101-books.sql | 9 ++ .../server/skel/api-ts/test/api/BooksTest.ts | 95 +++++++++++++++++++ packages/server/skel/api-ts/tsconfig.json | 17 ++++ packages/server/skel/api/README.md | 63 ++++++++++++ packages/server/skel/api/_.gitignore | 6 ++ packages/server/skel/api/_.mocharc.json | 9 ++ packages/server/skel/api/app.js | 5 + .../api/app/api/books/books.controller.js | 59 ++++++++++++ .../skel/api/app/api/books/books.dto.js | 40 ++++++++ .../skel/api/app/api/books/books.routes.js | 14 +++ packages/server/skel/api/app/config.js | 5 + packages/server/skel/api/app/models/Book.js | 19 ++++ packages/server/skel/api/app/routes.js | 13 +++ packages/server/skel/api/eslint.config.js | 35 +++++++ .../skel/api/locales/en/translation.json | 3 + packages/server/skel/api/nodemon.json | 11 +++ packages/server/skel/api/package.json | 20 ++++ .../server/skel/api/sql/20260101-books.sql | 9 ++ .../server/skel/api/test/api/BooksTest.js | 92 ++++++++++++++++++ packages/server/test/CreateTest.js | 57 +++++++++++ 34 files changed, 905 insertions(+), 7 deletions(-) create mode 100644 packages/server/skel/api-ts/README.md create mode 100644 packages/server/skel/api-ts/_.gitignore create mode 100644 packages/server/skel/api-ts/_.mocharc.json create mode 100644 packages/server/skel/api-ts/app.ts create mode 100644 packages/server/skel/api-ts/app/api/books/books.controller.ts create mode 100644 packages/server/skel/api-ts/app/api/books/books.dto.ts create mode 100644 packages/server/skel/api-ts/app/api/books/books.routes.ts create mode 100644 packages/server/skel/api-ts/app/config.ts create mode 100644 packages/server/skel/api-ts/app/models/Book.ts create mode 100644 packages/server/skel/api-ts/app/routes.ts create mode 100644 packages/server/skel/api-ts/locales/en/translation.json create mode 100644 packages/server/skel/api-ts/package.json create mode 100644 packages/server/skel/api-ts/sql/20260101-books.sql create mode 100644 packages/server/skel/api-ts/test/api/BooksTest.ts create mode 100644 packages/server/skel/api-ts/tsconfig.json create mode 100644 packages/server/skel/api/README.md create mode 100644 packages/server/skel/api/_.gitignore create mode 100644 packages/server/skel/api/_.mocharc.json create mode 100644 packages/server/skel/api/app.js create mode 100644 packages/server/skel/api/app/api/books/books.controller.js create mode 100644 packages/server/skel/api/app/api/books/books.dto.js create mode 100644 packages/server/skel/api/app/api/books/books.routes.js create mode 100644 packages/server/skel/api/app/config.js create mode 100644 packages/server/skel/api/app/models/Book.js create mode 100644 packages/server/skel/api/app/routes.js create mode 100644 packages/server/skel/api/eslint.config.js create mode 100644 packages/server/skel/api/locales/en/translation.json create mode 100644 packages/server/skel/api/nodemon.json create mode 100644 packages/server/skel/api/package.json create mode 100644 packages/server/skel/api/sql/20260101-books.sql create mode 100644 packages/server/skel/api/test/api/BooksTest.js create mode 100644 packages/server/test/CreateTest.js diff --git a/packages/server/cli/create.js b/packages/server/cli/create.js index 89657dbb..244a8160 100644 --- a/packages/server/cli/create.js +++ b/packages/server/cli/create.js @@ -55,15 +55,22 @@ const replaceInDirectory = async (dir, replacements) => { }; // igo create +const SKELETONS = ['tailwind', 'api', 'api-ts']; + module.exports = async function (argv) { const args = argv._; if (args.length !== 2) { - console.warn('Usage: igo create '); + console.warn('Usage: igo create [--skel=' + SKELETONS.join('|') + ']'); + process.exit(1); + } + + const model = argv.skel || 'tailwind'; + if (!SKELETONS.includes(model)) { + console.warn(`Unknown skeleton '${model}'. Available: ${SKELETONS.join(', ')}.`); process.exit(1); } const directory = './' + args[1]; - const model = 'tailwind'; await fs.mkdir(directory); diff --git a/packages/server/index.d.ts b/packages/server/index.d.ts index ac86b6f3..07bbd0b8 100644 --- a/packages/server/index.d.ts +++ b/packages/server/index.d.ts @@ -25,12 +25,24 @@ export interface ApiConfig { prefix: string; } +export interface CookieSessionConfig { + name: string; + keys: string[]; + maxAge: number; + sameSite?: 'Lax' | 'Strict' | 'None' | boolean; + [key: string]: unknown; +} + export interface Config { - env: string; - httpport: number | string; - projectRoot: string; - api: ApiConfig; - databases: string[]; + env: string; + httpport: number | string; + projectRoot: string; + api: ApiConfig; + databases: string[]; + cookieSecret: string; + cookieSession: CookieSessionConfig; + mailcrashto?: string | string[]; + loglevel: string; [key: string]: unknown; } diff --git a/packages/server/skel/api-ts/README.md b/packages/server/skel/api-ts/README.md new file mode 100644 index 00000000..57205cfd --- /dev/null +++ b/packages/server/skel/api-ts/README.md @@ -0,0 +1,78 @@ +# {project.name} + +API JSON TypeScript sur [igo](https://github.com/igocreate/igo). + +## Démarrer + +```bash +npm install +npm start # tsx watch, rechargement à chaud +npm test # mocha via tsx, base de test recréée à chaque run +npm run typecheck # tsc --noEmit +npm run build # compile vers dist/ +npm run serve # lance le build +``` + +## Structure + +``` +app/ + api/ + books/ ← un dossier par domaine + books.routes.ts ← les endpoints + books.controller.ts ← thin : service/modèle → DTO + books.dto.ts ← schémas entrants + sérialisation sortante + models/ ← modèles ORM + config.ts + routes.ts ← montage des routes +sql/ ← migrations +``` + +## Conventions + +**Les routes API se montent avec `app.api()`** — le préfixe (`/api`) vient de +`config.api.prefix`, jamais répété dans le code : + +```ts +app.api('/books', books); // -> /api/books +``` + +**La validation est automatique.** Le schéma s'attache au handler, igo +l'applique avant que le contrôleur ne tourne : + +```ts +export const create: ApiHandler<{ body: typeof dto.CreateBook }> = async (req, res) => { + req.body.pages; // number — typé depuis le schéma +}; +create.body = dto.CreateBook; +``` + +`req.body` et `req.query` contiennent la valeur validée — coercitions et +valeurs par défaut comprises. Plus de `parseInt(req.query.page)`. + +**Les erreurs sont en JSON**, au format [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) : + +```json +{ + "type": "about:blank", + "title": "Validation failed", + "status": 400, + "errors": [{ "path": "pages", "message": "expected number, received string" }] +} +``` + +Tout ce qui vit sous `/api` répond en JSON — y compris les 404 et les 500. + +**Le DTO est la barrière.** Le front ne voit jamais un modèle ORM brut : +ajouter une colonne au modèle n'expose rien tant qu'elle n'est pas nommée dans +`serialize()`. + +## TypeScript + +**Les schémas Zod sont la source des types.** `ApiHandler<{ body: typeof +CreateBook }>` donne à `req.body` la forme validée : aucune interface à +maintenir en double, et un champ absent du schéma est une erreur de +compilation. + +igo reste du JavaScript — les types viennent de fichiers `.d.ts` livrés avec +le paquet. Rien n'est compilé côté framework. diff --git a/packages/server/skel/api-ts/_.gitignore b/packages/server/skel/api-ts/_.gitignore new file mode 100644 index 00000000..45b5ab27 --- /dev/null +++ b/packages/server/skel/api-ts/_.gitignore @@ -0,0 +1,8 @@ + +node_modules + +.env +.DS_Store +*.log + +dist diff --git a/packages/server/skel/api-ts/_.mocharc.json b/packages/server/skel/api-ts/_.mocharc.json new file mode 100644 index 00000000..f9317747 --- /dev/null +++ b/packages/server/skel/api-ts/_.mocharc.json @@ -0,0 +1,12 @@ +{ + "recursive": true, + "extension": [ + "ts" + ], + "require": [ + "tsx" + ], + "reporter": "dot", + "exit": true, + "timeout": 10000 +} diff --git a/packages/server/skel/api-ts/app.ts b/packages/server/skel/api-ts/app.ts new file mode 100644 index 00000000..159e50f0 --- /dev/null +++ b/packages/server/skel/api-ts/app.ts @@ -0,0 +1,4 @@ + +import { app } from '@igojs/server'; + +app.run(); diff --git a/packages/server/skel/api-ts/app/api/books/books.controller.ts b/packages/server/skel/api-ts/app/api/books/books.controller.ts new file mode 100644 index 00000000..c2fe0e44 --- /dev/null +++ b/packages/server/skel/api-ts/app/api/books/books.controller.ts @@ -0,0 +1,61 @@ + +import { sendProblem } from '@igojs/server'; +import type { ApiHandler } from '@igojs/server'; + +import Book from '../../models/Book'; +import * as dto from './books.dto'; + +// The schemas below give req.body and req.query their types: no shape is +// declared twice, and a field that is not in the schema is a compile error. +export const index: ApiHandler<{ query: typeof dto.ListBooks }> = async (req, res) => { + const { page, limit, published } = req.query; + + let query = Book.order('created_at desc'); + if (published !== undefined) { + query = query.where({ published }); + } + + const { rows, pagination } = await query.page(page, limit).list(); + res.json({ + books: rows.map(dto.serialize), + page: dto.serializePage(pagination), + }); +}; +index.query = dto.ListBooks; + +// +export const show: ApiHandler = async (req, res) => { + const book = await Book.find(req.params.id); + if (!book) { + return void sendProblem(res, 404, { detail: 'Book not found' }); + } + res.json(dto.serialize(book)); +}; + +// +export const create: ApiHandler<{ body: typeof dto.CreateBook }> = async (req, res) => { + const book = await Book.create(req.body); + res.status(201).json(dto.serialize(book)); +}; +create.body = dto.CreateBook; + +// +export const update: ApiHandler<{ body: typeof dto.UpdateBook }> = async (req, res) => { + const book = await Book.find(req.params.id); + if (!book) { + return void sendProblem(res, 404, { detail: 'Book not found' }); + } + await book.update(req.body); + res.json(dto.serialize(book)); +}; +update.body = dto.UpdateBook; + +// +export const destroy: ApiHandler = async (req, res) => { + const book = await Book.find(req.params.id); + if (!book) { + return void sendProblem(res, 404, { detail: 'Book not found' }); + } + await book.delete(); + res.status(204).end(); +}; diff --git a/packages/server/skel/api-ts/app/api/books/books.dto.ts b/packages/server/skel/api-ts/app/api/books/books.dto.ts new file mode 100644 index 00000000..efac875b --- /dev/null +++ b/packages/server/skel/api-ts/app/api/books/books.dto.ts @@ -0,0 +1,41 @@ + +import { z } from 'zod'; +import type { BookRow } from '../../models/Book'; + +// Incoming: what the API accepts. Coercion and defaults are applied before the +// controller runs, so req.body and req.query already hold the right types. +export const CreateBook = z.object({ + title: z.string().min(1).max(255), + author: z.string().min(1).max(255), + pages: z.number().int().positive(), + published: z.boolean().default(false), +}); + +export const UpdateBook = CreateBook.partial(); + +export const ListBooks = z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(25), + // z.coerce.boolean() would turn 'false' into true: URL flags need this form + published: z.enum(['true', 'false']).transform(v => v === 'true').optional(), +}); + +// Outgoing: the barrier between the ORM model and the API. Adding a column to +// the model exposes nothing until it is named here. +export const serialize = (book: BookRow) => ({ + id: book.id, + title: book.title, + author: book.author, + pages: book.pages, + published: book.published, + createdAt: book.created_at, +}); + +// The ORM pagination also carries `links`, meant for rendering page numbers in +// a template: an API client builds its own navigation. +export const serializePage = (pagination: { page: number; nb: number; nb_pages: number; count: number }) => ({ + page: pagination.page, + perPage: pagination.nb, + pages: pagination.nb_pages, + total: pagination.count, +}); diff --git a/packages/server/skel/api-ts/app/api/books/books.routes.ts b/packages/server/skel/api-ts/app/api/books/books.routes.ts new file mode 100644 index 00000000..a6ff1f37 --- /dev/null +++ b/packages/server/skel/api-ts/app/api/books/books.routes.ts @@ -0,0 +1,14 @@ + +import { express } from '@igojs/server'; + +import * as controller from './books.controller'; + +const router = express.Router(); + +router.get('/', controller.index); +router.post('/', controller.create); +router.get('/:id', controller.show); +router.put('/:id', controller.update); +router.delete('/:id', controller.destroy); + +export default router; diff --git a/packages/server/skel/api-ts/app/config.ts b/packages/server/skel/api-ts/app/config.ts new file mode 100644 index 00000000..30ba2f62 --- /dev/null +++ b/packages/server/skel/api-ts/app/config.ts @@ -0,0 +1,6 @@ +import type { Config } from '@igojs/server'; + +export const init = (config: Config) => { + config.cookieSecret = '{RANDOM_1}'; + config.cookieSession.keys = [ '{RANDOM_2}' ]; +}; diff --git a/packages/server/skel/api-ts/app/models/Book.ts b/packages/server/skel/api-ts/app/models/Book.ts new file mode 100644 index 00000000..047a0a82 --- /dev/null +++ b/packages/server/skel/api-ts/app/models/Book.ts @@ -0,0 +1,28 @@ + +const { Model } = require('@igojs/db'); + +const schema = { + table: 'books', + columns: [ + 'id', + 'title', + 'author', + 'pages', + { name: 'published', type: 'boolean' }, + 'created_at', + ], +}; + +export interface BookRow { + id: number; + title: string; + author: string; + pages: number; + published: boolean; + created_at: Date; +} + +class Book extends Model(schema) { +} + +export default Book; diff --git a/packages/server/skel/api-ts/app/routes.ts b/packages/server/skel/api-ts/app/routes.ts new file mode 100644 index 00000000..4af76e2a --- /dev/null +++ b/packages/server/skel/api-ts/app/routes.ts @@ -0,0 +1,17 @@ +// Define your routes here +// Check http://expressjs.com/en/guide/routing.html for documentation + +import type { Express } from 'express'; + +import books from './api/books/books.routes'; + +// +export const init = (app: Express) => { + + // mounted under config.api.prefix -> /api/books + app.api('/books', books); + + app.get('/', (req, res) => { + res.json({ name: '{project.name}', status: 'running' }); + }); +}; diff --git a/packages/server/skel/api-ts/locales/en/translation.json b/packages/server/skel/api-ts/locales/en/translation.json new file mode 100644 index 00000000..f42a0b2a --- /dev/null +++ b/packages/server/skel/api-ts/locales/en/translation.json @@ -0,0 +1,3 @@ +{ + "title": "Igo running here" +} diff --git a/packages/server/skel/api-ts/package.json b/packages/server/skel/api-ts/package.json new file mode 100644 index 00000000..3064ec9b --- /dev/null +++ b/packages/server/skel/api-ts/package.json @@ -0,0 +1,26 @@ +{ + "name": "{project.name}", + "version": "0.0.1", + "description": "", + "main": "dist/app.js", + "scripts": { + "build": "tsc && cp -R sql locales dist/", + "start": "tsx watch app.ts", + "serve": "cd dist && node app.js", + "test": "mocha", + "typecheck": "tsc --noEmit" + }, + "author": "", + "license": "ISC", + "dependencies": { + "@igojs/igo": "{igo.version}", + "zod": "^4.5.4" + }, + "devDependencies": { + "@types/express": "^5.0.0", + "@types/mocha": "^10.0.0", + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.9.0" + } +} diff --git a/packages/server/skel/api-ts/sql/20260101-books.sql b/packages/server/skel/api-ts/sql/20260101-books.sql new file mode 100644 index 00000000..5f0ab4b7 --- /dev/null +++ b/packages/server/skel/api-ts/sql/20260101-books.sql @@ -0,0 +1,9 @@ +CREATE TABLE books ( + id INT NOT NULL AUTO_INCREMENT, + title VARCHAR(255) NOT NULL, + author VARCHAR(255) NOT NULL, + pages INT NOT NULL, + published TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id) +); diff --git a/packages/server/skel/api-ts/test/api/BooksTest.ts b/packages/server/skel/api-ts/test/api/BooksTest.ts new file mode 100644 index 00000000..415568ac --- /dev/null +++ b/packages/server/skel/api-ts/test/api/BooksTest.ts @@ -0,0 +1,95 @@ +import { dev } from '@igojs/server'; +import assert from 'assert'; + +import Book from '../../app/models/Book'; + +dev.test(); + +const agent = dev.agent; + +const createBook = (values = {}) => Book.create({ + title: 'Dune', author: 'Frank Herbert', pages: 412, ...values +}); + +describe('api/books', function() { + + describe('GET /api/books', function() { + + it('should list the books', async () => { + await createBook(); + + const res = await agent.get('/api/books'); + + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(res.data.books.length, 1); + assert.strictEqual(res.data.books[0].title, 'Dune'); + assert.strictEqual(res.data.page.total, 1); + }); + + it('should reject an invalid query param', async () => { + const res = await agent.get('/api/books?page=0'); + + assert.strictEqual(res.statusCode, 400); + assert.deepStrictEqual(res.data.errors.map((e: { path: string }) => e.path), ['page']); + }); + }); + + describe('GET /api/books/:id', function() { + + it('should expose only the serialized fields', async () => { + const book = await createBook(); + + const res = await agent.get(`/api/books/${book.id}`); + + assert.strictEqual(res.statusCode, 200); + assert.deepStrictEqual(Object.keys(res.data).sort(), + ['author', 'createdAt', 'id', 'pages', 'published', 'title']); + }); + + it('should answer 404 for an unknown id', async () => { + const res = await agent.get('/api/books/999999'); + + assert.strictEqual(res.statusCode, 404); + assert.strictEqual(res.data.status, 404); + }); + }); + + describe('POST /api/books', function() { + + it('should create a book', async () => { + const res = await agent.post('/api/books', { + body: { title: 'Dune', author: 'Frank Herbert', pages: 412 } + }); + + assert.strictEqual(res.statusCode, 201); + assert.strictEqual(res.data.title, 'Dune'); + assert.strictEqual(res.data.published, false); + + const book = await Book.find(res.data.id); + assert.strictEqual(book.title, 'Dune'); + }); + + it('should reject an invalid body', async () => { + const res = await agent.post('/api/books', { body: { title: '', pages: 'many' } }); + + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(res.data.title, 'Validation failed'); + assert.deepStrictEqual( + res.data.errors.map((e: { path: string }) => e.path).sort(), + ['author', 'pages', 'title'] + ); + }); + }); + + describe('DELETE /api/books/:id', function() { + + it('should delete the book', async () => { + const book = await createBook(); + + const res = await agent.delete(`/api/books/${book.id}`); + + assert.strictEqual(res.statusCode, 204); + assert.strictEqual(await Book.find(book.id), null); + }); + }); +}); diff --git a/packages/server/skel/api-ts/tsconfig.json b/packages/server/skel/api-ts/tsconfig.json new file mode 100644 index 00000000..773daf90 --- /dev/null +++ b/packages/server/skel/api-ts/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "allowJs": true, + "resolveJsonModule": true, + "outDir": "dist", + "rootDir": ".", + "sourceMap": true, + "skipLibCheck": true + }, + "include": ["app.ts", "app/**/*.ts", "test/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/server/skel/api/README.md b/packages/server/skel/api/README.md new file mode 100644 index 00000000..d8dfcfb0 --- /dev/null +++ b/packages/server/skel/api/README.md @@ -0,0 +1,63 @@ +# {project.name} + +API JSON sur [igo](https://github.com/igocreate/igo). + +## Démarrer + +```bash +npm install +npm start # nodemon sur app.js +npm test # mocha, base de test recréée à chaque run +``` + +## Structure + +``` +app/ + api/ + books/ ← un dossier par domaine + books.routes.js ← les endpoints + books.controller.js ← thin : service/modèle → DTO + books.dto.js ← schémas entrants + sérialisation sortante + models/ ← modèles ORM + config.js + routes.js ← montage des routes +sql/ ← migrations +``` + +## Conventions + +**Les routes API se montent avec `app.api()`** — le préfixe (`/api`) vient de +`config.api.prefix`, jamais répété dans le code : + +```js +app.api('/books', require('./api/books/books.routes')); // -> /api/books +``` + +**La validation est automatique.** Le schéma s'attache au handler, igo +l'applique avant que le contrôleur ne tourne : + +```js +exports.create.body = dto.CreateBook; +exports.index.query = dto.ListBooks; +``` + +`req.body` et `req.query` contiennent la valeur validée — coercitions et +valeurs par défaut comprises. Plus de `parseInt(req.query.page)`. + +**Les erreurs sont en JSON**, au format [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) : + +```json +{ + "type": "about:blank", + "title": "Validation failed", + "status": 400, + "errors": [{ "path": "pages", "message": "expected number, received string" }] +} +``` + +Tout ce qui vit sous `/api` répond en JSON — y compris les 404 et les 500. + +**Le DTO est la barrière.** Le front ne voit jamais un modèle ORM brut : +ajouter une colonne au modèle n'expose rien tant qu'elle n'est pas nommée dans +`serialize()`. diff --git a/packages/server/skel/api/_.gitignore b/packages/server/skel/api/_.gitignore new file mode 100644 index 00000000..98856e8f --- /dev/null +++ b/packages/server/skel/api/_.gitignore @@ -0,0 +1,6 @@ + +node_modules + +.env +.DS_Store +*.log diff --git a/packages/server/skel/api/_.mocharc.json b/packages/server/skel/api/_.mocharc.json new file mode 100644 index 00000000..629a09ea --- /dev/null +++ b/packages/server/skel/api/_.mocharc.json @@ -0,0 +1,9 @@ +{ + "recursive": true, + "extension": [ + "js" + ], + "reporter": "dot", + "exit": true, + "timeout": 10000 +} \ No newline at end of file diff --git a/packages/server/skel/api/app.js b/packages/server/skel/api/app.js new file mode 100644 index 00000000..5d3b7739 --- /dev/null +++ b/packages/server/skel/api/app.js @@ -0,0 +1,5 @@ + +// +const { app } = require('@igojs/server'); + +app.run(); diff --git a/packages/server/skel/api/app/api/books/books.controller.js b/packages/server/skel/api/app/api/books/books.controller.js new file mode 100644 index 00000000..a5cf3e6d --- /dev/null +++ b/packages/server/skel/api/app/api/books/books.controller.js @@ -0,0 +1,59 @@ + +const { sendProblem } = require('@igojs/server'); + +const Book = require('../../models/Book'); +const dto = require('./books.dto'); + +// +exports.index = async (req, res) => { + const { page, limit, published } = req.query; + + let query = Book.order('created_at desc'); + if (published !== undefined) { + query = query.where({ published }); + } + + const { rows, pagination } = await query.page(page, limit).list(); + res.json({ + books: rows.map(dto.serialize), + page: dto.serializePage(pagination), + }); +}; +exports.index.query = dto.ListBooks; + +// +exports.show = async (req, res) => { + const book = await Book.find(req.params.id); + if (!book) { + return sendProblem(res, 404, { detail: 'Book not found' }); + } + res.json(dto.serialize(book)); +}; + +// +exports.create = async (req, res) => { + const book = await Book.create(req.body); + res.status(201).json(dto.serialize(book)); +}; +exports.create.body = dto.CreateBook; + +// +exports.update = async (req, res) => { + const book = await Book.find(req.params.id); + if (!book) { + return sendProblem(res, 404, { detail: 'Book not found' }); + } + await book.update(req.body); + res.json(dto.serialize(book)); +}; +exports.update.body = dto.UpdateBook; + +// +exports.destroy = async (req, res) => { + const book = await Book.find(req.params.id); + if (!book) { + return sendProblem(res, 404, { detail: 'Book not found' }); + } + await book.delete(); + res.status(204).end(); +}; diff --git a/packages/server/skel/api/app/api/books/books.dto.js b/packages/server/skel/api/app/api/books/books.dto.js new file mode 100644 index 00000000..7c554e25 --- /dev/null +++ b/packages/server/skel/api/app/api/books/books.dto.js @@ -0,0 +1,40 @@ + +const { z } = require('zod'); + +// Incoming: what the API accepts. Coercion and defaults are applied before the +// controller runs, so req.body and req.query already hold the right types. +exports.CreateBook = z.object({ + title: z.string().min(1).max(255), + author: z.string().min(1).max(255), + pages: z.number().int().positive(), + published: z.boolean().default(false), +}); + +exports.UpdateBook = exports.CreateBook.partial(); + +exports.ListBooks = z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(25), + // z.coerce.boolean() would turn 'false' into true: URL flags need this form + published: z.enum(['true', 'false']).transform(v => v === 'true').optional(), +}); + +// Outgoing: the barrier between the ORM model and the API. Adding a column to +// the model exposes nothing until it is named here. +exports.serialize = (book) => ({ + id: book.id, + title: book.title, + author: book.author, + pages: book.pages, + published: book.published, + createdAt: book.created_at, +}); + +// The ORM pagination also carries `links`, meant for rendering page numbers in +// a template: an API client builds its own navigation. +exports.serializePage = (pagination) => ({ + page: pagination.page, + perPage: pagination.nb, + pages: pagination.nb_pages, + total: pagination.count, +}); diff --git a/packages/server/skel/api/app/api/books/books.routes.js b/packages/server/skel/api/app/api/books/books.routes.js new file mode 100644 index 00000000..66d88194 --- /dev/null +++ b/packages/server/skel/api/app/api/books/books.routes.js @@ -0,0 +1,14 @@ + +const { express } = require('@igojs/server'); + +const controller = require('./books.controller'); + +const router = express.Router(); + +router.get('/', controller.index); +router.post('/', controller.create); +router.get('/:id', controller.show); +router.put('/:id', controller.update); +router.delete('/:id', controller.destroy); + +module.exports = router; diff --git a/packages/server/skel/api/app/config.js b/packages/server/skel/api/app/config.js new file mode 100644 index 00000000..b43709d3 --- /dev/null +++ b/packages/server/skel/api/app/config.js @@ -0,0 +1,5 @@ + +module.exports.init = (config) => { + config.cookieSecret = '{RANDOM_1}'; + config.cookieSession.keys = [ '{RANDOM_2}' ]; +}; diff --git a/packages/server/skel/api/app/models/Book.js b/packages/server/skel/api/app/models/Book.js new file mode 100644 index 00000000..be820c04 --- /dev/null +++ b/packages/server/skel/api/app/models/Book.js @@ -0,0 +1,19 @@ + +const { Model } = require('@igojs/db'); + +const schema = { + table: 'books', + columns: [ + 'id', + 'title', + 'author', + 'pages', + { name: 'published', type: 'boolean' }, + 'created_at', + ], +}; + +class Book extends Model(schema) { +} + +module.exports = Book; diff --git a/packages/server/skel/api/app/routes.js b/packages/server/skel/api/app/routes.js new file mode 100644 index 00000000..d1d7168e --- /dev/null +++ b/packages/server/skel/api/app/routes.js @@ -0,0 +1,13 @@ +// Define your routes here +// Check http://expressjs.com/en/guide/routing.html for documentation + +// +module.exports.init = (app) => { + + // mounted under config.api.prefix -> /api/books + app.api('/books', require('./api/books/books.routes')); + + app.get('/', (req, res) => { + res.json({ name: '{project.name}', status: 'running' }); + }); +}; diff --git a/packages/server/skel/api/eslint.config.js b/packages/server/skel/api/eslint.config.js new file mode 100644 index 00000000..d251d420 --- /dev/null +++ b/packages/server/skel/api/eslint.config.js @@ -0,0 +1,35 @@ +module.exports = [{ + 'rules': { + 'indent': [ + 'error', + 2, + { + 'ArrayExpression': 'first', + 'CallExpression': {'arguments': 'first'}, + 'FunctionDeclaration': {'body': 1, 'parameters': 'first'}, + 'MemberExpression': 0, + 'ObjectExpression': 1 + } + ], + 'linebreak-style': [ + 'error', + 'unix' + ], + 'no-unused-vars': [ + 'error', + { 'vars': 'all', 'args': 'after-used', 'ignoreRestSiblings': false } + ], + 'quotes': [ + 'error', + 'single' + ], + 'semi': [ + 'error', + 'always' + ], + 'keyword-spacing': [ + 'error', + { 'after': true, 'before': true } + ] + } +}]; \ No newline at end of file diff --git a/packages/server/skel/api/locales/en/translation.json b/packages/server/skel/api/locales/en/translation.json new file mode 100644 index 00000000..f42a0b2a --- /dev/null +++ b/packages/server/skel/api/locales/en/translation.json @@ -0,0 +1,3 @@ +{ + "title": "Igo running here" +} diff --git a/packages/server/skel/api/nodemon.json b/packages/server/skel/api/nodemon.json new file mode 100644 index 00000000..6b7f1ebc --- /dev/null +++ b/packages/server/skel/api/nodemon.json @@ -0,0 +1,11 @@ +{ + "watch": [ + "app", + "locales" + ], + "ignore": [], + "ext": "js json", + "events": { + "start": "npm run eslint" + } +} diff --git a/packages/server/skel/api/package.json b/packages/server/skel/api/package.json new file mode 100644 index 00000000..45cccb47 --- /dev/null +++ b/packages/server/skel/api/package.json @@ -0,0 +1,20 @@ +{ + "name": "{project.name}", + "version": "0.0.1", + "description": "", + "main": "app.js", + "scripts": { + "eslint": "eslint ./app ./test", + "start": "nodemon app.js", + "test": "mocha" + }, + "author": "", + "license": "ISC", + "dependencies": { + "@igojs/igo": "{igo.version}", + "zod": "^4.5.4" + }, + "devDependencies": { + "eslint": "^10.4.1" + } +} diff --git a/packages/server/skel/api/sql/20260101-books.sql b/packages/server/skel/api/sql/20260101-books.sql new file mode 100644 index 00000000..5f0ab4b7 --- /dev/null +++ b/packages/server/skel/api/sql/20260101-books.sql @@ -0,0 +1,9 @@ +CREATE TABLE books ( + id INT NOT NULL AUTO_INCREMENT, + title VARCHAR(255) NOT NULL, + author VARCHAR(255) NOT NULL, + pages INT NOT NULL, + published TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id) +); diff --git a/packages/server/skel/api/test/api/BooksTest.js b/packages/server/skel/api/test/api/BooksTest.js new file mode 100644 index 00000000..79c72e86 --- /dev/null +++ b/packages/server/skel/api/test/api/BooksTest.js @@ -0,0 +1,92 @@ +require('@igojs/server').dev.test(); + +const assert = require('assert'); +const agent = require('@igojs/server').dev.agent; + +const Book = require('../../app/models/Book'); + +const createBook = (values = {}) => Book.create({ + title: 'Dune', author: 'Frank Herbert', pages: 412, ...values +}); + +describe('api/books', function() { + + describe('GET /api/books', function() { + + it('should list the books', async () => { + await createBook(); + + const res = await agent.get('/api/books'); + + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(res.data.books.length, 1); + assert.strictEqual(res.data.books[0].title, 'Dune'); + assert.strictEqual(res.data.page.total, 1); + }); + + it('should reject an invalid query param', async () => { + const res = await agent.get('/api/books?page=0'); + + assert.strictEqual(res.statusCode, 400); + assert.deepStrictEqual(res.data.errors.map(e => e.path), ['page']); + }); + }); + + describe('GET /api/books/:id', function() { + + it('should expose only the serialized fields', async () => { + const book = await createBook(); + + const res = await agent.get(`/api/books/${book.id}`); + + assert.strictEqual(res.statusCode, 200); + assert.deepStrictEqual( + Object.keys(res.data).sort(), + ['author', 'createdAt', 'id', 'pages', 'published', 'title'] + ); + }); + + it('should answer 404 for an unknown id', async () => { + const res = await agent.get('/api/books/999999'); + + assert.strictEqual(res.statusCode, 404); + assert.strictEqual(res.data.status, 404); + }); + }); + + describe('POST /api/books', function() { + + it('should create a book', async () => { + const res = await agent.post('/api/books', { + body: { title: 'Dune', author: 'Frank Herbert', pages: 412 } + }); + + assert.strictEqual(res.statusCode, 201); + assert.strictEqual(res.data.title, 'Dune'); + assert.strictEqual(res.data.published, false); + + const book = await Book.find(res.data.id); + assert.strictEqual(book.title, 'Dune'); + }); + + it('should reject an invalid body', async () => { + const res = await agent.post('/api/books', { body: { title: '', pages: 'many' } }); + + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(res.data.title, 'Validation failed'); + assert.deepStrictEqual(res.data.errors.map(e => e.path).sort(), ['author', 'pages', 'title']); + }); + }); + + describe('DELETE /api/books/:id', function() { + + it('should delete the book', async () => { + const book = await createBook(); + + const res = await agent.delete(`/api/books/${book.id}`); + + assert.strictEqual(res.statusCode, 204); + assert.strictEqual(await Book.find(book.id), null); + }); + }); +}); diff --git a/packages/server/test/CreateTest.js b/packages/server/test/CreateTest.js new file mode 100644 index 00000000..ac527756 --- /dev/null +++ b/packages/server/test/CreateTest.js @@ -0,0 +1,57 @@ +require('./init'); + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const create = require('@igojs/server/cli/create'); + +const SKELETONS = ['tailwind', 'api', 'api-ts']; + +describe('cli/create', function() { + this.timeout(20000); + + let cwd, tmp; + + beforeEach(() => { + cwd = process.cwd(); + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'igo-create-')); + process.chdir(tmp); + }); + + afterEach(() => { + process.chdir(cwd); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + SKELETONS.forEach((skel) => { + it(`should create a project from the ${skel} skeleton`, async () => { + await create({ _: ['create', 'myapp'], skel }); + + const pkg = JSON.parse(fs.readFileSync(path.join(tmp, 'myapp', 'package.json'), 'utf8')); + assert.strictEqual(pkg.name, 'myapp'); + assert(!/\{igo\.version\}/.test(pkg.dependencies['@igojs/igo']), 'igo version was not replaced'); + + // _.gitignore is renamed on the way out + assert(fs.existsSync(path.join(tmp, 'myapp', '.gitignore'))); + }); + }); + + it('should default to the tailwind skeleton', async () => { + await create({ _: ['create', 'myapp'] }); + assert(fs.existsSync(path.join(tmp, 'myapp', 'views')), 'tailwind skeleton has views'); + }); + + it('should carry the api conventions into the api skeletons', async () => { + await create({ _: ['create', 'myapi'], skel: 'api' }); + + const routes = fs.readFileSync(path.join(tmp, 'myapi', 'app', 'routes.js'), 'utf8'); + assert(routes.includes('app.api('), 'routes mount through app.api()'); + + const controller = fs.readFileSync( + path.join(tmp, 'myapi', 'app', 'api', 'books', 'books.controller.js'), 'utf8'); + assert(controller.includes('exports.create.body = dto.CreateBook'), + 'schema is attached to the handler'); + }); +}); From 9a6d9376d704c5e9a5e7aa48b50a5023dc8efdb3 Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Mon, 7 Sep 2026 11:15:14 +0200 Subject: [PATCH 05/80] docs(server): documenter les API JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nouvelle page server/api : montage via app.api(), validation par schéma attaché au handler, format RFC 9457, DTO, tests et typage TypeScript. Liens croisés depuis routes, errors et getting-started, qui décrivaient encore un serveur qui ne rend que du HTML. Corrige au passage le build vitepress, cassé avant cette branche : un Loaded non échappé était lu comme une balise, et trois liens pointaient vers des documents jamais commités. Co-Authored-By: Claude Opus 5 (1M context) --- docs/.vitepress/config.mjs | 1 + docs/adr/socle-back-nouveaux-projets.md | 2 +- docs/cadre-decision-stack-front.md | 6 +- docs/server/api.md | 184 ++++++++++++++++++++++++ docs/server/errors.md | 15 +- docs/server/getting-started.md | 14 ++ docs/server/routes.md | 3 + 7 files changed, 220 insertions(+), 5 deletions(-) create mode 100644 docs/server/api.md diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index b6e149f1..747820cb 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -63,6 +63,7 @@ export default defineConfig({ items: [ { text: 'Getting started', link: '/server/getting-started' }, { text: 'Routes & controllers', link: '/server/routes' }, + { text: 'JSON APIs', link: '/server/api' }, { text: 'Views', link: '/server/views' }, { text: 'Forms', link: '/server/forms' }, { text: 'Cache (Redis)', link: '/server/cache' }, diff --git a/docs/adr/socle-back-nouveaux-projets.md b/docs/adr/socle-back-nouveaux-projets.md index 041d0426..4f4ce48c 100644 --- a/docs/adr/socle-back-nouveaux-projets.md +++ b/docs/adr/socle-back-nouveaux-projets.md @@ -180,7 +180,7 @@ Analyse réalisée en septembre 2026 sur les sources officielles et les retours | **Migration lock** | **OUI** — advisory lock | OUI — avec bugs connus | **NON** — bug ouvert depuis 2023 | **NON** | **NON** | OUI | | **Pagination optimisée** | **OUI** — COUNT/IDS/FULL auto | Cursor + offset | Offset + cursor manuel | OUI — cursor + subquery auto | Offset seul | Offset seul | | **Scopes** | **OUI** — default + named + unscope | PARTIEL — via extensions | Beta | **OUI** — Filters | NON (community) | PARTIEL — composable | -| **TypeScript** | **NON** | **OUI** — best-in-class | **OUI** — sans codegen | **OUI** — Loaded | PARTIEL — QB non typé | **OUI** — best-in-class | +| **TypeScript** | **NON** | **OUI** — best-in-class | **OUI** — sans codegen | **OUI** — `Loaded` | PARTIEL — QB non typé | **OUI** — best-in-class | | **Relations** | PARTIEL — belongs_to, has_many | **OUI** — polymorphique v8 | OUI | **OUI** — polymorphique v7 | **OUI** | NON (query builder) | | **Migrations up/down** | PARTIEL — up only, pas de down | PARTIEL — down manuel | **NON** — pas de down | OUI | **OUI** — auto-gen + down | OUI | | **Seeds** | **OUI** — natif, CLI, bloqué en prod | PARTIEL — hook configurable | NON | **OUI** — SeedManager | NON (community) | NON | diff --git a/docs/cadre-decision-stack-front.md b/docs/cadre-decision-stack-front.md index e738f9ec..d8df879a 100644 --- a/docs/cadre-decision-stack-front.md +++ b/docs/cadre-decision-stack-front.md @@ -69,9 +69,9 @@ Ce sur quoi les ADR s'appuient. Chaque ligne porte le chiffre qui a compté. | Source | Ce qu'elle établit | |---|---| -| [Inventaire du besoin de réactivité](inventaire-besoin-reactivite.md) | **~85 % de la surface réactive exige un modèle d'état**, ~15 % se contentent d'une mise à jour partielle. Classement des 126 modules jQuery par motif. Inclut le test inverse sur funecap | -| [Rétrospective — coût de framework](retrospective-cout-framework.md) | **~1 écran sur 3** déclenche du travail de framework ou un contournement. Le péage se déclenche sur **l'habillage**, pas sur la logique. Coût passé : quelques jours à quelques semaines, engagés | -| [Atelier équipe du 19/08](atelier-equipe-20260819.md) | **Pondérations arrêtées par l'équipe.** Frictions quotidiennes chiffrées. Cinq besoins abandonnés faute d'outillage | +| Inventaire du besoin de réactivité | **~85 % de la surface réactive exige un modèle d'état**, ~15 % se contentent d'une mise à jour partielle. Classement des 126 modules jQuery par motif. Inclut le test inverse sur funecap | +| Rétrospective — coût de framework | **~1 écran sur 3** déclenche du travail de framework ou un contournement. Le péage se déclenche sur **l'habillage**, pas sur la logique. Coût passé : quelques jours à quelques semaines, engagés | +| Atelier équipe du 19/08 | **Pondérations arrêtées par l'équipe.** Frictions quotidiennes chiffrées. Cinq besoins abandonnés faute d'outillage | | Démo du 20/08 — POC React | Espace stagiaire de certigo porté en **moins d'une journée**, SCORM inclus, aucun process supplémentaire. Next.js et BFF écartés par l'équipe | | Sources igo, `@igojs/component` 6.1.1 | Socle réactif **complet** : composition, listes par clé, état partagé, événements parent↔enfant. Manquent le typage, les transitions, la testabilité applicative. **8 releases du 21/05 au 17/06/2026** | | Infra `ovh-ladom2` | nginx sert les assets (`try_files $uri @app`) ; igo ne les sert pas. `pm2 delete` → `pm2 start` ouvre une fenêtre d'indisponibilité. Six environnements | diff --git a/docs/server/api.md b/docs/server/api.md new file mode 100644 index 00000000..b76f07c3 --- /dev/null +++ b/docs/server/api.md @@ -0,0 +1,184 @@ + +# JSON APIs + +Routes mounted with `app.api()` answer in JSON — always. Validation errors, +404s and 500s all come back as [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) +problem documents, never as a rendered page. + +## Mounting routes + +```js +// app/routes.js +module.exports.init = (app) => { + app.api('/books', require('./api/books/books.routes')); // -> /api/books +}; +``` + +The prefix comes from `config.api.prefix` (`/api` by default), so it is never +repeated in the code. Override it in `app/config.js` if you need to: + +```js +config.api.prefix = '/v1'; +``` + +## Anatomy of a domain + +``` +app/api/books/ + books.routes.js the endpoints + books.controller.js thin: model or service -> DTO + books.dto.js incoming schemas + outgoing serialization +``` + +```js +// books.routes.js +const { express } = require('@igojs/server'); +const controller = require('./books.controller'); + +const router = express.Router(); + +router.get('/', controller.index); +router.post('/', controller.create); +router.get('/:id', controller.show); + +module.exports = router; +``` + +## Validation + +Attach a schema to a handler and igo applies it before the handler runs. There +is nothing to add to the routes: + +```js +// books.controller.js +exports.create = async (req, res) => { + const book = await Book.create(req.body); // already validated + res.status(201).json(dto.serialize(book)); +}; +exports.create.body = dto.CreateBook; + +exports.index = async (req, res) => { + const { page, limit } = req.query; // already numbers + ... +}; +exports.index.query = dto.ListBooks; +``` + +`body`, `query` and `params` are the three sources you can validate. The +validated value **replaces** the original, so coercions and defaults reach the +controller: + +```js +// books.dto.js +exports.ListBooks = z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(25), +}); +``` + +`GET /api/books` gives `req.query.page === 1`, and `?page=3` gives the number +`3` — no `parseInt` in the controller. + +::: warning Booleans in query strings +`z.coerce.boolean()` follows JavaScript: `Boolean('false')` is `true`, so every +present flag validates as true. For a URL flag, convert explicitly: + +```js +published: z.enum(['true', 'false']).transform(v => v === 'true').optional(), +``` +::: + +Any [Standard Schema](https://standardschema.dev) library works — zod, valibot +or arktype. The skeletons ship zod. + +Routes that accept a body without declaring a schema are listed in a warning at +startup. It never blocks the server: a `GET` without parameters legitimately has +no schema. + +## Error format + +Every API error is a problem document, served as `application/problem+json`: + +```json +{ + "type": "about:blank", + "title": "Validation failed", + "status": 400, + "errors": [ + { "path": "pages", "message": "Invalid input: expected number, received string" } + ] +} +``` + +`type`, `title` and `status` are the RFC 9457 fields; `errors` carries the +per-field detail so a front end can display it without transformation. + +To answer with one yourself: + +```js +const { sendProblem } = require('@igojs/server'); + +exports.show = async (req, res) => { + const book = await Book.find(req.params.id); + if (!book) { + return sendProblem(res, 404, { detail: 'Book not found' }); + } + res.json(dto.serialize(book)); +}; +``` + +Outside production a 500 includes the error message as `detail`; in production +it is omitted. Crash emails are unaffected — only the response format changes. + +## DTOs + +The DTO is the barrier between the ORM model and the API. Adding a column to a +model exposes nothing until it is named in `serialize()`: + +```js +exports.serialize = (book) => ({ + id: book.id, + title: book.title, + createdAt: book.created_at, +}); +``` + +## Testing + +`dev.agent` exposes the parsed response body as `res.data`: + +```js +it('should reject an invalid body', async () => { + const res = await agent.post('/api/books', { body: { title: '' } }); + + assert.strictEqual(res.statusCode, 400); + assert.deepStrictEqual(res.data.errors.map(e => e.path), ['title']); +}); +``` + +## TypeScript + +The schemas are the source of the types — no shape is declared twice: + +```ts +import type { ApiHandler } from '@igojs/server'; + +export const create: ApiHandler<{ body: typeof dto.CreateBook }> = async (req, res) => { + req.body.pages; // number + req.body.isbn; // compile error: not in the schema +}; +create.body = dto.CreateBook; +``` + +igo itself stays JavaScript: the types ship as `.d.ts` files, which are never +loaded at runtime. A JavaScript project is unaffected. + +## Starting a new API project + +```bash +igo create myapi --skel=api # JavaScript +igo create myapi --skel=api-ts # TypeScript +``` + +Both come with a working domain — model, DTO, controller, routes, migration and +integration tests. diff --git a/docs/server/errors.md b/docs/server/errors.md index c0bb3ca2..af9ddffd 100644 --- a/docs/server/errors.md +++ b/docs/server/errors.md @@ -27,9 +27,22 @@ Fatal errors that escape all handlers are logged, an email is sent, and the proc | Error type | Response | Email sent? | |------------|----------|-------------| | `URIError` (malformed URL) | 404 | No | -| `SyntaxError` (invalid JSON) | 500 | No | +| `SyntaxError` (invalid JSON) | 500, or 400 on an API request | No | | Other errors | 500 | Yes | +## API Requests + +A request under `config.api.prefix` (`/api` by default), or one whose `Accept` +header asks for JSON, never receives a rendered page. Errors come back as +[RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem documents — 404s and +validation failures included. See [JSON APIs](./api). + +```json +{ "type": "about:blank", "title": "Not Found", "status": 404 } +``` + +Crash emails are unaffected: only the response format changes. + ## Crash Emails Configure one or more recipients for error notification emails: diff --git a/docs/server/getting-started.md b/docs/server/getting-started.md index 75bf34e9..4a13576c 100644 --- a/docs/server/getting-started.md +++ b/docs/server/getting-started.md @@ -32,6 +32,19 @@ npm start `npm start` runs nodemon + webpack in parallel — the server reloads on `app/` changes, the bundle rebuilds on `js/` and `scss/` changes. +### Skeletons + +`create` scaffolds a server-rendered project by default. For a JSON API with no +views and no bundler: + +```sh +npx @igojs/server create myapi --skel=api # JavaScript +npx @igojs/server create myapi --skel=api-ts # TypeScript +``` + +Both ship a working domain — model, DTO, controller, routes, migration and +integration tests. See [JSON APIs](./api). + ## Minimal app If you'd rather wire things up by hand: @@ -62,6 +75,7 @@ module.exports = (config) => { ## Next steps * **[Routes & controllers](./routes)** — Routing and the controller layer +* **[JSON APIs](./api)** — Validation, RFC 9457 errors, DTOs, TypeScript * **[Views](./views)** — View engine, helpers, custom helpers * **[Forms](./forms)** — Sanitize/validate/convert pipeline * **[Cache](./cache)** — Redis cache API diff --git a/docs/server/routes.md b/docs/server/routes.md index 9a0f6147..f38738a7 100644 --- a/docs/server/routes.md +++ b/docs/server/routes.md @@ -58,6 +58,9 @@ module.exports.api = async (req, res) => { }; ``` +For a JSON API — validation, RFC 9457 errors and JSON 404s — mount the routes +with `app.api()` instead. See [JSON APIs](./api). + ## Redirects ```js From 305b9f1fa67502f342eb2e033197b78103d8e858 Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Mon, 7 Sep 2026 11:45:55 +0200 Subject: [PATCH 06/80] feat(server): rendre les erreurs API identifiables par programme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le type était constamment about:blank et le code d'erreur par champ était jeté : un client ne pouvait discriminer qu'en lisant title ou message, des libellés d'affichage qui bougent avec la version et la locale de zod. - Les erreurs de validation portent type: urn:igo:validation-failed. - Chaque entrée de errors[] porte le code zod (invalid_type, too_small…), quand la bibliothèque en fournit un — ce n'est pas garanti par Standard Schema. - Les titres viennent de http.STATUS_CODES au lieu d'une liste tenue à la main où 409, 429 et le reste tombaient sur « Error ». Les types métier restent à la charge de l'application : igo n'en connaît qu'un. La doc explique le mécanisme, propose /problems/ comme convention, et montre le contre-exemple qui manquait sur les DTO. Co-Authored-By: Claude Opus 5 (1M context) --- docs/adr/organisation-des-sources-back.md | 6 +- docs/server/api.md | 85 ++++++++++++++++++++--- packages/server/skel/api-ts/README.md | 12 +++- packages/server/skel/api/README.md | 12 +++- packages/server/src/api/problem.d.ts | 7 ++ packages/server/src/api/problem.js | 17 ++--- packages/server/src/api/validate.js | 23 ++++-- packages/server/test/ApiTest.js | 7 +- packages/server/test/api/problemTest.js | 14 +++- 9 files changed, 151 insertions(+), 32 deletions(-) diff --git a/docs/adr/organisation-des-sources-back.md b/docs/adr/organisation-des-sources-back.md index 7749c434..49830f4f 100644 --- a/docs/adr/organisation-des-sources-back.md +++ b/docs/adr/organisation-des-sources-back.md @@ -277,17 +277,19 @@ Les erreurs API suivent **[RFC 9457 Problem Details](https://www.rfc-editor.org/ ```json { - "type": "about:blank", + "type": "urn:igo:validation-failed", "title": "Validation failed", "status": 400, "errors": [ - { "path": "beneficiary_id", "message": "Expected number, received string" } + { "path": "beneficiary_id", "code": "invalid_type", "message": "Expected number, received string" } ] } ``` `type`, `title` et `status` sont les champs standard ; `errors` est l'extension pour le détail de validation, que le front affiche champ par champ sans transformation. +Le `type` identifie le problème — c'est lui, avec `errors[].code`, que le client teste. `title` et `message` sont des libellés d'affichage. Les erreurs métier de l'application posent leur propre type (`/problems/dossier-non-transmissible`) ; igo n'en fournit qu'un, pour la validation. + ### Validation avec Zod — middleware global Le middleware est **monté par igo sur le préfixe API**, pas déclaré route par route. Le schéma est attaché au handler : diff --git a/docs/server/api.md b/docs/server/api.md index b76f07c3..ee9a8c6b 100644 --- a/docs/server/api.md +++ b/docs/server/api.md @@ -101,11 +101,11 @@ Every API error is a problem document, served as `application/problem+json`: ```json { - "type": "about:blank", + "type": "urn:igo:validation-failed", "title": "Validation failed", "status": 400, "errors": [ - { "path": "pages", "message": "Invalid input: expected number, received string" } + { "path": "pages", "code": "invalid_type", "message": "Invalid input: expected number, received string" } ] } ``` @@ -113,36 +113,105 @@ Every API error is a problem document, served as `application/problem+json`: `type`, `title` and `status` are the RFC 9457 fields; `errors` carries the per-field detail so a front end can display it without transformation. -To answer with one yourself: +### Identifying an error + +Two levels, both meant to be branched on: + +- **`type`** identifies the problem itself. It is a URI, and it does not have to + resolve to anything. igo sets `urn:igo:validation-failed` when a schema + rejects a request; otherwise it stays `about:blank`, which the RFC defines as + "no specific type — the status says it all". +- **`errors[].code`** identifies what is wrong with one field: `invalid_type`, + `too_small`, `invalid_format`, `invalid_value`… These come from the schema + library and are stable across versions. + +Branch on `type` and `code`, never on `title` or `message`: those are display +strings whose wording changes with the schema library's version and locale. + +```js +if (problem.type === 'urn:igo:validation-failed') { + for (const { path, code } of problem.errors) { + setFieldError(path, translate(code)); // code, not message + } +} +``` + +`path` is dotted, and includes array indices: `tags.0`, `author.email`. + +### Typing your own errors + +This is where the format earns its keep. A status code says a request failed; a +`type` says **why**, and lets a client react to a specific business situation: ```js const { sendProblem } = require('@igojs/server'); -exports.show = async (req, res) => { +exports.borrow = async (req, res) => { const book = await Book.find(req.params.id); if (!book) { return sendProblem(res, 404, { detail: 'Book not found' }); } - res.json(dto.serialize(book)); + if (book.stock === 0) { + return sendProblem(res, 409, { + type: '/problems/out-of-stock', + title: 'Book is out of stock', + detail: `"${book.title}" is not available right now`, + }); + } + ... }; ``` +Without the `type`, a client receiving a 409 cannot tell "out of stock" from +"already borrowed" without parsing a human sentence. + +Define a type whenever a client would plausibly react differently — not one per +status code. A 404 rarely needs one; a 409 or a 422 usually does. + +`title` defaults to the standard HTTP wording for the status (`Conflict` for +409, `Too Many Requests` for 429), so pass it only when you have something more +precise to say. + +**A convention, not a rule.** igo suggests `/problems/` — short, readable, +and easy to serve documentation from later if you want to. URNs +(`urn:myapp:out-of-stock`) and full URLs work just as well. The RFC only asks +for a URI that stays stable, since clients branch on it. Pick one form and keep +it across the project. + Outside production a 500 includes the error message as `detail`; in production it is omitted. Crash emails are unaffected — only the response format changes. ## DTOs -The DTO is the barrier between the ORM model and the API. Adding a column to a -model exposes nothing until it is named in `serialize()`: +Returning a model straight from a controller sends every column it has: + +```js +res.json(book); // whatever `books` holds today, and tomorrow +``` + +Add `internal_cost` to the table six months later and it reaches the browser — +no controller changed, no test failed, nothing to notice in review. + +A DTO is a plain function that picks what goes out. It is an allow-list: a new +column is invisible until someone names it here. ```js exports.serialize = (book) => ({ id: book.id, title: book.title, - createdAt: book.created_at, + createdAt: book.created_at, // snake_case in SQL, camelCase over the wire }); ``` +```js +res.json(dto.serialize(book)); +``` + +Nothing in igo looks for this function — the controller calls it, so the name is +a convention, not a hook. It also decouples the two shapes: renaming a column +changes `serialize()`, not the API contract. And one model can have several +serializers when two audiences see different fields. + ## Testing `dev.agent` exposes the parsed response body as `res.data`: diff --git a/packages/server/skel/api-ts/README.md b/packages/server/skel/api-ts/README.md index 57205cfd..c21eb121 100644 --- a/packages/server/skel/api-ts/README.md +++ b/packages/server/skel/api-ts/README.md @@ -54,15 +54,23 @@ valeurs par défaut comprises. Plus de `parseInt(req.query.page)`. ```json { - "type": "about:blank", + "type": "urn:igo:validation-failed", "title": "Validation failed", "status": 400, - "errors": [{ "path": "pages", "message": "expected number, received string" }] + "errors": [{ "path": "pages", "code": "invalid_type", "message": "..." }] } ``` Tout ce qui vit sous `/api` répond en JSON — y compris les 404 et les 500. +Le client se branche sur `type` et `errors[].code`, jamais sur `title` ni +`message` — ce sont des libellés d'affichage. Pour une erreur métier, on pose +son propre type : + +```js +sendProblem(res, 409, { type: '/problems/out-of-stock', title: 'Book is out of stock' }); +``` + **Le DTO est la barrière.** Le front ne voit jamais un modèle ORM brut : ajouter une colonne au modèle n'expose rien tant qu'elle n'est pas nommée dans `serialize()`. diff --git a/packages/server/skel/api/README.md b/packages/server/skel/api/README.md index d8dfcfb0..02379b4a 100644 --- a/packages/server/skel/api/README.md +++ b/packages/server/skel/api/README.md @@ -49,15 +49,23 @@ valeurs par défaut comprises. Plus de `parseInt(req.query.page)`. ```json { - "type": "about:blank", + "type": "urn:igo:validation-failed", "title": "Validation failed", "status": 400, - "errors": [{ "path": "pages", "message": "expected number, received string" }] + "errors": [{ "path": "pages", "code": "invalid_type", "message": "..." }] } ``` Tout ce qui vit sous `/api` répond en JSON — y compris les 404 et les 500. +Le client se branche sur `type` et `errors[].code`, jamais sur `title` ni +`message` — ce sont des libellés d'affichage. Pour une erreur métier, on pose +son propre type : + +```js +sendProblem(res, 409, { type: '/problems/out-of-stock', title: 'Book is out of stock' }); +``` + **Le DTO est la barrière.** Le front ne voit jamais un modèle ORM brut : ajouter une colonne au modèle n'expose rien tant qu'elle n'est pas nommée dans `serialize()`. diff --git a/packages/server/src/api/problem.d.ts b/packages/server/src/api/problem.d.ts index 0a0d6681..e12e0f06 100644 --- a/packages/server/src/api/problem.d.ts +++ b/packages/server/src/api/problem.d.ts @@ -10,7 +10,11 @@ export interface ProblemDocument { } export interface ProblemError { + /** Dotted path of the offending field, e.g. 'tags.0'. */ path: string; + /** Stable identifier to branch on, e.g. 'invalid_type'. Absent if the schema library does not provide one. */ + code?: string; + /** Human-readable text; wording changes with the schema library. */ message: string; } @@ -23,6 +27,9 @@ export interface ProblemOptions { export declare const CONTENT_TYPE: 'application/problem+json'; +/** `type` of the problem igo returns when a schema rejects a request. */ +export declare const VALIDATION_FAILED: 'urn:igo:validation-failed'; + /** True when the request targets the API prefix, or asks for JSON. */ export declare function isApiRequest(req: Pick): boolean; diff --git a/packages/server/src/api/problem.js b/packages/server/src/api/problem.js index 71067ae9..2faf1b25 100644 --- a/packages/server/src/api/problem.js +++ b/packages/server/src/api/problem.js @@ -1,16 +1,13 @@ +const { STATUS_CODES } = require('http'); + const config = require('../config'); const CONTENT_TYPE = 'application/problem+json'; -const TITLES = { - 400: 'Bad Request', - 401: 'Unauthorized', - 403: 'Forbidden', - 404: 'Not Found', - 422: 'Unprocessable Content', - 500: 'Internal Server Error', -}; +// igo produces one problem specific enough to name: everything else is +// identified by its status alone. Applications define their own types. +const VALIDATION_FAILED = 'urn:igo:validation-failed'; // A request is served as JSON when it targets the API prefix, or when the // client asked for JSON and cannot render a dust page anyway. @@ -27,7 +24,7 @@ const isApiRequest = (req) => { const problem = (status, { title, detail, errors, type } = {}) => { const body = { type: type || 'about:blank', - title: title || TITLES[status] || 'Error', + title: title || STATUS_CODES[status] || 'Error', status, }; if (detail) { @@ -45,4 +42,4 @@ const send = (res, status, options) => { return res.json(problem(status, options)); }; -module.exports = { isApiRequest, problem, send, CONTENT_TYPE }; +module.exports = { isApiRequest, problem, send, CONTENT_TYPE, VALIDATION_FAILED }; diff --git a/packages/server/src/api/validate.js b/packages/server/src/api/validate.js index 86265f7b..0b893bbb 100644 --- a/packages/server/src/api/validate.js +++ b/packages/server/src/api/validate.js @@ -34,10 +34,19 @@ const replace = (req, source, value) => { req[source] = value; }; -const issuesOf = (result) => result.issues.map((issue) => ({ - path: (issue.path || []).map(segment => segment?.key ?? segment).join('.'), - message: issue.message, -})); +// `message` is meant for humans and changes with the schema library's version +// and locale; `code` is the stable identifier a client should branch on. It is +// a Zod extra rather than a Standard Schema guarantee, hence the check. +const issuesOf = (result) => result.issues.map((issue) => { + const error = { + path: (issue.path || []).map(segment => segment?.key ?? segment).join('.'), + }; + if (issue.code) { + error.code = issue.code; + } + error.message = issue.message; + return error; +}); // Wraps a handler so its schemas are applied before it runs. const wrap = (handler, schemas) => { @@ -46,7 +55,11 @@ const wrap = (handler, schemas) => { for (const [source, schema] of Object.entries(schemas)) { const result = await schema['~standard'].validate(req[source]); if (result.issues) { - return problem.send(res, 400, { title: 'Validation failed', errors: issuesOf(result) }); + return problem.send(res, 400, { + type: problem.VALIDATION_FAILED, + title: 'Validation failed', + errors: issuesOf(result), + }); } replace(req, source, result.value); } diff --git a/packages/server/test/ApiTest.js b/packages/server/test/ApiTest.js index 918e05f8..aadd85ce 100644 --- a/packages/server/test/ApiTest.js +++ b/packages/server/test/ApiTest.js @@ -17,8 +17,13 @@ describe('API', function() { const res = await agent.post('/api/books', { body: { title: 'Dune', pages: 'many' } }); assert.strictEqual(res.statusCode, 400); assert.strictEqual(res.data.status, 400); + assert.strictEqual(res.data.type, 'urn:igo:validation-failed'); assert.strictEqual(res.data.title, 'Validation failed'); - assert.deepStrictEqual(res.data.errors.map(e => e.path), ['pages']); + assert.deepStrictEqual(res.data.errors, [{ + path: 'pages', + code: 'invalid_type', + message: 'Invalid input: expected number, received string', + }]); }); it('should report every invalid field', async () => { diff --git a/packages/server/test/api/problemTest.js b/packages/server/test/api/problemTest.js index 13cd6743..617d1fee 100644 --- a/packages/server/test/api/problemTest.js +++ b/packages/server/test/api/problemTest.js @@ -52,8 +52,18 @@ describe('api/problem', function() { assert.deepStrictEqual(doc.errors, [{ path: 'a' }]); }); - it('should fall back to a generic title', () => { - assert.strictEqual(problem.problem(418).title, 'Error'); + it('should title any status from the HTTP registry', () => { + assert.strictEqual(problem.problem(409).title, 'Conflict'); + assert.strictEqual(problem.problem(429).title, 'Too Many Requests'); + }); + + it('should fall back to a generic title on an unknown status', () => { + assert.strictEqual(problem.problem(799).title, 'Error'); + }); + + it('should carry an application type when given', () => { + assert.strictEqual(problem.problem(409, { type: '/problems/out-of-stock' }).type, + '/problems/out-of-stock'); }); }); }); From 39fd3c4d24fe40f7742cafb431ec4a38a2b9d074 Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Mon, 7 Sep 2026 11:50:15 +0200 Subject: [PATCH 07/80] docs(server): expliquer pourquoi le type d'igo est un URN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /problems/ est un URI relatif : il appartient au domaine de l'application. Les erreurs du framework ne peuvent pas y vivre — le même problème aurait un identifiant différent sur chaque projet, et empiéterait sur les slugs de l'application. La doc proposait la convention sans dire pourquoi igo ne la suit pas lui-même. Co-Authored-By: Claude Opus 5 (1M context) --- docs/server/api.md | 21 +++++++++++++++------ packages/server/src/api/problem.js | 4 +++- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/docs/server/api.md b/docs/server/api.md index ee9a8c6b..b127995b 100644 --- a/docs/server/api.md +++ b/docs/server/api.md @@ -119,7 +119,8 @@ Two levels, both meant to be branched on: - **`type`** identifies the problem itself. It is a URI, and it does not have to resolve to anything. igo sets `urn:igo:validation-failed` when a schema - rejects a request; otherwise it stays `about:blank`, which the RFC defines as + rejects a request — the `urn:igo:` namespace marks the errors the framework + itself produces. Otherwise it stays `about:blank`, which the RFC defines as "no specific type — the status says it all". - **`errors[].code`** identifies what is wrong with one field: `invalid_type`, `too_small`, `invalid_format`, `invalid_value`… These come from the schema @@ -172,11 +173,19 @@ status code. A 404 rarely needs one; a 409 or a 422 usually does. 409, `Too Many Requests` for 429), so pass it only when you have something more precise to say. -**A convention, not a rule.** igo suggests `/problems/` — short, readable, -and easy to serve documentation from later if you want to. URNs -(`urn:myapp:out-of-stock`) and full URLs work just as well. The RFC only asks -for a URI that stays stable, since clients branch on it. Pick one form and keep -it across the project. +**A convention, not a rule.** igo suggests `/problems/` — a relative URI, +so it resolves against your own origin and you can serve documentation there +later if you want to. URNs (`urn:myapp:out-of-stock`) and absolute URLs work +just as well. The RFC only asks for a URI that stays stable, since clients +branch on it. Pick one form and keep it across the project. + +::: tip Why igo's own type is a URN +`/problems/` belongs to your application: it resolves against your domain, +and the slugs are yours to define. igo's own errors cannot live there — the same +framework error would get a different identifier on every project, and would +compete with your slugs. Hence `urn:igo:validation-failed`: one namespace for +the framework, identical everywhere, and `/problems/*` left entirely to you. +::: Outside production a 500 includes the error message as `detail`; in production it is omitted. Crash emails are unaffected — only the response format changes. diff --git a/packages/server/src/api/problem.js b/packages/server/src/api/problem.js index 2faf1b25..d5fb8b6a 100644 --- a/packages/server/src/api/problem.js +++ b/packages/server/src/api/problem.js @@ -6,7 +6,9 @@ const config = require('../config'); const CONTENT_TYPE = 'application/problem+json'; // igo produces one problem specific enough to name: everything else is -// identified by its status alone. Applications define their own types. +// identified by its status alone. A URN rather than the /problems/ form +// suggested to applications — a relative URI would resolve differently on every +// project, and would compete with the slugs the application defines. const VALIDATION_FAILED = 'urn:igo:validation-failed'; // A request is served as JSON when it targets the API prefix, or when the From ffde138c2ade33f3fca356fdecfa758f586f076e Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Mon, 7 Sep 2026 11:53:45 +0200 Subject: [PATCH 08/80] docs(server): signaler que l'ordre de montage des routes API compte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wire() placé avant routes.init() ne voit aucune route et n'enveloppe rien : la validation devient inactive sans erreur ni warning. Co-Authored-By: Claude Opus 5 (1M context) --- packages/server/src/routes.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/server/src/routes.js b/packages/server/src/routes.js index aa6e8d80..8d2c3004 100644 --- a/packages/server/src/routes.js +++ b/packages/server/src/routes.js @@ -6,6 +6,8 @@ const routes = require(config.projectRoot + '/app/routes'); // module.exports.init = function(app) { + // order matters: init() adds app.api(), which the project calls in its own + // routes, and wire() applies the schemas of the handlers it just declared. api.init(app); routes.init(app); From c745d9aa6081676aa85d78c500e51adc8d6a8746 Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Mon, 7 Sep 2026 12:03:07 +0200 Subject: [PATCH 09/80] =?UTF-8?q?chore:=20passer=20=C3=A0=20TypeScript=207?= =?UTF-8?q?=20et=20cibler=20Node=2024=20sur=20les=20squelettes=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TypeScript 7 est la version stable ; le monorepo était resté en 5. La montée révèle deux ruptures que seul un squelette généré à blanc pouvait montrer : - moduleResolution 'node' (node10) est supprimé — retiré du tsconfig. - les globales ambiantes ne sont plus incluses d'office — types: [node, mocha] déclaré explicitement, sans quoi describe/it ne compilent pas. - typescript/bin/tsc n'est plus résolvable (exports map) : typesTest passe par le manifeste du paquet, ce qui marche de TS 5 à 7. Au passage, son fallback this.skip() ne pouvait pas fonctionner dans une arrow function. Les nouveaux projets démarrent sur Node 24 : engines sur les deux squelettes API, @types/node 24, cible ES2023. Vérifié à blanc : typecheck, build, 7 tests et l'inférence depuis les schémas. Co-Authored-By: Claude Opus 5 (1M context) --- package-lock.json | 355 +++++++++++++++++++++- package.json | 2 +- packages/server/skel/api-ts/package.json | 13 +- packages/server/skel/api-ts/tsconfig.json | 6 +- packages/server/skel/api/package.json | 3 + packages/server/test/api/typesTest.js | 15 +- 6 files changed, 375 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6e4d885c..ccbc3503 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "husky": "^9.1.7", "lint-staged": "^17.3.0", "mocha": "^11.8.0", - "typescript": "^5.9.3", + "typescript": "^7.0.2", "vitepress": "^1.6.4" } }, @@ -2909,6 +2909,326 @@ "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "license": "MIT" }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", @@ -11047,17 +11367,38 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "devOptional": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "tsc": "bin/tsc" }, "engines": { - "node": ">=14.17" + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, "node_modules/uid-safe": { diff --git a/package.json b/package.json index 908640bb..1a6e87ab 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "husky": "^9.1.7", "lint-staged": "^17.3.0", "mocha": "^11.8.0", - "typescript": "^5.9.3", + "typescript": "^7.0.2", "vitepress": "^1.6.4" }, "lint-staged": { diff --git a/packages/server/skel/api-ts/package.json b/packages/server/skel/api-ts/package.json index 3064ec9b..a06884e9 100644 --- a/packages/server/skel/api-ts/package.json +++ b/packages/server/skel/api-ts/package.json @@ -12,15 +12,18 @@ }, "author": "", "license": "ISC", + "engines": { + "node": ">=24" + }, "dependencies": { "@igojs/igo": "{igo.version}", "zod": "^4.5.4" }, "devDependencies": { - "@types/express": "^5.0.0", - "@types/mocha": "^10.0.0", - "@types/node": "^22.0.0", - "tsx": "^4.19.0", - "typescript": "^5.9.0" + "@types/express": "^5.0.6", + "@types/mocha": "^10.0.10", + "@types/node": "^24.0.0", + "tsx": "^4.20.0", + "typescript": "^7.0.0" } } diff --git a/packages/server/skel/api-ts/tsconfig.json b/packages/server/skel/api-ts/tsconfig.json index 773daf90..4ebd94ff 100644 --- a/packages/server/skel/api-ts/tsconfig.json +++ b/packages/server/skel/api-ts/tsconfig.json @@ -1,8 +1,7 @@ { "compilerOptions": { - "target": "ES2022", + "target": "ES2023", "module": "commonjs", - "moduleResolution": "node", "strict": true, "esModuleInterop": true, "allowJs": true, @@ -10,7 +9,8 @@ "outDir": "dist", "rootDir": ".", "sourceMap": true, - "skipLibCheck": true + "skipLibCheck": true, + "types": ["node", "mocha"] }, "include": ["app.ts", "app/**/*.ts", "test/**/*.ts"], "exclude": ["node_modules", "dist"] diff --git a/packages/server/skel/api/package.json b/packages/server/skel/api/package.json index 45cccb47..e4f7fcbe 100644 --- a/packages/server/skel/api/package.json +++ b/packages/server/skel/api/package.json @@ -10,6 +10,9 @@ }, "author": "", "license": "ISC", + "engines": { + "node": ">=24" + }, "dependencies": { "@igojs/igo": "{igo.version}", "zod": "^4.5.4" diff --git a/packages/server/test/api/typesTest.js b/packages/server/test/api/typesTest.js index 79e7f92e..20159796 100644 --- a/packages/server/test/api/typesTest.js +++ b/packages/server/test/api/typesTest.js @@ -11,11 +11,20 @@ const PROJECT = path.join(__dirname, '..', 'types', 'tsconfig.json'); describe('api/types', function() { this.timeout(60000); - it('should typecheck the declarations against a TypeScript consumer', () => { - let tsc; + // TypeScript 7 restricts its exports map, so its internal paths cannot be + // resolved directly: locate the binary through the package manifest instead. + const findTsc = () => { try { - tsc = require.resolve('typescript/bin/tsc'); + const pkg = require.resolve('typescript/package.json'); + return path.join(path.dirname(pkg), 'bin', 'tsc'); } catch { + return null; + } + }; + + it('should typecheck the declarations against a TypeScript consumer', function() { + const tsc = findTsc(); + if (!tsc) { return this.skip(); } From f03f2336b0d6a29d44fb7ea924cfd5b35888a2f7 Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Mon, 7 Sep 2026 12:15:32 +0200 Subject: [PATCH 10/80] chore(server): accepter mocha 12 en peer dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mocha 12 est sorti fin août ; la plage ^11.0.0 le refusait, au point que npm bloquait l'installation. Les 724 tests des quatre paquets passent en 12. Trouvé en essayant pnpm sur un squelette généré : son mode strict signale les peers non satisfaits, là où npm installe en silence. Co-Authored-By: Claude Opus 5 (1M context) --- package-lock.json | 170 ++++++++++++++++++++++++++++++++++- packages/server/package.json | 5 +- 2 files changed, 173 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index ccbc3503..70857184 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4099,6 +4099,7 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true, "license": "ISC" }, "node_modules/browserslist": { @@ -5100,6 +5101,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -5209,6 +5211,7 @@ "version": "8.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -5933,6 +5936,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, "license": "MIT", "dependencies": { "locate-path": "^6.0.0", @@ -6332,6 +6336,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, "license": "MIT", "bin": { "he": "bin/he" @@ -6750,6 +6755,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6759,6 +6765,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6804,6 +6811,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -7179,6 +7187,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, "license": "MIT", "dependencies": { "p-locate": "^5.0.0" @@ -7212,6 +7221,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.1.0", @@ -8223,6 +8233,7 @@ "version": "11.8.0", "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.8.0.tgz", "integrity": "sha512-VyCeUdGN3A9lmCTTgG4yuvY9ixxaDk+xt2R/7/+1AP6EqNG+G9OKkzBwhVtVYoNX8YsxNSgAl8mOv3IAeOpFbw==", + "dev": true, "license": "MIT", "dependencies": { "browser-stdout": "^1.3.1", @@ -8259,12 +8270,14 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, "license": "MIT" }, "node_modules/mocha/node_modules/brace-expansion": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -8274,6 +8287,7 @@ "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.2" @@ -8707,6 +8721,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" @@ -8722,6 +8737,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, "license": "MIT", "dependencies": { "p-limit": "^3.0.2" @@ -10881,6 +10897,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -12082,6 +12099,7 @@ "version": "9.3.4", "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==", + "dev": true, "license": "Apache-2.0" }, "node_modules/wrap-ansi": { @@ -12268,6 +12286,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, "license": "MIT", "dependencies": { "camelcase": "^6.0.0", @@ -12327,6 +12346,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -12542,11 +12562,14 @@ "bin": { "igo": "cli/igo.js" }, + "devDependencies": { + "mocha": "^12.0.0" + }, "peerDependencies": { "autoprefixer": "^10.4.0", "express": "^5.0.0", "i18next": "^26.0.0", - "mocha": "^11.0.0", + "mocha": "^11.0.0 || ^12.0.0", "nodemon": "^3.0.0", "postcss": "^8.0.0", "redis": "^6.0.0", @@ -12627,6 +12650,16 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, + "packages/server/node_modules/diff": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "packages/server/node_modules/enhanced-resolve": { "version": "5.24.5", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", @@ -12676,6 +12709,24 @@ "node": ">=14.14" } }, + "packages/server/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "packages/server/node_modules/http-proxy-middleware": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-4.2.0.tgz", @@ -12725,6 +12776,86 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/server/node_modules/js-yaml": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.1.tgz", + "integrity": "sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, + "packages/server/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "packages/server/node_modules/mocha": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-12.0.0.tgz", + "integrity": "sha512-NYNh5IFt6WYqm9bi4601m7vix8MZdXC0DwS4gY6WhXO2RgJWhivhISVmq1oklCif18OSI9l+vx5Mdm5oh1XGiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "browser-stdout": "^1.3.1", + "chokidar": "^5.0.0", + "debug": "^4.3.5", + "diff": "^9.0.0", + "find-up": "^5.0.0", + "glob": "^13.0.0", + "is-path-inside": "^3.0.3", + "is-unicode-supported": "^0.1.0", + "js-yaml": "^5.0.0", + "minimatch": "^10.2.2", + "ms": "^2.1.3", + "picocolors": "^1.1.1", + "serialize-javascript": "^7.0.2", + "strip-json-comments": "^5.0.3", + "supports-color": "^8.1.1", + "workerpool": "^10.0.0" + }, + "bin": { + "mocha": "bin/mocha.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "packages/server/node_modules/mocha/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "packages/server/node_modules/nodemailer": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz", @@ -12769,6 +12900,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/server/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "packages/server/node_modules/qs": { "version": "6.15.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", @@ -12829,6 +12977,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "packages/server/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/server/node_modules/supports-color": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", @@ -13037,6 +13198,13 @@ "node": ">=10.13.0" } }, + "packages/server/node_modules/workerpool": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-10.0.3.tgz", + "integrity": "sha512-6z2Iis68Wqth93/G/wJP9u+R3O+d2XTlgWChGCwuT1qLbBsOYueGRZuJ++v3mtDP5KjYdy+WzvWC+VWETSVXJA==", + "dev": true, + "license": "Apache-2.0" + }, "packages/server/node_modules/wsl-utils": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-1.0.0.tgz", diff --git a/packages/server/package.json b/packages/server/package.json index 4b7de37f..80381758 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -64,10 +64,13 @@ "autoprefixer": "^10.4.0", "express": "^5.0.0", "i18next": "^26.0.0", - "mocha": "^11.0.0", + "mocha": "^11.0.0 || ^12.0.0", "nodemon": "^3.0.0", "postcss": "^8.0.0", "redis": "^6.0.0", "sass": "^1.0.0" + }, + "devDependencies": { + "mocha": "^12.0.0" } } From 7622837050914e0e6583f4b2ad36e8a21355062d Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Mon, 7 Sep 2026 12:34:32 +0200 Subject: [PATCH 11/80] =?UTF-8?q?feat(server):=20rendre=20le=20crash=20sur?= =?UTF-8?q?=20exception=20non=20captur=C3=A9e=20configurable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit process.exit(1) était inconditionnel : une exception survenue pendant une requête déjà répondue tuait quand même le serveur. config.exitOnUncaughtException = false permet de rester en vie dans ce cas précis. Hors contexte de requête, on sort toujours, même option désactivée : Node ne garantit rien sur l'état du process et rien ne peut en répondre. Le défaut reste inchangé — l'option n'a de sens qu'une fois l'alerting détaché du crash → mail. Le script du test vit dans fixtures/*.cjs : sous test/**/*.js, mocha le chargeait comme un fichier de test et il tuait le runner, tronquant la suite sans message d'échec. Co-Authored-By: Claude Opus 5 (1M context) --- docs/server/errors.md | 9 +++++ packages/server/index.d.ts | 2 + packages/server/src/config.js | 4 ++ packages/server/src/connect/errorhandler.js | 13 ++++++- packages/server/test/UncaughtExceptionTest.js | 36 +++++++++++++++++ packages/server/test/fixtures/uncaught.cjs | 39 +++++++++++++++++++ 6 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 packages/server/test/UncaughtExceptionTest.js create mode 100644 packages/server/test/fixtures/uncaught.cjs diff --git a/docs/server/errors.md b/docs/server/errors.md index af9ddffd..8ba73511 100644 --- a/docs/server/errors.md +++ b/docs/server/errors.md @@ -22,6 +22,15 @@ If a promise rejects without a catch and the error happens within a request cont Fatal errors that escape all handlers are logged, an email is sent, and the process exits after 1 second. Use a process manager like PM2 to restart automatically. +Node gives no guarantee about the state of a process that reached this point, so restarting is the default. Once your alerting no longer depends on the crash email to notice an error, you can keep serving: + +```js +// app/config.js +config.exitOnUncaughtException = false; +``` + +The server then stays up **only** when the exception happened during a request that was already answered. An exception raised outside any request still exits, since nothing can vouch for the process state. + ## Special Cases | Error type | Response | Email sent? | diff --git a/packages/server/index.d.ts b/packages/server/index.d.ts index 07bbd0b8..d3e82d41 100644 --- a/packages/server/index.d.ts +++ b/packages/server/index.d.ts @@ -42,6 +42,8 @@ export interface Config { cookieSecret: string; cookieSession: CookieSessionConfig; mailcrashto?: string | string[]; + /** false keeps the server alive after an uncaught exception a request already answered. */ + exitOnUncaughtException: boolean; loglevel: string; [key: string]: unknown; } diff --git a/packages/server/src/config.js b/packages/server/src/config.js index 516a5930..1e952a07 100644 --- a/packages/server/src/config.js +++ b/packages/server/src/config.js @@ -35,6 +35,10 @@ module.exports.init = function() { // routes under this prefix answer in JSON, never in HTML config.api = { prefix: '/api' }; + // set to false to keep serving after an uncaught exception that a request + // already answered — only once alerting no longer relies on the crash email + config.exitOnUncaughtException = true; + config.i18n = { whitelist: [ 'en', 'fr' ], preload: [ 'en', 'fr' ], diff --git a/packages/server/src/connect/errorhandler.js b/packages/server/src/connect/errorhandler.js index 03cea20e..4f3c2ef9 100644 --- a/packages/server/src/connect/errorhandler.js +++ b/packages/server/src/connect/errorhandler.js @@ -17,6 +17,8 @@ * - Logs error and sends email notification * - Forces process.exit(1) after 1 second * - Process manager (PM2, systemd) will restart the server + * - config.exitOnUncaughtException = false keeps the server alive when the + * request was already answered (never outside a request context) * * Special cases: * - URIError (malformed URL): returns 404 @@ -262,8 +264,9 @@ process.on('unhandledRejection', (err) => { // Handle uncaught exceptions - log, send email, then exit process.on('uncaughtException', (err) => { const context = asyncLocalStorage.getStore(); + const handled = !!(context && context.req && context.res); - if (context && context.req && context.res) { + if (handled) { handle(err, context.req, context.res); } else { logger.error('Uncaught exception outside of request context:', err); @@ -271,6 +274,14 @@ process.on('uncaughtException', (err) => { sendCrashEmail(`Uncaught exception: ${err}`, `
${escapeHtml(err.stack)}
`, String(err)); } + // Node makes no promise about the state of a process that reached this point, + // so restarting is the safe default. A request that was handled and answered + // is the case worth keeping alive, once alerting no longer relies on the + // crash email to notice the error. + if (config.exitOnUncaughtException === false && handled) { + return; + } + // Exit after a short delay to allow email to be sent setTimeout(() => { process.exit(1); diff --git a/packages/server/test/UncaughtExceptionTest.js b/packages/server/test/UncaughtExceptionTest.js new file mode 100644 index 00000000..749a2919 --- /dev/null +++ b/packages/server/test/UncaughtExceptionTest.js @@ -0,0 +1,36 @@ +require('./init'); + +const assert = require('assert'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +// .cjs, outside the test glob: mocha would otherwise load it as a test +// file and the script would exit the runner itself. +const SCRIPT = path.join(__dirname, 'fixtures', 'uncaught.cjs'); + +// process.exit() cannot be observed from inside the test process: run each +// scenario in a child and read its exit code. +const run = (mode) => { + try { + execFileSync(process.execPath, [SCRIPT, mode], { encoding: 'utf8', stdio: 'pipe' }); + return 0; + } catch (err) { + return err.status; + } +}; + +describe('ErrorHandler uncaught exceptions', function() { + this.timeout(20000); + + it('should exit by default, so a process manager restarts a broken server', () => { + assert.strictEqual(run('default'), 1); + }); + + it('should keep serving when the request was answered and exit is disabled', () => { + assert.strictEqual(run('survive'), 0); + }); + + it('should exit even when disabled, if the exception happened outside a request', () => { + assert.strictEqual(run('no-context'), 1); + }); +}); diff --git a/packages/server/test/fixtures/uncaught.cjs b/packages/server/test/fixtures/uncaught.cjs new file mode 100644 index 00000000..471b3eef --- /dev/null +++ b/packages/server/test/fixtures/uncaught.cjs @@ -0,0 +1,39 @@ +// Driven by UncaughtExceptionTest: raises an uncaught exception in a child +// process so its exit code can be observed. +process.env.NODE_ENV = 'test'; + +const mode = process.argv[2]; + +const config = require('../../src/config'); +config.init(); +config.exitOnUncaughtException = mode === 'default'; + +const errorhandler = require('../../src/connect/errorhandler'); +const logger = require('../../src/logger'); + +logger.error = () => {}; + +const fakeReq = () => ({ + method: 'GET', originalUrl: '/x', url: '/x', path: '/x', protocol: 'http', + headers: { host: 'localhost' }, get: () => '', body: {}, session: {}, +}); + +const fakeRes = () => { + const res = { headersSent: false, statusCode: 200, setHeader: () => {} }; + res.status = (code) => { res.statusCode = code; return res; }; + res.render = () => res; + res.send = () => res; + res.json = () => res; + return res; +}; + +const raise = () => process.emit('uncaughtException', new Error('boom')); + +if (mode === 'no-context') { + raise(); +} else { + errorhandler.initContext({})(fakeReq(), fakeRes(), raise); +} + +// only reached when the handler chose not to exit +setTimeout(() => process.exit(0), 1500); From 7fa707ff383f0585b6a330840c7b54395559b42f Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Mon, 7 Sep 2026 12:42:39 +0200 Subject: [PATCH 12/80] feat(server)!: un seul squelette API, en TypeScript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les nouveaux projets partent en TypeScript ; la variante JavaScript n'aurait pas eu de client. skel/api-ts devient skel/api, l'ancien skel/api disparaît. Fait avant publication : après, retirer une valeur de --skel aurait été cassant. Les refontes ne passent pas par le squelette — leur projet existe déjà — mais skel/api reste leur référence de structure. Un projet JS peut d'ailleurs charger du .ts sans build via tsx, ce qui rend l'ajout d'une API TypeScript à un back existant progressif. Co-Authored-By: Claude Opus 5 (1M context) --- docs/feuille-de-route-socle-igo.md | 2 +- docs/server/api.md | 7 +- docs/server/getting-started.md | 9 +- packages/server/cli/create.js | 2 +- packages/server/skel/api-ts/README.md | 86 ----------------- packages/server/skel/api-ts/_.gitignore | 8 -- packages/server/skel/api-ts/_.mocharc.json | 12 --- .../skel/api-ts/locales/en/translation.json | 3 - packages/server/skel/api-ts/package.json | 29 ------ .../server/skel/api-ts/sql/20260101-books.sql | 9 -- packages/server/skel/api/README.md | 41 ++++++--- packages/server/skel/api/_.gitignore | 2 + packages/server/skel/api/_.mocharc.json | 7 +- packages/server/skel/api/app.js | 5 - packages/server/skel/{api-ts => api}/app.ts | 0 .../api/app/api/books/books.controller.js | 59 ------------ .../app/api/books/books.controller.ts | 0 .../skel/api/app/api/books/books.dto.js | 40 -------- .../app/api/books/books.dto.ts | 0 .../skel/api/app/api/books/books.routes.js | 14 --- .../app/api/books/books.routes.ts | 0 packages/server/skel/api/app/config.js | 5 - .../server/skel/{api-ts => api}/app/config.ts | 0 packages/server/skel/api/app/models/Book.js | 19 ---- .../skel/{api-ts => api}/app/models/Book.ts | 0 packages/server/skel/api/app/routes.js | 13 --- .../server/skel/{api-ts => api}/app/routes.ts | 0 packages/server/skel/api/eslint.config.js | 35 ------- packages/server/skel/api/nodemon.json | 11 --- packages/server/skel/api/package.json | 16 +++- .../server/skel/api/test/api/BooksTest.js | 92 ------------------- .../{api-ts => api}/test/api/BooksTest.ts | 0 .../server/skel/{api-ts => api}/tsconfig.json | 0 packages/server/test/CreateTest.js | 8 +- 34 files changed, 59 insertions(+), 475 deletions(-) delete mode 100644 packages/server/skel/api-ts/README.md delete mode 100644 packages/server/skel/api-ts/_.gitignore delete mode 100644 packages/server/skel/api-ts/_.mocharc.json delete mode 100644 packages/server/skel/api-ts/locales/en/translation.json delete mode 100644 packages/server/skel/api-ts/package.json delete mode 100644 packages/server/skel/api-ts/sql/20260101-books.sql delete mode 100644 packages/server/skel/api/app.js rename packages/server/skel/{api-ts => api}/app.ts (100%) delete mode 100644 packages/server/skel/api/app/api/books/books.controller.js rename packages/server/skel/{api-ts => api}/app/api/books/books.controller.ts (100%) delete mode 100644 packages/server/skel/api/app/api/books/books.dto.js rename packages/server/skel/{api-ts => api}/app/api/books/books.dto.ts (100%) delete mode 100644 packages/server/skel/api/app/api/books/books.routes.js rename packages/server/skel/{api-ts => api}/app/api/books/books.routes.ts (100%) delete mode 100644 packages/server/skel/api/app/config.js rename packages/server/skel/{api-ts => api}/app/config.ts (100%) delete mode 100644 packages/server/skel/api/app/models/Book.js rename packages/server/skel/{api-ts => api}/app/models/Book.ts (100%) delete mode 100644 packages/server/skel/api/app/routes.js rename packages/server/skel/{api-ts => api}/app/routes.ts (100%) delete mode 100644 packages/server/skel/api/eslint.config.js delete mode 100644 packages/server/skel/api/nodemon.json delete mode 100644 packages/server/skel/api/test/api/BooksTest.js rename packages/server/skel/{api-ts => api}/test/api/BooksTest.ts (100%) rename packages/server/skel/{api-ts => api}/tsconfig.json (100%) diff --git a/docs/feuille-de-route-socle-igo.md b/docs/feuille-de-route-socle-igo.md index 76f712a2..e2ac14b0 100644 --- a/docs/feuille-de-route-socle-igo.md +++ b/docs/feuille-de-route-socle-igo.md @@ -23,7 +23,7 @@ Les améliorations sont cumulatives : ce qui sert aux refontes sert aussi aux gr | **Réponses d'erreur JSON** | Sous le préfixe API, tout répond en JSON au format RFC 9457 — 500, 404 et erreurs de validation | Faible | | **Middleware de validation Zod** | Middleware global monté par igo ; le schéma est attaché au handler, rien à écrire dans les routes | Faible | | **Déclarations TypeScript** | `.d.ts` sur l'API publique — les schémas Zod deviennent la source des types, sans impact sur les projets JS | Moyen | -| **Squelettes API** | `skel/api` (JS) et `skel/api-ts` (TypeScript), à côté de `skel/tailwind` | Moyen | +| **Squelette API** | `skel/api` — TypeScript, à côté de `skel/tailwind` | Moyen | ## Phase 2 — Première refonte front diff --git a/docs/server/api.md b/docs/server/api.md index b127995b..c75d1493 100644 --- a/docs/server/api.md +++ b/docs/server/api.md @@ -254,9 +254,8 @@ loaded at runtime. A JavaScript project is unaffected. ## Starting a new API project ```bash -igo create myapi --skel=api # JavaScript -igo create myapi --skel=api-ts # TypeScript +igo create myapi --skel=api ``` -Both come with a working domain — model, DTO, controller, routes, migration and -integration tests. +TypeScript, with a working domain — model, DTO, controller, routes, migration +and integration tests. diff --git a/docs/server/getting-started.md b/docs/server/getting-started.md index 4a13576c..0ff1c9d6 100644 --- a/docs/server/getting-started.md +++ b/docs/server/getting-started.md @@ -34,15 +34,14 @@ npm start ### Skeletons -`create` scaffolds a server-rendered project by default. For a JSON API with no -views and no bundler: +`create` scaffolds a server-rendered project by default. For a TypeScript JSON +API with no views and no bundler: ```sh -npx @igojs/server create myapi --skel=api # JavaScript -npx @igojs/server create myapi --skel=api-ts # TypeScript +npx @igojs/server create myapi --skel=api ``` -Both ship a working domain — model, DTO, controller, routes, migration and +It ships a working domain — model, DTO, controller, routes, migration and integration tests. See [JSON APIs](./api). ## Minimal app diff --git a/packages/server/cli/create.js b/packages/server/cli/create.js index 244a8160..8c8c4228 100644 --- a/packages/server/cli/create.js +++ b/packages/server/cli/create.js @@ -55,7 +55,7 @@ const replaceInDirectory = async (dir, replacements) => { }; // igo create -const SKELETONS = ['tailwind', 'api', 'api-ts']; +const SKELETONS = ['tailwind', 'api']; module.exports = async function (argv) { const args = argv._; diff --git a/packages/server/skel/api-ts/README.md b/packages/server/skel/api-ts/README.md deleted file mode 100644 index c21eb121..00000000 --- a/packages/server/skel/api-ts/README.md +++ /dev/null @@ -1,86 +0,0 @@ -# {project.name} - -API JSON TypeScript sur [igo](https://github.com/igocreate/igo). - -## Démarrer - -```bash -npm install -npm start # tsx watch, rechargement à chaud -npm test # mocha via tsx, base de test recréée à chaque run -npm run typecheck # tsc --noEmit -npm run build # compile vers dist/ -npm run serve # lance le build -``` - -## Structure - -``` -app/ - api/ - books/ ← un dossier par domaine - books.routes.ts ← les endpoints - books.controller.ts ← thin : service/modèle → DTO - books.dto.ts ← schémas entrants + sérialisation sortante - models/ ← modèles ORM - config.ts - routes.ts ← montage des routes -sql/ ← migrations -``` - -## Conventions - -**Les routes API se montent avec `app.api()`** — le préfixe (`/api`) vient de -`config.api.prefix`, jamais répété dans le code : - -```ts -app.api('/books', books); // -> /api/books -``` - -**La validation est automatique.** Le schéma s'attache au handler, igo -l'applique avant que le contrôleur ne tourne : - -```ts -export const create: ApiHandler<{ body: typeof dto.CreateBook }> = async (req, res) => { - req.body.pages; // number — typé depuis le schéma -}; -create.body = dto.CreateBook; -``` - -`req.body` et `req.query` contiennent la valeur validée — coercitions et -valeurs par défaut comprises. Plus de `parseInt(req.query.page)`. - -**Les erreurs sont en JSON**, au format [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) : - -```json -{ - "type": "urn:igo:validation-failed", - "title": "Validation failed", - "status": 400, - "errors": [{ "path": "pages", "code": "invalid_type", "message": "..." }] -} -``` - -Tout ce qui vit sous `/api` répond en JSON — y compris les 404 et les 500. - -Le client se branche sur `type` et `errors[].code`, jamais sur `title` ni -`message` — ce sont des libellés d'affichage. Pour une erreur métier, on pose -son propre type : - -```js -sendProblem(res, 409, { type: '/problems/out-of-stock', title: 'Book is out of stock' }); -``` - -**Le DTO est la barrière.** Le front ne voit jamais un modèle ORM brut : -ajouter une colonne au modèle n'expose rien tant qu'elle n'est pas nommée dans -`serialize()`. - -## TypeScript - -**Les schémas Zod sont la source des types.** `ApiHandler<{ body: typeof -CreateBook }>` donne à `req.body` la forme validée : aucune interface à -maintenir en double, et un champ absent du schéma est une erreur de -compilation. - -igo reste du JavaScript — les types viennent de fichiers `.d.ts` livrés avec -le paquet. Rien n'est compilé côté framework. diff --git a/packages/server/skel/api-ts/_.gitignore b/packages/server/skel/api-ts/_.gitignore deleted file mode 100644 index 45b5ab27..00000000 --- a/packages/server/skel/api-ts/_.gitignore +++ /dev/null @@ -1,8 +0,0 @@ - -node_modules - -.env -.DS_Store -*.log - -dist diff --git a/packages/server/skel/api-ts/_.mocharc.json b/packages/server/skel/api-ts/_.mocharc.json deleted file mode 100644 index f9317747..00000000 --- a/packages/server/skel/api-ts/_.mocharc.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "recursive": true, - "extension": [ - "ts" - ], - "require": [ - "tsx" - ], - "reporter": "dot", - "exit": true, - "timeout": 10000 -} diff --git a/packages/server/skel/api-ts/locales/en/translation.json b/packages/server/skel/api-ts/locales/en/translation.json deleted file mode 100644 index f42a0b2a..00000000 --- a/packages/server/skel/api-ts/locales/en/translation.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "title": "Igo running here" -} diff --git a/packages/server/skel/api-ts/package.json b/packages/server/skel/api-ts/package.json deleted file mode 100644 index a06884e9..00000000 --- a/packages/server/skel/api-ts/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "{project.name}", - "version": "0.0.1", - "description": "", - "main": "dist/app.js", - "scripts": { - "build": "tsc && cp -R sql locales dist/", - "start": "tsx watch app.ts", - "serve": "cd dist && node app.js", - "test": "mocha", - "typecheck": "tsc --noEmit" - }, - "author": "", - "license": "ISC", - "engines": { - "node": ">=24" - }, - "dependencies": { - "@igojs/igo": "{igo.version}", - "zod": "^4.5.4" - }, - "devDependencies": { - "@types/express": "^5.0.6", - "@types/mocha": "^10.0.10", - "@types/node": "^24.0.0", - "tsx": "^4.20.0", - "typescript": "^7.0.0" - } -} diff --git a/packages/server/skel/api-ts/sql/20260101-books.sql b/packages/server/skel/api-ts/sql/20260101-books.sql deleted file mode 100644 index 5f0ab4b7..00000000 --- a/packages/server/skel/api-ts/sql/20260101-books.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE books ( - id INT NOT NULL AUTO_INCREMENT, - title VARCHAR(255) NOT NULL, - author VARCHAR(255) NOT NULL, - pages INT NOT NULL, - published TINYINT(1) NOT NULL DEFAULT 0, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (id) -); diff --git a/packages/server/skel/api/README.md b/packages/server/skel/api/README.md index 02379b4a..c21eb121 100644 --- a/packages/server/skel/api/README.md +++ b/packages/server/skel/api/README.md @@ -1,13 +1,16 @@ # {project.name} -API JSON sur [igo](https://github.com/igocreate/igo). +API JSON TypeScript sur [igo](https://github.com/igocreate/igo). ## Démarrer ```bash npm install -npm start # nodemon sur app.js -npm test # mocha, base de test recréée à chaque run +npm start # tsx watch, rechargement à chaud +npm test # mocha via tsx, base de test recréée à chaque run +npm run typecheck # tsc --noEmit +npm run build # compile vers dist/ +npm run serve # lance le build ``` ## Structure @@ -16,12 +19,12 @@ npm test # mocha, base de test recréée à chaque run app/ api/ books/ ← un dossier par domaine - books.routes.js ← les endpoints - books.controller.js ← thin : service/modèle → DTO - books.dto.js ← schémas entrants + sérialisation sortante + books.routes.ts ← les endpoints + books.controller.ts ← thin : service/modèle → DTO + books.dto.ts ← schémas entrants + sérialisation sortante models/ ← modèles ORM - config.js - routes.js ← montage des routes + config.ts + routes.ts ← montage des routes sql/ ← migrations ``` @@ -30,16 +33,18 @@ sql/ ← migrations **Les routes API se montent avec `app.api()`** — le préfixe (`/api`) vient de `config.api.prefix`, jamais répété dans le code : -```js -app.api('/books', require('./api/books/books.routes')); // -> /api/books +```ts +app.api('/books', books); // -> /api/books ``` **La validation est automatique.** Le schéma s'attache au handler, igo l'applique avant que le contrôleur ne tourne : -```js -exports.create.body = dto.CreateBook; -exports.index.query = dto.ListBooks; +```ts +export const create: ApiHandler<{ body: typeof dto.CreateBook }> = async (req, res) => { + req.body.pages; // number — typé depuis le schéma +}; +create.body = dto.CreateBook; ``` `req.body` et `req.query` contiennent la valeur validée — coercitions et @@ -69,3 +74,13 @@ sendProblem(res, 409, { type: '/problems/out-of-stock', title: 'Book is out of s **Le DTO est la barrière.** Le front ne voit jamais un modèle ORM brut : ajouter une colonne au modèle n'expose rien tant qu'elle n'est pas nommée dans `serialize()`. + +## TypeScript + +**Les schémas Zod sont la source des types.** `ApiHandler<{ body: typeof +CreateBook }>` donne à `req.body` la forme validée : aucune interface à +maintenir en double, et un champ absent du schéma est une erreur de +compilation. + +igo reste du JavaScript — les types viennent de fichiers `.d.ts` livrés avec +le paquet. Rien n'est compilé côté framework. diff --git a/packages/server/skel/api/_.gitignore b/packages/server/skel/api/_.gitignore index 98856e8f..45b5ab27 100644 --- a/packages/server/skel/api/_.gitignore +++ b/packages/server/skel/api/_.gitignore @@ -4,3 +4,5 @@ node_modules .env .DS_Store *.log + +dist diff --git a/packages/server/skel/api/_.mocharc.json b/packages/server/skel/api/_.mocharc.json index 629a09ea..f9317747 100644 --- a/packages/server/skel/api/_.mocharc.json +++ b/packages/server/skel/api/_.mocharc.json @@ -1,9 +1,12 @@ { "recursive": true, "extension": [ - "js" + "ts" + ], + "require": [ + "tsx" ], "reporter": "dot", "exit": true, "timeout": 10000 -} \ No newline at end of file +} diff --git a/packages/server/skel/api/app.js b/packages/server/skel/api/app.js deleted file mode 100644 index 5d3b7739..00000000 --- a/packages/server/skel/api/app.js +++ /dev/null @@ -1,5 +0,0 @@ - -// -const { app } = require('@igojs/server'); - -app.run(); diff --git a/packages/server/skel/api-ts/app.ts b/packages/server/skel/api/app.ts similarity index 100% rename from packages/server/skel/api-ts/app.ts rename to packages/server/skel/api/app.ts diff --git a/packages/server/skel/api/app/api/books/books.controller.js b/packages/server/skel/api/app/api/books/books.controller.js deleted file mode 100644 index a5cf3e6d..00000000 --- a/packages/server/skel/api/app/api/books/books.controller.js +++ /dev/null @@ -1,59 +0,0 @@ - -const { sendProblem } = require('@igojs/server'); - -const Book = require('../../models/Book'); -const dto = require('./books.dto'); - -// -exports.index = async (req, res) => { - const { page, limit, published } = req.query; - - let query = Book.order('created_at desc'); - if (published !== undefined) { - query = query.where({ published }); - } - - const { rows, pagination } = await query.page(page, limit).list(); - res.json({ - books: rows.map(dto.serialize), - page: dto.serializePage(pagination), - }); -}; -exports.index.query = dto.ListBooks; - -// -exports.show = async (req, res) => { - const book = await Book.find(req.params.id); - if (!book) { - return sendProblem(res, 404, { detail: 'Book not found' }); - } - res.json(dto.serialize(book)); -}; - -// -exports.create = async (req, res) => { - const book = await Book.create(req.body); - res.status(201).json(dto.serialize(book)); -}; -exports.create.body = dto.CreateBook; - -// -exports.update = async (req, res) => { - const book = await Book.find(req.params.id); - if (!book) { - return sendProblem(res, 404, { detail: 'Book not found' }); - } - await book.update(req.body); - res.json(dto.serialize(book)); -}; -exports.update.body = dto.UpdateBook; - -// -exports.destroy = async (req, res) => { - const book = await Book.find(req.params.id); - if (!book) { - return sendProblem(res, 404, { detail: 'Book not found' }); - } - await book.delete(); - res.status(204).end(); -}; diff --git a/packages/server/skel/api-ts/app/api/books/books.controller.ts b/packages/server/skel/api/app/api/books/books.controller.ts similarity index 100% rename from packages/server/skel/api-ts/app/api/books/books.controller.ts rename to packages/server/skel/api/app/api/books/books.controller.ts diff --git a/packages/server/skel/api/app/api/books/books.dto.js b/packages/server/skel/api/app/api/books/books.dto.js deleted file mode 100644 index 7c554e25..00000000 --- a/packages/server/skel/api/app/api/books/books.dto.js +++ /dev/null @@ -1,40 +0,0 @@ - -const { z } = require('zod'); - -// Incoming: what the API accepts. Coercion and defaults are applied before the -// controller runs, so req.body and req.query already hold the right types. -exports.CreateBook = z.object({ - title: z.string().min(1).max(255), - author: z.string().min(1).max(255), - pages: z.number().int().positive(), - published: z.boolean().default(false), -}); - -exports.UpdateBook = exports.CreateBook.partial(); - -exports.ListBooks = z.object({ - page: z.coerce.number().int().min(1).default(1), - limit: z.coerce.number().int().min(1).max(100).default(25), - // z.coerce.boolean() would turn 'false' into true: URL flags need this form - published: z.enum(['true', 'false']).transform(v => v === 'true').optional(), -}); - -// Outgoing: the barrier between the ORM model and the API. Adding a column to -// the model exposes nothing until it is named here. -exports.serialize = (book) => ({ - id: book.id, - title: book.title, - author: book.author, - pages: book.pages, - published: book.published, - createdAt: book.created_at, -}); - -// The ORM pagination also carries `links`, meant for rendering page numbers in -// a template: an API client builds its own navigation. -exports.serializePage = (pagination) => ({ - page: pagination.page, - perPage: pagination.nb, - pages: pagination.nb_pages, - total: pagination.count, -}); diff --git a/packages/server/skel/api-ts/app/api/books/books.dto.ts b/packages/server/skel/api/app/api/books/books.dto.ts similarity index 100% rename from packages/server/skel/api-ts/app/api/books/books.dto.ts rename to packages/server/skel/api/app/api/books/books.dto.ts diff --git a/packages/server/skel/api/app/api/books/books.routes.js b/packages/server/skel/api/app/api/books/books.routes.js deleted file mode 100644 index 66d88194..00000000 --- a/packages/server/skel/api/app/api/books/books.routes.js +++ /dev/null @@ -1,14 +0,0 @@ - -const { express } = require('@igojs/server'); - -const controller = require('./books.controller'); - -const router = express.Router(); - -router.get('/', controller.index); -router.post('/', controller.create); -router.get('/:id', controller.show); -router.put('/:id', controller.update); -router.delete('/:id', controller.destroy); - -module.exports = router; diff --git a/packages/server/skel/api-ts/app/api/books/books.routes.ts b/packages/server/skel/api/app/api/books/books.routes.ts similarity index 100% rename from packages/server/skel/api-ts/app/api/books/books.routes.ts rename to packages/server/skel/api/app/api/books/books.routes.ts diff --git a/packages/server/skel/api/app/config.js b/packages/server/skel/api/app/config.js deleted file mode 100644 index b43709d3..00000000 --- a/packages/server/skel/api/app/config.js +++ /dev/null @@ -1,5 +0,0 @@ - -module.exports.init = (config) => { - config.cookieSecret = '{RANDOM_1}'; - config.cookieSession.keys = [ '{RANDOM_2}' ]; -}; diff --git a/packages/server/skel/api-ts/app/config.ts b/packages/server/skel/api/app/config.ts similarity index 100% rename from packages/server/skel/api-ts/app/config.ts rename to packages/server/skel/api/app/config.ts diff --git a/packages/server/skel/api/app/models/Book.js b/packages/server/skel/api/app/models/Book.js deleted file mode 100644 index be820c04..00000000 --- a/packages/server/skel/api/app/models/Book.js +++ /dev/null @@ -1,19 +0,0 @@ - -const { Model } = require('@igojs/db'); - -const schema = { - table: 'books', - columns: [ - 'id', - 'title', - 'author', - 'pages', - { name: 'published', type: 'boolean' }, - 'created_at', - ], -}; - -class Book extends Model(schema) { -} - -module.exports = Book; diff --git a/packages/server/skel/api-ts/app/models/Book.ts b/packages/server/skel/api/app/models/Book.ts similarity index 100% rename from packages/server/skel/api-ts/app/models/Book.ts rename to packages/server/skel/api/app/models/Book.ts diff --git a/packages/server/skel/api/app/routes.js b/packages/server/skel/api/app/routes.js deleted file mode 100644 index d1d7168e..00000000 --- a/packages/server/skel/api/app/routes.js +++ /dev/null @@ -1,13 +0,0 @@ -// Define your routes here -// Check http://expressjs.com/en/guide/routing.html for documentation - -// -module.exports.init = (app) => { - - // mounted under config.api.prefix -> /api/books - app.api('/books', require('./api/books/books.routes')); - - app.get('/', (req, res) => { - res.json({ name: '{project.name}', status: 'running' }); - }); -}; diff --git a/packages/server/skel/api-ts/app/routes.ts b/packages/server/skel/api/app/routes.ts similarity index 100% rename from packages/server/skel/api-ts/app/routes.ts rename to packages/server/skel/api/app/routes.ts diff --git a/packages/server/skel/api/eslint.config.js b/packages/server/skel/api/eslint.config.js deleted file mode 100644 index d251d420..00000000 --- a/packages/server/skel/api/eslint.config.js +++ /dev/null @@ -1,35 +0,0 @@ -module.exports = [{ - 'rules': { - 'indent': [ - 'error', - 2, - { - 'ArrayExpression': 'first', - 'CallExpression': {'arguments': 'first'}, - 'FunctionDeclaration': {'body': 1, 'parameters': 'first'}, - 'MemberExpression': 0, - 'ObjectExpression': 1 - } - ], - 'linebreak-style': [ - 'error', - 'unix' - ], - 'no-unused-vars': [ - 'error', - { 'vars': 'all', 'args': 'after-used', 'ignoreRestSiblings': false } - ], - 'quotes': [ - 'error', - 'single' - ], - 'semi': [ - 'error', - 'always' - ], - 'keyword-spacing': [ - 'error', - { 'after': true, 'before': true } - ] - } -}]; \ No newline at end of file diff --git a/packages/server/skel/api/nodemon.json b/packages/server/skel/api/nodemon.json deleted file mode 100644 index 6b7f1ebc..00000000 --- a/packages/server/skel/api/nodemon.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "watch": [ - "app", - "locales" - ], - "ignore": [], - "ext": "js json", - "events": { - "start": "npm run eslint" - } -} diff --git a/packages/server/skel/api/package.json b/packages/server/skel/api/package.json index e4f7fcbe..a06884e9 100644 --- a/packages/server/skel/api/package.json +++ b/packages/server/skel/api/package.json @@ -2,11 +2,13 @@ "name": "{project.name}", "version": "0.0.1", "description": "", - "main": "app.js", + "main": "dist/app.js", "scripts": { - "eslint": "eslint ./app ./test", - "start": "nodemon app.js", - "test": "mocha" + "build": "tsc && cp -R sql locales dist/", + "start": "tsx watch app.ts", + "serve": "cd dist && node app.js", + "test": "mocha", + "typecheck": "tsc --noEmit" }, "author": "", "license": "ISC", @@ -18,6 +20,10 @@ "zod": "^4.5.4" }, "devDependencies": { - "eslint": "^10.4.1" + "@types/express": "^5.0.6", + "@types/mocha": "^10.0.10", + "@types/node": "^24.0.0", + "tsx": "^4.20.0", + "typescript": "^7.0.0" } } diff --git a/packages/server/skel/api/test/api/BooksTest.js b/packages/server/skel/api/test/api/BooksTest.js deleted file mode 100644 index 79c72e86..00000000 --- a/packages/server/skel/api/test/api/BooksTest.js +++ /dev/null @@ -1,92 +0,0 @@ -require('@igojs/server').dev.test(); - -const assert = require('assert'); -const agent = require('@igojs/server').dev.agent; - -const Book = require('../../app/models/Book'); - -const createBook = (values = {}) => Book.create({ - title: 'Dune', author: 'Frank Herbert', pages: 412, ...values -}); - -describe('api/books', function() { - - describe('GET /api/books', function() { - - it('should list the books', async () => { - await createBook(); - - const res = await agent.get('/api/books'); - - assert.strictEqual(res.statusCode, 200); - assert.strictEqual(res.data.books.length, 1); - assert.strictEqual(res.data.books[0].title, 'Dune'); - assert.strictEqual(res.data.page.total, 1); - }); - - it('should reject an invalid query param', async () => { - const res = await agent.get('/api/books?page=0'); - - assert.strictEqual(res.statusCode, 400); - assert.deepStrictEqual(res.data.errors.map(e => e.path), ['page']); - }); - }); - - describe('GET /api/books/:id', function() { - - it('should expose only the serialized fields', async () => { - const book = await createBook(); - - const res = await agent.get(`/api/books/${book.id}`); - - assert.strictEqual(res.statusCode, 200); - assert.deepStrictEqual( - Object.keys(res.data).sort(), - ['author', 'createdAt', 'id', 'pages', 'published', 'title'] - ); - }); - - it('should answer 404 for an unknown id', async () => { - const res = await agent.get('/api/books/999999'); - - assert.strictEqual(res.statusCode, 404); - assert.strictEqual(res.data.status, 404); - }); - }); - - describe('POST /api/books', function() { - - it('should create a book', async () => { - const res = await agent.post('/api/books', { - body: { title: 'Dune', author: 'Frank Herbert', pages: 412 } - }); - - assert.strictEqual(res.statusCode, 201); - assert.strictEqual(res.data.title, 'Dune'); - assert.strictEqual(res.data.published, false); - - const book = await Book.find(res.data.id); - assert.strictEqual(book.title, 'Dune'); - }); - - it('should reject an invalid body', async () => { - const res = await agent.post('/api/books', { body: { title: '', pages: 'many' } }); - - assert.strictEqual(res.statusCode, 400); - assert.strictEqual(res.data.title, 'Validation failed'); - assert.deepStrictEqual(res.data.errors.map(e => e.path).sort(), ['author', 'pages', 'title']); - }); - }); - - describe('DELETE /api/books/:id', function() { - - it('should delete the book', async () => { - const book = await createBook(); - - const res = await agent.delete(`/api/books/${book.id}`); - - assert.strictEqual(res.statusCode, 204); - assert.strictEqual(await Book.find(book.id), null); - }); - }); -}); diff --git a/packages/server/skel/api-ts/test/api/BooksTest.ts b/packages/server/skel/api/test/api/BooksTest.ts similarity index 100% rename from packages/server/skel/api-ts/test/api/BooksTest.ts rename to packages/server/skel/api/test/api/BooksTest.ts diff --git a/packages/server/skel/api-ts/tsconfig.json b/packages/server/skel/api/tsconfig.json similarity index 100% rename from packages/server/skel/api-ts/tsconfig.json rename to packages/server/skel/api/tsconfig.json diff --git a/packages/server/test/CreateTest.js b/packages/server/test/CreateTest.js index ac527756..d02877df 100644 --- a/packages/server/test/CreateTest.js +++ b/packages/server/test/CreateTest.js @@ -7,7 +7,7 @@ const path = require('path'); const create = require('@igojs/server/cli/create'); -const SKELETONS = ['tailwind', 'api', 'api-ts']; +const SKELETONS = ['tailwind', 'api']; describe('cli/create', function() { this.timeout(20000); @@ -46,12 +46,12 @@ describe('cli/create', function() { it('should carry the api conventions into the api skeletons', async () => { await create({ _: ['create', 'myapi'], skel: 'api' }); - const routes = fs.readFileSync(path.join(tmp, 'myapi', 'app', 'routes.js'), 'utf8'); + const routes = fs.readFileSync(path.join(tmp, 'myapi', 'app', 'routes.ts'), 'utf8'); assert(routes.includes('app.api('), 'routes mount through app.api()'); const controller = fs.readFileSync( - path.join(tmp, 'myapi', 'app', 'api', 'books', 'books.controller.js'), 'utf8'); - assert(controller.includes('exports.create.body = dto.CreateBook'), + path.join(tmp, 'myapi', 'app', 'api', 'books', 'books.controller.ts'), 'utf8'); + assert(controller.includes('create.body = dto.CreateBook'), 'schema is attached to the handler'); }); }); From 03cf44010fea09a85b3ecbdfced743a4944ef63e Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Mon, 7 Sep 2026 12:52:29 +0200 Subject: [PATCH 13/80] =?UTF-8?q?feat(server):=20logs=20structur=C3=A9s=20?= =?UTF-8?q?et=20identifiant=20de=20requ=C3=AAte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le logger produisait des lignes de texte colorisées, y compris en production : les codes ANSI polluaient la sortie et le second argument était jeté. Un logger.info('ok', { user_id: 42 }) perdait silencieusement user_id, et la stack d'une erreur n'apparaissait nulle part. - JSON en production, lisible ailleurs (LOG_FORMAT pour forcer). Les métadonnées deviennent des champs, la stack et les codes SQL aussi. - Une ligne par requête : méthode, chemin, statut, durée. Le niveau suit le statut (5xx error, 4xx warn). - Un identifiant de requête, exposé par req.id, l'en-tête X-Request-Id, et estampillé sur chaque log de la requête sans rien passer en paramètre. C'est ce qui relie les lignes d'une requête entre elles, et un rapport client à ce que le serveur a fait. Un X-Request-Id entrant est réutilisé plutôt que remplacé : une requête garde un seul identifiant à travers un proxy ou entre services. Ça prépare l'ingestion Loki sans dépendre de l'outil : le jour où le collecteur arrive, il n'y a pas de code applicatif à reprendre. Co-Authored-By: Claude Opus 5 (1M context) --- docs/.vitepress/config.mjs | 1 + docs/adr/strategie-observabilite.md | 6 +- docs/feuille-de-route-socle-igo.md | 3 +- docs/server/logging.md | 95 ++++++++++++++++ docs/server/routes.md | 15 +-- packages/server/index.d.ts | 13 ++- packages/server/src/app.js | 2 + packages/server/src/config.js | 4 + packages/server/src/connect/requestlogger.js | 60 ++++++++++ packages/server/src/logger.js | 71 ++++++++---- packages/server/test/LoggerTest.js | 103 ++++++++++++++++++ .../server/test/connect/requestloggerTest.js | 34 ++++++ 12 files changed, 370 insertions(+), 37 deletions(-) create mode 100644 docs/server/logging.md create mode 100644 packages/server/src/connect/requestlogger.js create mode 100644 packages/server/test/LoggerTest.js create mode 100644 packages/server/test/connect/requestloggerTest.js diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index 747820cb..ff1467b3 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -71,6 +71,7 @@ export default defineConfig({ { text: 'Flash scope', link: '/server/flash' }, { text: 'i18n', link: '/server/i18n' }, { text: 'Error handling', link: '/server/errors' }, + { text: 'Logging', link: '/server/logging' }, ], }, ], diff --git a/docs/adr/strategie-observabilite.md b/docs/adr/strategie-observabilite.md index 49451dfa..36991c2c 100644 --- a/docs/adr/strategie-observabilite.md +++ b/docs/adr/strategie-observabilite.md @@ -37,11 +37,11 @@ Ce qu'on logue : requêtes HTTP, erreurs applicatives, requêtes SQL lentes, dé | Évolution | Effort | |---|---| -| Logger structuré JSON (remplace `console.log`) — module dans `@igojs/server` | Faible | +| Logger structuré JSON (remplace `console.log`) — module dans `@igojs/server` | **livré** (igo 6.3) | | `reportError()` front — une fonction, un fichier | Faible | | Error Boundary racine React | Faible | | Gestion explicite des états loading/error/data dans les sections front | Convention | -| Centralisation des logs HTTP (nginx/LB existants, ou middleware applicatif si corrélation fine) | Faible à moyen | +| Centralisation des logs HTTP (nginx/LB existants, ou middleware applicatif si corrélation fine) | **livré** — middleware applicatif avec identifiant de requête (igo 6.3) | | Suppression progressive du crash → mail | Moyen | ## Considered Options @@ -183,7 +183,7 @@ C'est un compromis acceptable parce que : | Phase | Ce qui se passe | |---|---| | **1. Grafana Cloud + Faro** | Compte Grafana Cloud, SDK Faro front, OpenTelemetry back. Alerting Teams sur les erreurs. | -| **2. Logger structuré** | Module JSON dans `@igojs/server`, logs centralisés dans Loki. | +| **2. Logger structuré** | ~~Module JSON dans `@igojs/server`~~ livré en 6.3 ; reste à centraliser dans Loki. | | **3. Infra + services managés** | Alloy sur le bare metal, intégrations MySQL/Redis OVH. Dashboards par projet. | | **4. Cible** | Crash → mail retiré. Error handler `@igojs/server` capture sans `process.exit(1)`. | diff --git a/docs/feuille-de-route-socle-igo.md b/docs/feuille-de-route-socle-igo.md index e2ac14b0..a597ba87 100644 --- a/docs/feuille-de-route-socle-igo.md +++ b/docs/feuille-de-route-socle-igo.md @@ -24,6 +24,7 @@ Les améliorations sont cumulatives : ce qui sert aux refontes sert aussi aux gr | **Middleware de validation Zod** | Middleware global monté par igo ; le schéma est attaché au handler, rien à écrire dans les routes | Faible | | **Déclarations TypeScript** | `.d.ts` sur l'API publique — les schémas Zod deviennent la source des types, sans impact sur les projets JS | Moyen | | **Squelette API** | `skel/api` — TypeScript, à côté de `skel/tailwind` | Moyen | +| **Logs structurés** | JSON en production, identifiant de requête propagé, une ligne par requête — prépare l'ingestion Loki sans dépendre de l'outil | Faible | ## Phase 2 — Première refonte front @@ -32,7 +33,7 @@ Les améliorations sont cumulatives : ce qui sert aux refontes sert aussi aux gr | **DTOs sur les routes API** | Chaque contrôleur API sérialise via un DTO — le front ne voit jamais un modèle ORM brut | Progressif | | **TypeScript progressif** | `allowJs: true` dans le projet applicatif, nouveaux fichiers en `.ts` | Moyen (config) | | **Grafana Cloud + Faro** | SDK Faro côté front, OpenTelemetry côté back, alerting Teams sur les erreurs | Faible | -| **Logger structuré** | Module JSON dans `@igojs/server`, logs centralisés dans Loki | Moyen | +| **Centralisation Loki** | Brancher le collecteur sur les logs JSON déjà produits | Faible | ## Phase 3 — Avant le premier greenfield diff --git a/docs/server/logging.md b/docs/server/logging.md new file mode 100644 index 00000000..4af7a030 --- /dev/null +++ b/docs/server/logging.md @@ -0,0 +1,95 @@ + +# Logging + +Igo logs through [winston](https://github.com/winstonjs/winston). Two formats: +readable lines in a terminal, one JSON object per line in production — which is +what a log collector can actually query. + +## Usage + +```js +const { logger } = require('@igojs/server'); + +logger.info('folder submitted', { folder_id: folder.id, user_id: req.session.user_id }); +logger.warn('quota nearly reached', { used: 92 }); +logger.error(err); +``` + +The second argument becomes **fields**, not text. That is what makes a log +searchable: `folder_id = 42` is a query, `"folder 42 submitted"` is a substring +match. + +## Format + +| | Format | Why | +|---|---|---| +| dev, test | `human` | Coloured, one line, metadata appended | +| production | `json` | One object per line, ingested as-is | + +```js +// app/config.js +config.logformat = 'json'; // or 'human' +``` + +`LOG_FORMAT` and `LOG_LEVEL` override it from the environment, which is handy +to reproduce production output locally: + +```sh +LOG_FORMAT=json npm start +``` + +Errors keep their stack, and SQL errors keep their `code` and `sqlState`, as +separate fields. + +## Request logs + +Every request is logged once it completes: + +```json +{"level":"info","message":"request","method":"GET","path":"/api/books", + "status":200,"duration_ms":5.4,"request_id":"c253246c-…","timestamp":"…"} +``` + +The level follows the status: `error` at 5xx, `warn` at 4xx, `info` otherwise. + +```js +config.logrequests = false; // silence it (already off in tests) +``` + +## Request id + +Each request gets an id, exposed three ways: + +- **`req.id`** in a handler, +- **`X-Request-Id`** on the response, +- **`request_id`** on every log emitted during that request — including your own + `logger.info()` calls, with nothing to pass along. + +```js +exports.create = async (req, res) => { + logger.info('creating a book', { title: req.body.title }); + // -> {"message":"creating a book","title":"…","request_id":"c253246c-…"} +}; +``` + +That is what lets the lines of one request be pulled together, and a client +report be matched with what the server did. + +An inbound `X-Request-Id` or `X-Correlation-Id` is **reused** rather than +replaced, so a request keeps one id across a proxy or between services. A front +end that sends the id it generated can then point at the exact server-side +request behind an error it saw. + +## Sending logs elsewhere + +The JSON format is designed to be read by a collector — Loki, Datadog, or +anything that ingests JSON lines. Nothing to configure in igo: point the +collector at the process output. + +To add a destination, winston transports work as usual: + +```js +// app/config.js — logger is a plain winston logger +const { logger } = require('@igojs/server'); +logger.add(new winston.transports.File({ filename: 'logs/app.log' })); +``` diff --git a/docs/server/routes.md b/docs/server/routes.md index f38738a7..9d3437e9 100644 --- a/docs/server/routes.md +++ b/docs/server/routes.md @@ -80,13 +80,14 @@ Igo.js configures the following middleware in order: 4. **Session** — Encrypted session cookie (31-day expiry) 5. **Body parsers** — URL-encoded and JSON (10MB limit) 6. **Multipart** — File upload parsing via [multiparty](https://github.com/pillarjs/multiparty) -7. **Flash** — Flash messages (see [Flash](./flash)) -8. **Validator** — Request validation (see [Forms](./forms)) -9. **i18n** — Language detection (see [i18n](./i18n)) -10. **Locals** — Sets `res.locals.env`, `res.locals.lang` -11. **Assets** — Webpack manifest injection -12. **Routes** — Your application routes -13. **Error handler** — Catches errors (see [Errors](./errors)) +7. **Request logger** — Request id and one log line per request (see [Logging](./logging)) +8. **Flash** — Flash messages (see [Flash](./flash)) +9. **Validator** — Request validation (see [Forms](./forms)) +10. **i18n** — Language detection (see [i18n](./i18n)) +11. **Locals** — Sets `res.locals.env`, `res.locals.lang` +12. **Assets** — Webpack manifest injection +13. **Routes** — Your application routes +14. **Error handler** — Catches errors (see [Errors](./errors)) ## Static Files diff --git a/packages/server/index.d.ts b/packages/server/index.d.ts index d3e82d41..2f260928 100644 --- a/packages/server/index.d.ts +++ b/packages/server/index.d.ts @@ -45,6 +45,10 @@ export interface Config { /** false keeps the server alive after an uncaught exception a request already answered. */ exitOnUncaughtException: boolean; loglevel: string; + /** 'json' for log collectors, 'human' for a terminal. */ + logformat: 'json' | 'human'; + /** false silences the one-line-per-request log. */ + logrequests: boolean; [key: string]: unknown; } @@ -100,10 +104,11 @@ export declare const cache: { }; export declare const logger: { - error(...args: unknown[]): void; - warn(...args: unknown[]): void; - info(...args: unknown[]): void; - debug(...args: unknown[]): void; + error(message: unknown, meta?: Record): void; + warn(message: string, meta?: Record): void; + info(message: string, meta?: Record): void; + debug(message: string, meta?: Record): void; + log(level: string, message: string, meta?: Record): void; }; export declare const mailer: { diff --git a/packages/server/src/app.js b/packages/server/src/app.js index 29a59fb7..e87de21e 100644 --- a/packages/server/src/app.js +++ b/packages/server/src/app.js @@ -15,6 +15,7 @@ const errorHandler = require('./connect/errorhandler'); const flash = require('./connect/flash'); const locals = require('./connect/locals'); const multipart = require('./connect/multipart'); +const requestLogger = require('./connect/requestlogger'); const session = require('./connect/session'); const validator = require('./connect/validator'); const logger = require('./logger'); @@ -110,6 +111,7 @@ module.exports.configure = async () => { } + app.use(requestLogger); app.use(flash); app.use(validator); diff --git a/packages/server/src/config.js b/packages/server/src/config.js index 1e952a07..3c85f6d3 100644 --- a/packages/server/src/config.js +++ b/packages/server/src/config.js @@ -115,6 +115,10 @@ module.exports.init = function() { // logger config.loglevel = process.env.LOG_LEVEL || 'info'; + // 'json' for log collectors, 'human' for a terminal + config.logformat = process.env.LOG_FORMAT || (config.env === 'production' ? 'json' : 'human'); + // set to false to silence the one-line-per-request log + config.logrequests = config.env !== 'test'; // if (config.env === 'dev') { diff --git a/packages/server/src/connect/requestlogger.js b/packages/server/src/connect/requestlogger.js new file mode 100644 index 00000000..480d07c4 --- /dev/null +++ b/packages/server/src/connect/requestlogger.js @@ -0,0 +1,60 @@ + +const { AsyncLocalStorage } = require('async_hooks'); +const { randomUUID } = require('crypto'); + +const config = require('../config'); +const logger = require('../logger'); + +const storage = new AsyncLocalStorage(); + +// A reverse proxy or an upstream service may already have issued one: reusing +// it is what lets a single request be followed across services. +const INBOUND_HEADERS = ['x-request-id', 'x-correlation-id']; + +const incomingId = (req) => { + for (const header of INBOUND_HEADERS) { + const value = req.headers?.[header]; + if (typeof value === 'string' && value.length && value.length <= 200) { + return value; + } + } + return null; +}; + +const levelFor = (status) => { + if (status >= 500) { + return 'error'; + } + return status >= 400 ? 'warn' : 'info'; +}; + +logger.provideRequestId(() => storage.getStore()?.requestId); + +// One line per request, carrying the id every log of that request is stamped +// with. Mounted by igo before the routes. +module.exports = (req, res, next) => { + const requestId = incomingId(req) || randomUUID(); + const start = process.hrtime.bigint(); + + req.id = requestId; + res.setHeader('X-Request-Id', requestId); + + storage.run({ requestId }, () => { + // mock responses in tests are plain objects, with no events to listen to + if (config.logrequests !== false && typeof res.on === 'function') { + res.on('finish', () => { + const duration = Number(process.hrtime.bigint() - start) / 1e6; + logger.log(levelFor(res.statusCode), 'request', { + method: req.method, + // req.path is rewritten to the router-relative path once mounted + path: (req.originalUrl || req.url || '').split('?')[0], + status: res.statusCode, + duration_ms: Math.round(duration * 10) / 10, + }); + }); + } + next(); + }); +}; + +module.exports.requestId = () => storage.getStore()?.requestId; diff --git a/packages/server/src/logger.js b/packages/server/src/logger.js index d9d584cd..274ef325 100644 --- a/packages/server/src/logger.js +++ b/packages/server/src/logger.js @@ -3,41 +3,68 @@ const winston = require('winston'); const config = require('./config'); +// Terminal-friendly: one readable line, colours, metadata appended. +const humanFormat = () => winston.format.combine( + winston.format.colorize(), + winston.format.timestamp(), + winston.format.splat(), + winston.format.printf(info => { + const { timestamp, level, message, request_id, ...rest } = info; + const id = request_id ? ` [${request_id.slice(0, 8)}]` : ''; + const fields = Object.keys(rest).length ? ` ${JSON.stringify(rest)}` : ''; + return `${timestamp} ${level}:${id} ${message}${fields}`; + }) +); + +// Machine-readable: one JSON object per line, which is what log collectors +// ingest. Colour codes and dropped metadata make text logs unqueryable. +const jsonFormat = () => winston.format.combine( + winston.format.timestamp(), + winston.format.splat(), + winston.format.errors({ stack: true }), + winston.format.json() +); + // const logger = winston.createLogger({ - level: 'info', - format: winston.format.combine( - winston.format.colorize(), - winston.format.timestamp(), - winston.format.splat(), - winston.format.printf(info => { - return `${info.timestamp} ${info.level}: ${info.message}`; - }) - ), - colorize: true, + level: 'info', + format: humanFormat(), transports: [ new winston.transports.Console() ] }); +// Stamps every log emitted during a request with its id, so the lines of one +// request can be pulled together — and matched with what the client reports. +const withRequestId = winston.format((info) => { + const requestId = module.exports.currentRequestId(); + if (requestId && !info.request_id) { + info.request_id = requestId; + } + return info; +}); + // module.exports = logger; -// -module.exports.init = () => { +// Set by the request logger; kept here so logger.js does not depend on the +// error handler, which already depends on config and mailer. +let currentRequestId = () => undefined; + +module.exports.currentRequestId = (...args) => currentRequestId(...args); - // logger.add(new winston.transports.File({ - // filename: `logs/${config.env}.log` - // })); +module.exports.provideRequestId = (fn) => { + currentRequestId = fn; +}; - logger.level = config.loglevel; +// +module.exports.init = () => { - // if (process.env.PAPERTRAIL_HOST && config.env !== 'test') { - // logger.add(new winston.transports.Papertrail({ - // host: 'logs.papertrailapp.com', - // port: 12345 - // })); - // } + logger.level = config.loglevel; + logger.format = winston.format.combine( + withRequestId(), + config.logformat === 'json' ? jsonFormat() : humanFormat() + ); logger.debug('Winston logger initialized'); diff --git a/packages/server/test/LoggerTest.js b/packages/server/test/LoggerTest.js new file mode 100644 index 00000000..bc2e1bdd --- /dev/null +++ b/packages/server/test/LoggerTest.js @@ -0,0 +1,103 @@ +require('./init'); + +const assert = require('assert'); +const winston = require('winston'); +const { Writable } = require('stream'); + +const config = require('@igojs/server').config; +const logger = require('@igojs/server').logger; + +// Captures what a transport would actually write, which is the only way to +// tell a readable line from an ingestible JSON object. +const captureOutput = (fn, reconfigure) => { + const lines = []; + const format = logger.format; + const level = logger.level; + const transports = logger.transports.slice(); + + logger.clear(); + logger.add(new winston.transports.Stream({ + stream: new Writable({ + write(chunk, encoding, callback) { + lines.push(chunk.toString().trim()); + callback(); + }, + }), + })); + if (reconfigure) { + reconfigure(); + lines.length = 0; + } + logger.level = 'info'; + + try { + fn(); + } finally { + logger.clear(); + transports.forEach(t => logger.add(t)); + logger.format = format; + logger.level = level; + } + return lines; +}; + +// logger.init() emits a line of its own: run it while the transports are +// already swapped out, so it neither pollutes the console nor the capture. +const withFormat = (logformat, fn) => { + const initial = config.logformat; + config.logformat = logformat; + try { + return captureOutput(fn, () => logger.init()); + } finally { + config.logformat = initial; + captureOutput(() => {}, () => logger.init()); + } +}; + +describe('Logger', function() { + + describe('json format', function() { + + it('should emit one parseable object per line', () => { + const [line] = withFormat('json', () => logger.info('hello')); + const entry = JSON.parse(line); + assert.strictEqual(entry.message, 'hello'); + assert.strictEqual(entry.level, 'info'); + assert(entry.timestamp); + }); + + it('should keep metadata as fields, not drop them', () => { + const [line] = withFormat('json', () => logger.info('done', { user_id: 42, folder_id: 7 })); + const entry = JSON.parse(line); + assert.strictEqual(entry.user_id, 42); + assert.strictEqual(entry.folder_id, 7); + }); + + it('should carry the stack of an error', () => { + const [line] = withFormat('json', () => logger.error(new Error('boom'))); + const entry = JSON.parse(line); + assert.strictEqual(entry.message, 'boom'); + assert(entry.stack.includes('Error: boom')); + }); + + it('should not colour what a log collector reads', () => { + const [line] = withFormat('json', () => logger.info('plain')); + // eslint-disable-next-line no-control-regex + assert(!/\[/.test(line), 'ANSI escape codes leaked into the JSON output'); + }); + }); + + describe('human format', function() { + + it('should stay on one readable line', () => { + const [line] = withFormat('human', () => logger.info('hello')); + assert(line.includes('hello')); + assert.throws(() => JSON.parse(line)); + }); + + it('should append metadata rather than lose it', () => { + const [line] = withFormat('human', () => logger.info('done', { user_id: 42 })); + assert(line.includes('"user_id":42')); + }); + }); +}); diff --git a/packages/server/test/connect/requestloggerTest.js b/packages/server/test/connect/requestloggerTest.js new file mode 100644 index 00000000..1362e330 --- /dev/null +++ b/packages/server/test/connect/requestloggerTest.js @@ -0,0 +1,34 @@ +require('../init'); + +const assert = require('assert'); +const agent = require('@igojs/server').dev.agent; + +const requestlogger = require('@igojs/server/src/connect/requestlogger'); + +describe('connect/requestlogger', function() { + + it('should expose a request id to the handler and the client', async () => { + const res = await agent.get('/'); + assert.match(res.headers['X-Request-Id'], /^[0-9a-f-]{36}$/); + }); + + it('should give each request its own id', async () => { + const first = await agent.get('/'); + const second = await agent.get('/'); + assert.notStrictEqual(first.headers['X-Request-Id'], second.headers['X-Request-Id']); + }); + + it('should reuse an id issued upstream, so one request can be followed across services', async () => { + const res = await agent.get('/', { headers: { 'x-request-id': 'from-the-proxy' } }); + assert.strictEqual(res.headers['X-Request-Id'], 'from-the-proxy'); + }); + + it('should ignore an absurdly long inbound id', async () => { + const res = await agent.get('/', { headers: { 'x-request-id': 'x'.repeat(300) } }); + assert.match(res.headers['X-Request-Id'], /^[0-9a-f-]{36}$/); + }); + + it('should not leak a request id outside of a request', () => { + assert.strictEqual(requestlogger.requestId(), undefined); + }); +}); From 0bfe5b2eb32fca4e5e86c5efa23a819059990e57 Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Mon, 7 Sep 2026 14:32:51 +0200 Subject: [PATCH 14/80] feat(server): identifier le service, la version et l'environnement dans les logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une plateforme de logs centralisée reçoit tous les projets et tous les environnements au même endroit : sans ces champs, une ligne ne dit pas d'où elle vient. Ajoutés à toutes les lignes JSON, omis du format terminal où ils sont constants. config.appname et config.version viennent du package.json du projet, avec APP_NAME et APP_VERSION pour surcharger. appname était déjà utilisé par les mails de crash mais jamais défini : leur objet s'intitulait « [undefined] Crash: … ». Les deux sont résolus à la lecture, pas à l'init : test/init.js — et tout projet qui fait pareil — réassigne projectRoot après config.init(). Co-Authored-By: Claude Opus 5 (1M context) --- docs/server/logging.md | 15 ++++++++++ packages/server/index.d.ts | 4 +++ packages/server/src/config.js | 36 +++++++++++++++++++++++ packages/server/src/logger.js | 10 +++++++ packages/server/test/ConfigTest.js | 9 ++++++ packages/server/test/LoggerTest.js | 13 ++++++++ packages/server/test/project/package.json | 5 ++++ 7 files changed, 92 insertions(+) create mode 100644 packages/server/test/project/package.json diff --git a/docs/server/logging.md b/docs/server/logging.md index 4af7a030..33d57cb9 100644 --- a/docs/server/logging.md +++ b/docs/server/logging.md @@ -41,6 +41,21 @@ LOG_FORMAT=json npm start Errors keep their stack, and SQL errors keep their `code` and `sqlState`, as separate fields. +### Standing fields + +In `json`, every line also carries where it comes from: + +```json +{"service":"myapi","version":"1.4.0","environment":"production", …} +``` + +`service` and `version` default to the `name` and `version` of your project's +`package.json`; `APP_NAME` and `APP_VERSION` override them, as does setting +`config.appname` / `config.version` directly. Without them, a pooled log +platform cannot tell one project — or one environment — from another. + +They are left out of the `human` format, where all three are constant. + ## Request logs Every request is logged once it completes: diff --git a/packages/server/index.d.ts b/packages/server/index.d.ts index 2f260928..c5a67ada 100644 --- a/packages/server/index.d.ts +++ b/packages/server/index.d.ts @@ -39,6 +39,10 @@ export interface Config { projectRoot: string; api: ApiConfig; databases: string[]; + /** Names the app in crash emails and logs; defaults to the project package name. */ + appname: string; + /** Defaults to the project package version. */ + version: string; cookieSecret: string; cookieSession: CookieSessionConfig; mailcrashto?: string | string[]; diff --git a/packages/server/src/config.js b/packages/server/src/config.js index 3c85f6d3..566ef8c3 100644 --- a/packages/server/src/config.js +++ b/packages/server/src/config.js @@ -9,6 +9,36 @@ module.exports = config; const DEFAULT_COOKIE_SECRET = 'abcdefghijklmnopqrstuvwxyz'; const DEFAULT_SESSION_KEY = 'aaaaaaaaaaa'; +// A project without a readable package.json still has to boot. +const readProjectPackage = (projectRoot) => { + try { + return require(projectRoot + '/package.json'); + } catch { + return {}; + } +}; + +// Reads the project package.json on first access rather than at init(), then +// caches it: a value set by the application always wins. +const defineProjectValue = (target, property, override, packageKey) => { + Object.defineProperty(target, property, { + configurable: true, + enumerable: true, + get() { + const value = override || readProjectPackage(target.projectRoot)[packageKey]; + Object.defineProperty(target, property, { + value, writable: true, configurable: true, enumerable: true + }); + return value; + }, + set(value) { + Object.defineProperty(target, property, { + value, writable: true, configurable: true, enumerable: true + }); + }, + }); +}; + // module.exports.init = function() { @@ -21,6 +51,12 @@ module.exports.init = function() { config.httpport = process.env.HTTP_PORT || 3000; config.projectRoot = process.cwd(); + // Identifies the app in crash emails and in every log line, which is what + // tells one project and one environment apart once logs are pooled. + // Resolved on read: projectRoot can still be reassigned after init(). + defineProjectValue(config, 'appname', process.env.APP_NAME, 'name'); + defineProjectValue(config, 'version', process.env.APP_VERSION, 'version'); + config.cookieSecret = process.env.COOKIE_SECRET || DEFAULT_COOKIE_SECRET; config.cookieSession = { name: 'app', diff --git a/packages/server/src/logger.js b/packages/server/src/logger.js index 274ef325..2c576111 100644 --- a/packages/server/src/logger.js +++ b/packages/server/src/logger.js @@ -61,6 +61,16 @@ module.exports.provideRequestId = (fn) => { module.exports.init = () => { logger.level = config.loglevel; + + // Once several projects and environments write to the same place, a log line + // is only useful if it says where it comes from. Only in the machine-readable + // format: in a terminal these three are constant and just add noise. + logger.defaultMeta = config.logformat === 'json' ? { + service: config.appname, + version: config.version, + environment: config.env, + } : undefined; + logger.format = winston.format.combine( withRequestId(), config.logformat === 'json' ? jsonFormat() : humanFormat() diff --git a/packages/server/test/ConfigTest.js b/packages/server/test/ConfigTest.js index fa2ba6a5..540522c8 100644 --- a/packages/server/test/ConfigTest.js +++ b/packages/server/test/ConfigTest.js @@ -5,6 +5,15 @@ const config = require('@igojs/server').config; describe('igo.config', () => { + describe('app identity', () => { + + it('should name the app after the project package, for crash emails and logs', () => { + const projectPackage = require('./project/package.json'); + assert.strictEqual(config.appname, projectPackage.name); + assert.strictEqual(config.version, projectPackage.version); + }); + }); + describe('config.checkSecrets', () => { const withConfig = (overrides, fn) => { diff --git a/packages/server/test/LoggerTest.js b/packages/server/test/LoggerTest.js index bc2e1bdd..7e875850 100644 --- a/packages/server/test/LoggerTest.js +++ b/packages/server/test/LoggerTest.js @@ -80,6 +80,14 @@ describe('Logger', function() { assert(entry.stack.includes('Error: boom')); }); + it('should say which service, version and environment a line comes from', () => { + const [line] = withFormat('json', () => logger.info('hello')); + const entry = JSON.parse(line); + assert.strictEqual(entry.environment, config.env); + assert.strictEqual(entry.service, config.appname); + assert.strictEqual(entry.version, config.version); + }); + it('should not colour what a log collector reads', () => { const [line] = withFormat('json', () => logger.info('plain')); // eslint-disable-next-line no-control-regex @@ -99,5 +107,10 @@ describe('Logger', function() { const [line] = withFormat('human', () => logger.info('done', { user_id: 42 })); assert(line.includes('"user_id":42')); }); + + it('should leave out the fields that are constant in a terminal', () => { + const [line] = withFormat('human', () => logger.info('done')); + assert(!line.includes('environment'), 'service/version/environment are json-only'); + }); }); }); diff --git a/packages/server/test/project/package.json b/packages/server/test/project/package.json new file mode 100644 index 00000000..a35c0d0c --- /dev/null +++ b/packages/server/test/project/package.json @@ -0,0 +1,5 @@ +{ + "name": "igo-test-project", + "version": "1.2.3", + "private": true +} From 08f9276cd34c27e4e068fa403c1f388f8bf61b7a Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Mon, 7 Sep 2026 14:43:35 +0200 Subject: [PATCH 15/80] feat(server): squelette front React MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit igo create --skel=front — SPA Vite + React + React Router + TanStack Query, consommant l'API JSON d'igo. Conforme aux ADR front : organisation par feature, règle d'injection des données (seuls pages/ et sections/ appellent useQuery), TanStack Query pour l'état serveur, tests Vitest + Testing Library + MSW. Le proxy /api a été vérifié contre un vrai serveur igo : la requête traverse, et surtout le cookie de session aussi — c'est le point que l'ADR chaîne de build demandait de valider avant la première mise en production. Le client HTTP lit les documents RFC 9457 : ApiError.fieldError(champ) donne le message à afficher sous l'input concerné. Le scaffold officiel Vite a servi de contrôle. Trois options reprises de lui, dont erasableSyntaxOnly qui a trouvé une propriété de constructeur non transpilable par esbuild. Co-Authored-By: Claude Opus 5 (1M context) --- packages/server/cli/create.js | 2 +- packages/server/skel/front/README.md | 95 +++++++++++++++++++ packages/server/skel/front/_.gitignore | 7 ++ packages/server/skel/front/index.html | 12 +++ packages/server/skel/front/package.json | 39 ++++++++ .../server/skel/front/pnpm-workspace.yaml | 5 + .../src/components/layout/app-layout.tsx | 9 ++ .../skel/front/src/features/books/api.ts | 26 +++++ .../books/components/books-list.test.tsx | 23 +++++ .../features/books/components/books-list.tsx | 23 +++++ .../features/books/pages/books-page.test.tsx | 62 ++++++++++++ .../src/features/books/pages/books-page.tsx | 28 ++++++ .../books/sections/add-book-section.tsx | 53 +++++++++++ .../skel/front/src/features/books/types.ts | 23 +++++ packages/server/skel/front/src/index.css | 1 + .../server/skel/front/src/lib/api-client.ts | 49 ++++++++++ .../server/skel/front/src/lib/query-client.ts | 14 +++ packages/server/skel/front/src/main.tsx | 17 ++++ packages/server/skel/front/src/routes.tsx | 13 +++ .../server/skel/front/src/test/handlers.ts | 25 +++++ .../server/skel/front/src/test/msw-server.ts | 5 + .../server/skel/front/src/test/render.tsx | 15 +++ packages/server/skel/front/src/test/setup.ts | 14 +++ packages/server/skel/front/src/vite-env.d.ts | 1 + packages/server/skel/front/tsconfig.json | 39 ++++++++ packages/server/skel/front/vite.config.ts | 28 ++++++ packages/server/skel/front/vitest.config.ts | 13 +++ packages/server/test/CreateTest.js | 6 +- 28 files changed, 643 insertions(+), 4 deletions(-) create mode 100644 packages/server/skel/front/README.md create mode 100644 packages/server/skel/front/_.gitignore create mode 100644 packages/server/skel/front/index.html create mode 100644 packages/server/skel/front/package.json create mode 100644 packages/server/skel/front/pnpm-workspace.yaml create mode 100644 packages/server/skel/front/src/components/layout/app-layout.tsx create mode 100644 packages/server/skel/front/src/features/books/api.ts create mode 100644 packages/server/skel/front/src/features/books/components/books-list.test.tsx create mode 100644 packages/server/skel/front/src/features/books/components/books-list.tsx create mode 100644 packages/server/skel/front/src/features/books/pages/books-page.test.tsx create mode 100644 packages/server/skel/front/src/features/books/pages/books-page.tsx create mode 100644 packages/server/skel/front/src/features/books/sections/add-book-section.tsx create mode 100644 packages/server/skel/front/src/features/books/types.ts create mode 100644 packages/server/skel/front/src/index.css create mode 100644 packages/server/skel/front/src/lib/api-client.ts create mode 100644 packages/server/skel/front/src/lib/query-client.ts create mode 100644 packages/server/skel/front/src/main.tsx create mode 100644 packages/server/skel/front/src/routes.tsx create mode 100644 packages/server/skel/front/src/test/handlers.ts create mode 100644 packages/server/skel/front/src/test/msw-server.ts create mode 100644 packages/server/skel/front/src/test/render.tsx create mode 100644 packages/server/skel/front/src/test/setup.ts create mode 100644 packages/server/skel/front/src/vite-env.d.ts create mode 100644 packages/server/skel/front/tsconfig.json create mode 100644 packages/server/skel/front/vite.config.ts create mode 100644 packages/server/skel/front/vitest.config.ts diff --git a/packages/server/cli/create.js b/packages/server/cli/create.js index 8c8c4228..647b6065 100644 --- a/packages/server/cli/create.js +++ b/packages/server/cli/create.js @@ -55,7 +55,7 @@ const replaceInDirectory = async (dir, replacements) => { }; // igo create -const SKELETONS = ['tailwind', 'api']; +const SKELETONS = ['tailwind', 'api', 'front']; module.exports = async function (argv) { const args = argv._; diff --git a/packages/server/skel/front/README.md b/packages/server/skel/front/README.md new file mode 100644 index 00000000..b2d56477 --- /dev/null +++ b/packages/server/skel/front/README.md @@ -0,0 +1,95 @@ +# {project.name} — front + +SPA React servie en assets statiques, consommant l'API JSON d'igo. + +## Démarrer + +```bash +pnpm install +pnpm dev # http://localhost:5173, proxy /api vers le back +pnpm test # vitest + testing library + msw +pnpm typecheck +pnpm build # -> dist/ +``` + +Le back doit tourner en parallèle (`pnpm dev` côté igo, port 3000 par défaut). +`API_URL` pointe le proxy ailleurs : + +```bash +API_URL=http://127.0.0.1:3111 pnpm dev +``` + +## Le proxy + +En développement, Vite proxifie `/api` vers igo. Le navigateur ne voit qu'une +seule origine : **le cookie de session passe sans CORS**, et les URL d'API +restent relatives — le même build tourne ensuite sur tous les environnements +sans être recompilé. + +## Structure + +``` +src/ + main.tsx point d'entrée, providers + routes.tsx arbre de routes, lazy par feature + components/ + layout/ coquille de page + features/ + books/ + pages/ composants de route — PEUVENT fetch + sections/ blocs autonomes — PEUVENT fetch + components/ affichage — PURS, props only + api.ts queries et mutations TanStack Query + types.ts types de la feature + lib/ + api-client.ts wrapper fetch typé, erreurs RFC 9457 + query-client.ts configuration TanStack Query + test/ + handlers.ts handlers MSW par défaut + render.tsx rendu avec providers +``` + +## La règle d'injection des données + +**Seuls `pages/` et `sections/` appellent `useQuery` ou `useMutation`.** Tout le +reste reçoit ses données par props. + +Un bloc qu'on peut retirer sans casser ses voisins possède ses données → c'est +une **section**. Un bloc réutilisé ailleurs, ou dont le retrait casserait +l'affichage → c'est un **composant pur**. + +Ça se vérifie : + +```bash +grep -r "useQuery\|useMutation" src/components/ # doit être vide +grep -r "useQuery\|useMutation" src/features/*/components/ # doit être vide +``` + +## Les erreurs + +Le back renvoie du [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457). `ApiError` +expose le document, et `fieldError(champ)` le message à afficher sous un input : + +```tsx +const error = mutation.error instanceof ApiError ? mutation.error : null; +{error?.fieldError('title') &&

{error.fieldError('title')}

} +``` + +La validation de surface côté client est un agrément d'usage ; **le serveur +reste l'autorité**, et ses erreurs s'affichent telles quelles. + +## Les tests + +MSW intercepte au niveau réseau, donc le vrai `apiClient` tourne dans les +tests — changer de wrapper HTTP ne les casse pas. + +| Où | Niveau | Ce qu'on simule | +|---|---|---| +| `components/` | rendu avec props | rien | +| `sections/`, `pages/` | rendu avec providers | le réseau (MSW) | + +## Le système de design + +Tailwind est installé, sans bibliothèque de composants. [shadcn/ui](https://ui.shadcn.com) +est la recommandation — ses composants se copient dans `src/components/ui/`, +projet par projet. C'est un choix, pas une obligation. diff --git a/packages/server/skel/front/_.gitignore b/packages/server/skel/front/_.gitignore new file mode 100644 index 00000000..f4776d87 --- /dev/null +++ b/packages/server/skel/front/_.gitignore @@ -0,0 +1,7 @@ + +node_modules + +.DS_Store +*.log + +dist diff --git a/packages/server/skel/front/index.html b/packages/server/skel/front/index.html new file mode 100644 index 00000000..9620d098 --- /dev/null +++ b/packages/server/skel/front/index.html @@ -0,0 +1,12 @@ + + + + + + {project.name} + + +
+ + + diff --git a/packages/server/skel/front/package.json b/packages/server/skel/front/package.json new file mode 100644 index 00000000..ed8af55f --- /dev/null +++ b/packages/server/skel/front/package.json @@ -0,0 +1,39 @@ +{ + "name": "{project.name}-front", + "private": true, + "version": "0.0.1", + "type": "module", + "engines": { + "node": ">=24" + }, + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@tanstack/react-query": "^5.102.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router": "^8.3.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.0", + "@testing-library/jest-dom": "^6.9.0", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^6.1.0", + "jsdom": "^30.0.0", + "msw": "^2.12.0", + "tailwindcss": "^4.3.0", + "typescript": "^7.0.0", + "vite": "^8.2.0", + "vitest": "^5.0.0", + "@types/node": "^24.13.0" + } +} diff --git a/packages/server/skel/front/pnpm-workspace.yaml b/packages/server/skel/front/pnpm-workspace.yaml new file mode 100644 index 00000000..c5308f17 --- /dev/null +++ b/packages/server/skel/front/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +# msw's postinstall only prints a banner; the browser service worker is +# generated on demand by `msw init`. pnpm blocks postinstall scripts unless a +# package is listed here. +allowBuilds: + msw: true diff --git a/packages/server/skel/front/src/components/layout/app-layout.tsx b/packages/server/skel/front/src/components/layout/app-layout.tsx new file mode 100644 index 00000000..c9164f15 --- /dev/null +++ b/packages/server/skel/front/src/components/layout/app-layout.tsx @@ -0,0 +1,9 @@ +import { Outlet } from 'react-router'; + +export function AppLayout() { + return ( +
+ +
+ ); +} diff --git a/packages/server/skel/front/src/features/books/api.ts b/packages/server/skel/front/src/features/books/api.ts new file mode 100644 index 00000000..8abdbd0b --- /dev/null +++ b/packages/server/skel/front/src/features/books/api.ts @@ -0,0 +1,26 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { apiClient } from '@/lib/api-client'; + +import type { Book, BooksPage, CreateBook } from './types'; + +const keys = { + all: ['books'] as const, + list: (page: number) => ['books', { page }] as const, +}; + +export function useBooks(page = 1) { + return useQuery({ + queryKey: keys.list(page), + queryFn: () => apiClient.get(`/api/books?page=${page}`), + }); +} + +export function useCreateBook() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (book: CreateBook) => apiClient.post('/api/books', book), + onSuccess: () => queryClient.invalidateQueries({ queryKey: keys.all }), + }); +} diff --git a/packages/server/skel/front/src/features/books/components/books-list.test.tsx b/packages/server/skel/front/src/features/books/components/books-list.test.tsx new file mode 100644 index 00000000..91bb41a3 --- /dev/null +++ b/packages/server/skel/front/src/features/books/components/books-list.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { aBook } from '@/test/handlers'; + +import { BooksList } from './books-list'; + +// A pure component needs no providers: props in, markup out. +describe('BooksList', () => { + + it('should list every book', () => { + render(); + + expect(screen.getByText('Dune')).toBeInTheDocument(); + expect(screen.getByText('Neuromancer')).toBeInTheDocument(); + }); + + it('should say so when there is nothing to show', () => { + render(); + + expect(screen.getByText(/no book yet/i)).toBeInTheDocument(); + }); +}); diff --git a/packages/server/skel/front/src/features/books/components/books-list.tsx b/packages/server/skel/front/src/features/books/components/books-list.tsx new file mode 100644 index 00000000..f4428937 --- /dev/null +++ b/packages/server/skel/front/src/features/books/components/books-list.tsx @@ -0,0 +1,23 @@ +import type { Book } from '../types'; + +// Pure: everything arrives through props. No useQuery here — see the data +// injection rule in the front conventions. +export function BooksList({ books }: { books: Book[] }) { + if (books.length === 0) { + return

No book yet.

; + } + + return ( +
    + {books.map(book => ( +
  • +
    + {book.title} + {book.author} +
    + {book.pages} pages +
  • + ))} +
+ ); +} diff --git a/packages/server/skel/front/src/features/books/pages/books-page.test.tsx b/packages/server/skel/front/src/features/books/pages/books-page.test.tsx new file mode 100644 index 00000000..a6a75787 --- /dev/null +++ b/packages/server/skel/front/src/features/books/pages/books-page.test.tsx @@ -0,0 +1,62 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { describe, expect, it } from 'vitest'; + +import { renderWithProviders } from '@/test/render'; +import { server } from '@/test/msw-server'; + +import { BooksPage } from './books-page'; + +describe('BooksPage', () => { + + it('should show the books once loaded', async () => { + renderWithProviders(); + + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + expect(await screen.findByText('Dune')).toBeInTheDocument(); + }); + + it('should report a server error instead of loading forever', async () => { + server.use(http.get('/api/books', () => + HttpResponse.json( + { type: 'about:blank', title: 'Internal Server Error', status: 500 }, + { status: 500 } + ) + )); + + renderWithProviders(); + + expect(await screen.findByRole('alert')).toHaveTextContent(/internal server error/i); + }); + + it('should show validation errors under the fields the server named', async () => { + server.use(http.post('/api/books', () => + HttpResponse.json({ + type: 'urn:igo:validation-failed', + title: 'Validation failed', + status: 400, + errors: [{ path: 'title', code: 'too_small', message: 'Too small' }], + }, { status: 400 }) + )); + + renderWithProviders(); + await screen.findByText('Dune'); + + await userEvent.click(screen.getByRole('button', { name: /add book/i })); + + expect(await screen.findByText('Too small')).toBeInTheDocument(); + }); + + it('should add a book and refresh the list', async () => { + renderWithProviders(); + await screen.findByText('Dune'); + + await userEvent.type(screen.getByLabelText(/title/i), 'Neuromancer'); + await userEvent.type(screen.getByLabelText(/author/i), 'Gibson'); + await userEvent.type(screen.getByLabelText(/pages/i), '271'); + await userEvent.click(screen.getByRole('button', { name: /add book/i })); + + await waitFor(() => expect(screen.getByLabelText(/title/i)).toHaveValue('')); + }); +}); diff --git a/packages/server/skel/front/src/features/books/pages/books-page.tsx b/packages/server/skel/front/src/features/books/pages/books-page.tsx new file mode 100644 index 00000000..6c2bf0c5 --- /dev/null +++ b/packages/server/skel/front/src/features/books/pages/books-page.tsx @@ -0,0 +1,28 @@ +import { useBooks } from '../api'; +import { BooksList } from '../components/books-list'; +import { AddBookSection } from '../sections/add-book-section'; + +// A page assembles. Loading and error states are handled explicitly rather +// than left to a spinner that never resolves. +export function BooksPage() { + const { data, isPending, isError, error } = useBooks(); + + return ( + <> +

Books

+ + + + {isPending &&

Loading…

} + {isError &&

{error.message}

} + {data && ( + <> + +

{data.page.total} in total

+ + )} + + ); +} + +export const Component = BooksPage; diff --git a/packages/server/skel/front/src/features/books/sections/add-book-section.tsx b/packages/server/skel/front/src/features/books/sections/add-book-section.tsx new file mode 100644 index 00000000..4f170c22 --- /dev/null +++ b/packages/server/skel/front/src/features/books/sections/add-book-section.tsx @@ -0,0 +1,53 @@ +import { useState } from 'react'; + +import { ApiError } from '@/lib/api-client'; + +import { useCreateBook } from '../api'; + +const EMPTY = { title: '', author: '', pages: '' }; + +// A section owns its mutation. The server is the authority on validity: its +// per-field errors are displayed as they come, without being re-derived here. +export function AddBookSection() { + const [form, setForm] = useState(EMPTY); + const createBook = useCreateBook(); + + const error = createBook.error instanceof ApiError ? createBook.error : null; + + const submit = (event: React.FormEvent) => { + event.preventDefault(); + createBook.mutate( + { title: form.title, author: form.author, pages: Number(form.pages) }, + { onSuccess: () => setForm(EMPTY) } + ); + }; + + return ( +
+ {(['title', 'author', 'pages'] as const).map(field => ( +
+ + setForm({ ...form, [field]: e.target.value })} + className="mt-1 w-full rounded border border-slate-300 px-3 py-2" + /> + {error?.fieldError(field) && ( +

{error.fieldError(field)}

+ )} +
+ ))} + + +
+ ); +} diff --git a/packages/server/skel/front/src/features/books/types.ts b/packages/server/skel/front/src/features/books/types.ts new file mode 100644 index 00000000..8b83ee9d --- /dev/null +++ b/packages/server/skel/front/src/features/books/types.ts @@ -0,0 +1,23 @@ +// Mirrors the DTO the back serializes. Kept by hand: the back is JavaScript, +// so there is no contract to generate from — a mismatch shows up in the +// feature tests, which run against the real payload shape. +export interface Book { + id: number; + title: string; + author: string; + pages: number; + published: boolean; + createdAt: string; +} + +export interface BooksPage { + books: Book[]; + page: { page: number; perPage: number; pages: number; total: number }; +} + +export interface CreateBook { + title: string; + author: string; + pages: number; + published?: boolean; +} diff --git a/packages/server/skel/front/src/index.css b/packages/server/skel/front/src/index.css new file mode 100644 index 00000000..f1d8c73c --- /dev/null +++ b/packages/server/skel/front/src/index.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/packages/server/skel/front/src/lib/api-client.ts b/packages/server/skel/front/src/lib/api-client.ts new file mode 100644 index 00000000..b1cac65a --- /dev/null +++ b/packages/server/skel/front/src/lib/api-client.ts @@ -0,0 +1,49 @@ +// RFC 9457 problem document, as returned by igo on every API error. +export interface Problem { + type: string; + title: string; + status: number; + detail?: string; + errors?: { path: string; code?: string; message: string }[]; +} + +export class ApiError extends Error { + readonly problem: Problem; + + constructor(problem: Problem) { + super(problem.detail || problem.title); + this.name = 'ApiError'; + this.problem = problem; + } + + /** Message for one field, to sit under the input that caused it. */ + fieldError(path: string): string | undefined { + return this.problem.errors?.find(e => e.path === path)?.message; + } +} + +// Relative URLs on purpose: the same build then runs against every +// environment, behind the dev proxy or behind nginx. +const request = async (method: string, path: string, body?: unknown): Promise => { + const response = await fetch(path, { + method, + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + const problem = await response.json().catch(() => ({ + type: 'about:blank', title: response.statusText, status: response.status, + })); + throw new ApiError(problem as Problem); + } + + return response.status === 204 ? (undefined as T) : response.json(); +}; + +export const apiClient = { + get: (path: string) => request('GET', path), + post: (path: string, body: unknown) => request('POST', path, body), + put: (path: string, body: unknown) => request('PUT', path, body), + delete: (path: string) => request('DELETE', path), +}; diff --git a/packages/server/skel/front/src/lib/query-client.ts b/packages/server/skel/front/src/lib/query-client.ts new file mode 100644 index 00000000..815485d0 --- /dev/null +++ b/packages/server/skel/front/src/lib/query-client.ts @@ -0,0 +1,14 @@ +import { QueryClient } from '@tanstack/react-query'; + +import { ApiError } from './api-client'; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + // a 404 or a validation error will not fix itself on retry + retry: (failureCount, error) => + !(error instanceof ApiError && error.problem.status < 500) && failureCount < 2, + }, + }, +}); diff --git a/packages/server/skel/front/src/main.tsx b/packages/server/skel/front/src/main.tsx new file mode 100644 index 00000000..a017a72e --- /dev/null +++ b/packages/server/skel/front/src/main.tsx @@ -0,0 +1,17 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { RouterProvider } from 'react-router'; + +import { queryClient } from '@/lib/query-client'; +import { router } from '@/routes'; + +import './index.css'; + +createRoot(document.getElementById('root')!).render( + + + + + +); diff --git a/packages/server/skel/front/src/routes.tsx b/packages/server/skel/front/src/routes.tsx new file mode 100644 index 00000000..d04a59bc --- /dev/null +++ b/packages/server/skel/front/src/routes.tsx @@ -0,0 +1,13 @@ +import { createBrowserRouter } from 'react-router'; + +import { AppLayout } from '@/components/layout/app-layout'; + +export const router = createBrowserRouter([ + { + element: , + children: [ + // lazy per feature: a route is only downloaded when it is visited + { index: true, lazy: () => import('@/features/books/pages/books-page') }, + ], + }, +]); diff --git a/packages/server/skel/front/src/test/handlers.ts b/packages/server/skel/front/src/test/handlers.ts new file mode 100644 index 00000000..5defd591 --- /dev/null +++ b/packages/server/skel/front/src/test/handlers.ts @@ -0,0 +1,25 @@ +import { http, HttpResponse } from 'msw'; + +import type { Book } from '@/features/books/types'; + +export const aBook = (overrides: Partial = {}): Book => ({ + id: 1, title: 'Dune', author: 'Frank Herbert', pages: 412, + published: true, createdAt: '2026-01-01T00:00:00.000Z', + ...overrides, +}); + +// Default handlers describe the happy path; a test overrides the one case it +// is about with server.use(). +export const handlers = [ + http.get('/api/books', () => + HttpResponse.json({ + books: [aBook()], + page: { page: 1, perPage: 25, pages: 1, total: 1 }, + }) + ), + + http.post('/api/books', async ({ request }) => { + const body = (await request.json()) as Partial; + return HttpResponse.json(aBook({ id: 2, ...body }), { status: 201 }); + }), +]; diff --git a/packages/server/skel/front/src/test/msw-server.ts b/packages/server/skel/front/src/test/msw-server.ts new file mode 100644 index 00000000..5ac9204f --- /dev/null +++ b/packages/server/skel/front/src/test/msw-server.ts @@ -0,0 +1,5 @@ +import { setupServer } from 'msw/node'; + +import { handlers } from './handlers'; + +export const server = setupServer(...handlers); diff --git a/packages/server/skel/front/src/test/render.tsx b/packages/server/skel/front/src/test/render.tsx new file mode 100644 index 00000000..0a2d8370 --- /dev/null +++ b/packages/server/skel/front/src/test/render.tsx @@ -0,0 +1,15 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render } from '@testing-library/react'; +import type { ReactElement } from 'react'; + +// A fresh client per test: a cache shared between tests makes them pass or +// fail depending on their order. Retries off so an error surfaces at once. +export function renderWithProviders(ui: ReactElement) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + + return render( + {ui} + ); +} diff --git a/packages/server/skel/front/src/test/setup.ts b/packages/server/skel/front/src/test/setup.ts new file mode 100644 index 00000000..91f1d1e7 --- /dev/null +++ b/packages/server/skel/front/src/test/setup.ts @@ -0,0 +1,14 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup } from '@testing-library/react'; +import { afterAll, afterEach, beforeAll } from 'vitest'; + +import { server } from './msw-server'; + +// MSW intercepts at the network level, so the production apiClient runs +// untouched: swapping the HTTP wrapper does not break these tests. +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterEach(() => { + server.resetHandlers(); + cleanup(); +}); +afterAll(() => server.close()); diff --git a/packages/server/skel/front/src/vite-env.d.ts b/packages/server/skel/front/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/packages/server/skel/front/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/server/skel/front/tsconfig.json b/packages/server/skel/front/tsconfig.json new file mode 100644 index 00000000..341289fc --- /dev/null +++ b/packages/server/skel/front/tsconfig.json @@ -0,0 +1,39 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": [ + "ES2023", + "DOM", + "DOM.Iterable" + ], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "types": [ + "vitest/globals", + "@testing-library/jest-dom" + ], + "paths": { + "@/*": [ + "./src/*" + ] + }, + "moduleDetection": "force", + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "erasableSyntaxOnly": true + }, + "include": [ + "src", + "vite.config.ts", + "vitest.config.ts" + ] +} diff --git a/packages/server/skel/front/vite.config.ts b/packages/server/skel/front/vite.config.ts new file mode 100644 index 00000000..72d493b2 --- /dev/null +++ b/packages/server/skel/front/vite.config.ts @@ -0,0 +1,28 @@ +import { fileURLToPath, URL } from 'node:url'; + +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + + // tsconfig paths are for the type checker only: the bundler needs its own + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + + server: { + port: 5173, + proxy: { + // The browser sees a single origin, so the igo session cookie is sent + // like any same-origin cookie — no CORS, no credentials handling. + '/api': { + target: process.env.API_URL || 'http://127.0.0.1:3000', + changeOrigin: false, + }, + }, + }, +}); diff --git a/packages/server/skel/front/vitest.config.ts b/packages/server/skel/front/vitest.config.ts new file mode 100644 index 00000000..abbfbac1 --- /dev/null +++ b/packages/server/skel/front/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig, mergeConfig } from 'vitest/config'; + +import viteConfig from './vite.config.ts'; + +// Vitest 5 no longer accepts a `test` key in vite's defineConfig: the test +// setup lives in its own file and reuses the app config. +export default mergeConfig(viteConfig, defineConfig({ + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test/setup.ts'], + }, +})); diff --git a/packages/server/test/CreateTest.js b/packages/server/test/CreateTest.js index d02877df..560afbcc 100644 --- a/packages/server/test/CreateTest.js +++ b/packages/server/test/CreateTest.js @@ -7,7 +7,7 @@ const path = require('path'); const create = require('@igojs/server/cli/create'); -const SKELETONS = ['tailwind', 'api']; +const SKELETONS = ['tailwind', 'api', 'front']; describe('cli/create', function() { this.timeout(20000); @@ -30,8 +30,8 @@ describe('cli/create', function() { await create({ _: ['create', 'myapp'], skel }); const pkg = JSON.parse(fs.readFileSync(path.join(tmp, 'myapp', 'package.json'), 'utf8')); - assert.strictEqual(pkg.name, 'myapp'); - assert(!/\{igo\.version\}/.test(pkg.dependencies['@igojs/igo']), 'igo version was not replaced'); + assert(pkg.name.startsWith('myapp'), `project name was not substituted: ${pkg.name}`); + assert(!/\{[a-z.]+\}/.test(JSON.stringify(pkg)), 'a placeholder was left unreplaced'); // _.gitignore is renamed on the way out assert(fs.existsSync(path.join(tmp, 'myapp', '.gitignore'))); From d70eb3caafd4490c1a3398d3b3a30fecb41925b1 Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Mon, 7 Sep 2026 15:03:30 +0200 Subject: [PATCH 16/80] feat(server): outillage projet dans les squelettes api et front MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tout ce qu'on refait à chaque nouveau projet, porté par le squelette : - oxlint plutôt qu'eslint : typescript-eslint refuse TS 7 au chargement (« does not support TS 7.0 »), et npm rétrograde silencieusement en TS 6 pour satisfaire son peer. oxlint n'a aucune dépendance au compilateur. - husky + lint-staged : oxlint sur les fichiers indexés au pre-commit. - commitlint : les Conventional Commits sont vérifiés, plus seulement écrits dans une doc. - CI GitHub Actions : lint, typecheck, tests, build. Les services MySQL et Redis sont du boilerplate identique d'un projet igo à l'autre. - CLAUDE.md : les conventions du projet, là où un agent les lira. - .env.example, .nvmrc, pnpm-workspace.yaml. Les squelettes passent à pnpm — son mode strict avait déjà trouvé le décalage mocha 12. Il bloque aussi les postinstall par défaut, d'où les allowBuilds pour esbuild, @parcel/watcher et msw. create.js renomme maintenant aussi les dossiers `_.` : `_.husky/` et `_.github/` restaient préfixés, donc inertes. Les configs lint-staged vivent dans leur propre fichier, des deux côtés : lint-staged remonte au plus proche package.json portant la clé, donc celui d'un squelette s'appliquait aux commits d'igo et réclamait oxlint. Vérifié à blanc sur les deux squelettes : lint, typecheck, tests et build verts, et les deux hooks git bloquent bien ce qu'ils doivent bloquer. Co-Authored-By: Claude Opus 5 (1M context) --- lint-staged.config.js | 8 ++ package.json | 3 - packages/server/cli/create.js | 14 +-- packages/server/skel/api/.oxlintrc.json | 11 +++ packages/server/skel/api/CLAUDE.md | 85 ++++++++++++++++++ packages/server/skel/api/_.env.example | 26 ++++++ .../server/skel/api/_.github/workflows/ci.yml | 48 ++++++++++ packages/server/skel/api/_.husky/commit-msg | 1 + packages/server/skel/api/_.husky/pre-commit | 1 + packages/server/skel/api/_.lintstagedrc.json | 3 + packages/server/skel/api/_.nvmrc | 1 + .../server/skel/api/commitlint.config.cjs | 3 + packages/server/skel/api/package.json | 9 +- packages/server/skel/api/pnpm-workspace.yaml | 5 ++ .../server/skel/api/test/api/BooksTest.ts | 4 +- packages/server/skel/front/.oxlintrc.json | 24 +++++ packages/server/skel/front/CLAUDE.md | 90 +++++++++++++++++++ packages/server/skel/front/_.env.example | 3 + .../skel/front/_.github/workflows/ci.yml | 34 +++++++ packages/server/skel/front/_.husky/commit-msg | 1 + packages/server/skel/front/_.husky/pre-commit | 1 + .../server/skel/front/_.lintstagedrc.json | 3 + packages/server/skel/front/_.nvmrc | 1 + .../server/skel/front/commitlint.config.js | 3 + packages/server/skel/front/package.json | 13 ++- 25 files changed, 381 insertions(+), 14 deletions(-) create mode 100644 lint-staged.config.js create mode 100644 packages/server/skel/api/.oxlintrc.json create mode 100644 packages/server/skel/api/CLAUDE.md create mode 100644 packages/server/skel/api/_.env.example create mode 100644 packages/server/skel/api/_.github/workflows/ci.yml create mode 100644 packages/server/skel/api/_.husky/commit-msg create mode 100644 packages/server/skel/api/_.husky/pre-commit create mode 100644 packages/server/skel/api/_.lintstagedrc.json create mode 100644 packages/server/skel/api/_.nvmrc create mode 100644 packages/server/skel/api/commitlint.config.cjs create mode 100644 packages/server/skel/api/pnpm-workspace.yaml create mode 100644 packages/server/skel/front/.oxlintrc.json create mode 100644 packages/server/skel/front/CLAUDE.md create mode 100644 packages/server/skel/front/_.env.example create mode 100644 packages/server/skel/front/_.github/workflows/ci.yml create mode 100644 packages/server/skel/front/_.husky/commit-msg create mode 100644 packages/server/skel/front/_.husky/pre-commit create mode 100644 packages/server/skel/front/_.lintstagedrc.json create mode 100644 packages/server/skel/front/_.nvmrc create mode 100644 packages/server/skel/front/commitlint.config.js diff --git a/lint-staged.config.js b/lint-staged.config.js new file mode 100644 index 00000000..f616cd16 --- /dev/null +++ b/lint-staged.config.js @@ -0,0 +1,8 @@ +// The skeletons ship their own lint-staged config: without an explicit config +// here, lint-staged would pick theirs up for files under skel/ and try to run +// a linter igo does not have. +module.exports = { + 'packages/*/src/**/*.js': 'eslint', + 'packages/*/test/**/*.js': 'eslint', + 'packages/*/cli/**/*.js': 'eslint', +}; diff --git a/package.json b/package.json index 1a6e87ab..f760675f 100644 --- a/package.json +++ b/package.json @@ -27,9 +27,6 @@ "typescript": "^7.0.2", "vitepress": "^1.6.4" }, - "lint-staged": { - "*.js": "eslint" - }, "overrides": { "serialize-javascript": "^7.0.5", "diff": "^8.0.3" diff --git a/packages/server/cli/create.js b/packages/server/cli/create.js index 647b6065..e54ff1c2 100644 --- a/packages/server/cli/create.js +++ b/packages/server/cli/create.js @@ -13,14 +13,18 @@ const renameUnderscoreFiles = async (dir) => { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { - const srcPath = path.join(dir, entry.name); + let srcPath = path.join(dir, entry.name); + + // npm refuses to publish a directory containing .gitignore, .husky and the + // like, so the skeletons ship them prefixed. + if (entry.name.startsWith('_.')) { + const destPath = path.join(dir, '.' + entry.name.slice(2)); + await fse.move(srcPath, destPath, { overwrite: true }); + srcPath = destPath; + } if (entry.isDirectory()) { await renameUnderscoreFiles(srcPath); // récursif - } else if (entry.name.startsWith('_.')) { - const newName = '.' + entry.name.slice(2); - const destPath = path.join(dir, newName); - await fse.move(srcPath, destPath, { overwrite: true }); } } }; diff --git a/packages/server/skel/api/.oxlintrc.json b/packages/server/skel/api/.oxlintrc.json new file mode 100644 index 00000000..8ff8c2a3 --- /dev/null +++ b/packages/server/skel/api/.oxlintrc.json @@ -0,0 +1,11 @@ +{ + "plugins": ["typescript", "unicorn", "oxc"], + "categories": { + "correctness": "error", + "suspicious": "warn" + }, + "rules": { + "no-console": "warn" + }, + "ignorePatterns": ["dist", "node_modules"] +} diff --git a/packages/server/skel/api/CLAUDE.md b/packages/server/skel/api/CLAUDE.md new file mode 100644 index 00000000..66949805 --- /dev/null +++ b/packages/server/skel/api/CLAUDE.md @@ -0,0 +1,85 @@ +# {project.name} + +API JSON sur [igo](https://github.com/igocreate/igo). TypeScript, Node 24, pnpm. + +## Commandes + +```bash +pnpm start # tsx watch +pnpm test # mocha — vraie base, isolée par transaction +pnpm lint # oxlint +pnpm typecheck # tsc --noEmit +pnpm build # -> dist/ +``` + +Les tests ont besoin de MySQL et Redis en local. La base de test est recréée et +migrée à chaque exécution. + +## Structure + +``` +app/ + api// un dossier par domaine exposé + .routes.ts les endpoints + .controller.ts thin : service ou modèle -> DTO + .dto.ts schémas entrants + serialize sortant + models/ modèles ORM + config.ts surcharge de la config igo + routes.ts montage +sql/ migrations, une par fichier daté +test/ miroir de app/ +``` + +## Conventions + +**Les routes API se montent avec `app.api()`** — le préfixe `/api` vient de +`config.api.prefix`, jamais réécrit à la main. + +**La validation est portée par le schéma attaché au handler**, jamais par un +appel dans le contrôleur : + +```ts +export const create: ApiHandler<{ body: typeof dto.CreateBook }> = async (req, res) => { … }; +create.body = dto.CreateBook; +``` + +`req.body` et `req.query` arrivent validés et coercés. Pas de `parseInt`, pas de +garde manuelle sur un champ requis. + +**Le DTO est la barrière.** Un contrôleur ne renvoie jamais un modèle ORM : +`res.json(dto.serialize(book))`. Ajouter une colonne au modèle n'expose rien +tant qu'elle n'est pas nommée dans `serialize()`. + +**Les erreurs sont des documents RFC 9457**, via `sendProblem(res, status, …)`. +Un cas métier mérite son propre `type` (`/problems/out-of-stock`) — c'est ce que +le client teste, jamais le libellé. + +**La logique métier vit dans les services**, pas dans les contrôleurs, dès +qu'elle dépasse un appel au modèle. + +**Les logs portent des champs, pas des phrases** : `logger.info('book created', +{ book_id })` plutôt qu'une chaîne interpolée. L'identifiant de requête est +ajouté tout seul. + +## Tests + +Tout contrôleur API a un test d'intégration couvrant au minimum : le cas +nominal, la validation (400), l'entité absente (404), et l'accès refusé quand la +route est protégée. + +Les tests passent par `dev.agent` contre la vraie base. Les mocks ne servent que +pour les dépendances externes — API tierces, SMTP. + +## Commits + +[Conventional Commits](https://www.conventionalcommits.org), vérifié par un hook : +`feat:`, `fix:`, `chore:`, `refactor:`, `docs:`, `test:`, `ci:`. Le scope entre +parenthèses quand il aide — `fix(books): …`. + +Le hook de pre-commit passe oxlint sur les fichiers indexés. + +## Documentation + +- [Routes et API JSON](https://igocreate.github.io/igo/server/api) +- [ORM](https://igocreate.github.io/igo/db/models) +- [Logs](https://igocreate.github.io/igo/server/logging) diff --git a/packages/server/skel/api/_.env.example b/packages/server/skel/api/_.env.example new file mode 100644 index 00000000..6e5d3ba2 --- /dev/null +++ b/packages/server/skel/api/_.env.example @@ -0,0 +1,26 @@ +# Copy to .env — never commit .env +NODE_ENV=dev +HTTP_PORT=3000 + +# Sessions: generate with `openssl rand -hex 32`. igo refuses to start in +# production with the default values. +COOKIE_SECRET= +COOKIE_SESSION_KEYS= + +MYSQL_HOST=127.0.0.1 +MYSQL_PORT=3306 +MYSQL_USERNAME=root +MYSQL_PASSWORD= +MYSQL_DATABASE= + +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 + +# json in production, human elsewhere +# LOG_FORMAT=json +# LOG_LEVEL=info + +# SMTP_HOST= +# SMTP_USER= +# SMTP_PASSWORD= +# SMTP_FROM= diff --git a/packages/server/skel/api/_.github/workflows/ci.yml b/packages/server/skel/api/_.github/workflows/ci.yml new file mode 100644 index 00000000..c04f06b9 --- /dev/null +++ b/packages/server/skel/api/_.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + pull_request: + push: + branches: [main, master] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: Lint, typecheck & tests + runs-on: ubuntu-latest + + services: + mysql: + image: mysql:8 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: "yes" + MYSQL_DATABASE: test + ports: + - 3306:3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + redis: + image: redis + ports: + - 6379:6379 + options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=3 + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm lint + - run: pnpm typecheck + + # tests run against the real database, isolated by transaction rollback + - run: pnpm test diff --git a/packages/server/skel/api/_.husky/commit-msg b/packages/server/skel/api/_.husky/commit-msg new file mode 100644 index 00000000..9ef41ae4 --- /dev/null +++ b/packages/server/skel/api/_.husky/commit-msg @@ -0,0 +1 @@ +pnpm commitlint --edit "$1" diff --git a/packages/server/skel/api/_.husky/pre-commit b/packages/server/skel/api/_.husky/pre-commit new file mode 100644 index 00000000..cb2c84d5 --- /dev/null +++ b/packages/server/skel/api/_.husky/pre-commit @@ -0,0 +1 @@ +pnpm lint-staged diff --git a/packages/server/skel/api/_.lintstagedrc.json b/packages/server/skel/api/_.lintstagedrc.json new file mode 100644 index 00000000..8302f925 --- /dev/null +++ b/packages/server/skel/api/_.lintstagedrc.json @@ -0,0 +1,3 @@ +{ + "*.ts": "oxlint" +} diff --git a/packages/server/skel/api/_.nvmrc b/packages/server/skel/api/_.nvmrc new file mode 100644 index 00000000..a45fd52c --- /dev/null +++ b/packages/server/skel/api/_.nvmrc @@ -0,0 +1 @@ +24 diff --git a/packages/server/skel/api/commitlint.config.cjs b/packages/server/skel/api/commitlint.config.cjs new file mode 100644 index 00000000..d3da6028 --- /dev/null +++ b/packages/server/skel/api/commitlint.config.cjs @@ -0,0 +1,3 @@ +// Conventional Commits: feat, fix, chore, refactor, docs, test, ci… +// https://www.conventionalcommits.org +module.exports = { extends: ['@commitlint/config-conventional'] }; diff --git a/packages/server/skel/api/package.json b/packages/server/skel/api/package.json index a06884e9..30cf540a 100644 --- a/packages/server/skel/api/package.json +++ b/packages/server/skel/api/package.json @@ -7,8 +7,10 @@ "build": "tsc && cp -R sql locales dist/", "start": "tsx watch app.ts", "serve": "cd dist && node app.js", + "lint": "oxlint", "test": "mocha", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "prepare": "husky" }, "author": "", "license": "ISC", @@ -20,9 +22,14 @@ "zod": "^4.5.4" }, "devDependencies": { + "@commitlint/cli": "^21.2.0", + "@commitlint/config-conventional": "^21.2.0", "@types/express": "^5.0.6", "@types/mocha": "^10.0.10", "@types/node": "^24.0.0", + "husky": "^9.1.7", + "lint-staged": "^17.5.0", + "oxlint": "^1.81.0", "tsx": "^4.20.0", "typescript": "^7.0.0" } diff --git a/packages/server/skel/api/pnpm-workspace.yaml b/packages/server/skel/api/pnpm-workspace.yaml new file mode 100644 index 00000000..9fcacbed --- /dev/null +++ b/packages/server/skel/api/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +# pnpm blocks postinstall scripts unless a package is listed here. These two +# compile or download a native binary, which they need to run at all. +allowBuilds: + esbuild: true + '@parcel/watcher': true diff --git a/packages/server/skel/api/test/api/BooksTest.ts b/packages/server/skel/api/test/api/BooksTest.ts index 415568ac..eb2e5c88 100644 --- a/packages/server/skel/api/test/api/BooksTest.ts +++ b/packages/server/skel/api/test/api/BooksTest.ts @@ -42,7 +42,7 @@ describe('api/books', function() { const res = await agent.get(`/api/books/${book.id}`); assert.strictEqual(res.statusCode, 200); - assert.deepStrictEqual(Object.keys(res.data).sort(), + assert.deepStrictEqual(Object.keys(res.data).toSorted(), ['author', 'createdAt', 'id', 'pages', 'published', 'title']); }); @@ -75,7 +75,7 @@ describe('api/books', function() { assert.strictEqual(res.statusCode, 400); assert.strictEqual(res.data.title, 'Validation failed'); assert.deepStrictEqual( - res.data.errors.map((e: { path: string }) => e.path).sort(), + res.data.errors.map((e: { path: string }) => e.path).toSorted(), ['author', 'pages', 'title'] ); }); diff --git a/packages/server/skel/front/.oxlintrc.json b/packages/server/skel/front/.oxlintrc.json new file mode 100644 index 00000000..0adaeddc --- /dev/null +++ b/packages/server/skel/front/.oxlintrc.json @@ -0,0 +1,24 @@ +{ + "plugins": [ + "typescript", + "unicorn", + "oxc", + "react", + "react-perf", + "jsx-a11y", + "import" + ], + "categories": { + "correctness": "error", + "suspicious": "warn" + }, + "rules": { + "no-console": "warn", + "react/react-in-jsx-scope": "off", + "import/no-unassigned-import": "off" + }, + "ignorePatterns": [ + "dist", + "node_modules" + ] +} diff --git a/packages/server/skel/front/CLAUDE.md b/packages/server/skel/front/CLAUDE.md new file mode 100644 index 00000000..3b726279 --- /dev/null +++ b/packages/server/skel/front/CLAUDE.md @@ -0,0 +1,90 @@ +# {project.name} — front + +SPA React consommant l'API JSON d'igo. Vite, TypeScript, Node 24, pnpm. + +## Commandes + +```bash +pnpm dev # http://localhost:5173, proxy /api vers le back +pnpm test # vitest + testing library + msw +pnpm lint # oxlint +pnpm typecheck # tsc --noEmit +pnpm build # -> dist/ +``` + +Le back doit tourner en parallèle. `API_URL` pointe le proxy ailleurs que sur +`http://127.0.0.1:3000`. + +## Structure + +``` +src/ + main.tsx point d'entrée, providers + routes.tsx arbre de routes, lazy par feature + components/ + ui/ composants copiés (shadcn) — purs + layout/ coquille de page + features// + pages/ composants de route — PEUVENT fetch + sections/ blocs autonomes — PEUVENT fetch + components/ affichage — PURS, props only + api.ts queries et mutations TanStack Query + types.ts types de la feature + lib/ + api-client.ts wrapper fetch typé, erreurs RFC 9457 + test/ handlers MSW, helper de rendu +``` + +## Conventions + +**Seuls `pages/` et `sections/` appellent `useQuery` ou `useMutation`.** Tout le +reste reçoit ses données par props. Un bloc retirable sans casser ses voisins +possède ses données — c'est une section ; un bloc réutilisé ailleurs est un +composant pur. + +Ça se vérifie : + +```bash +grep -r "useQuery\|useMutation" src/components/ # doit être vide +grep -r "useQuery\|useMutation" src/features/*/components/ # doit être vide +``` + +**Les URL d'API sont relatives** (`/api/…`). Jamais de base URL absolue : c'est +ce qui permet au même build de tourner sur tous les environnements. + +**L'état serveur appartient à TanStack Query**, pas à un `useState` synchronisé +par `useEffect`. L'état purement client passe par React context tant qu'il reste +léger. + +**Les états loading et error sont explicites** dans les pages et sections. Pas +de composant qui suppose que les données sont là. + +**Le serveur est l'autorité sur la validation.** La validation côté client est +un confort ; les erreurs du serveur s'affichent telles quelles, par champ, via +`ApiError.fieldError(champ)`. + +**Les types de la feature reflètent le DTO du back.** Ils sont écrits à la main : +si les deux dérivent, ce sont les tests de feature qui le montrent. + +## Tests + +MSW intercepte au niveau réseau, donc le vrai `apiClient` tourne dans les tests. + +| Où | Niveau | Ce qu'on simule | +|---|---|---| +| `components/` | rendu avec props | rien | +| `sections/`, `pages/` | rendu avec providers | le réseau (MSW) | + +Un test de feature couvre le cas nominal, l'erreur serveur, et la validation. + +## Commits + +[Conventional Commits](https://www.conventionalcommits.org), vérifié par un hook. +Le hook de pre-commit passe oxlint sur les fichiers indexés. + +## Système de design + +Tailwind est installé, sans bibliothèque de composants. +[shadcn/ui](https://ui.shadcn.com) est la recommandation — ses composants se +copient dans `src/components/ui/` et deviennent du code du projet. C'est un +choix, pas une obligation. diff --git a/packages/server/skel/front/_.env.example b/packages/server/skel/front/_.env.example new file mode 100644 index 00000000..47c15306 --- /dev/null +++ b/packages/server/skel/front/_.env.example @@ -0,0 +1,3 @@ +# Copy to .env — never commit .env +# Where the dev proxy sends /api. Defaults to http://127.0.0.1:3000. +# API_URL=http://127.0.0.1:3000 diff --git a/packages/server/skel/front/_.github/workflows/ci.yml b/packages/server/skel/front/_.github/workflows/ci.yml new file mode 100644 index 00000000..ec0b2492 --- /dev/null +++ b/packages/server/skel/front/_.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + pull_request: + push: + branches: [main, master] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: Lint, typecheck, tests & build + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm lint + - run: pnpm typecheck + - run: pnpm test + + # a build that fails in CI is a deployment that would have failed + - run: pnpm build diff --git a/packages/server/skel/front/_.husky/commit-msg b/packages/server/skel/front/_.husky/commit-msg new file mode 100644 index 00000000..9ef41ae4 --- /dev/null +++ b/packages/server/skel/front/_.husky/commit-msg @@ -0,0 +1 @@ +pnpm commitlint --edit "$1" diff --git a/packages/server/skel/front/_.husky/pre-commit b/packages/server/skel/front/_.husky/pre-commit new file mode 100644 index 00000000..cb2c84d5 --- /dev/null +++ b/packages/server/skel/front/_.husky/pre-commit @@ -0,0 +1 @@ +pnpm lint-staged diff --git a/packages/server/skel/front/_.lintstagedrc.json b/packages/server/skel/front/_.lintstagedrc.json new file mode 100644 index 00000000..2cd3053b --- /dev/null +++ b/packages/server/skel/front/_.lintstagedrc.json @@ -0,0 +1,3 @@ +{ + "*.{ts,tsx}": "oxlint" +} diff --git a/packages/server/skel/front/_.nvmrc b/packages/server/skel/front/_.nvmrc new file mode 100644 index 00000000..a45fd52c --- /dev/null +++ b/packages/server/skel/front/_.nvmrc @@ -0,0 +1 @@ +24 diff --git a/packages/server/skel/front/commitlint.config.js b/packages/server/skel/front/commitlint.config.js new file mode 100644 index 00000000..aa57d579 --- /dev/null +++ b/packages/server/skel/front/commitlint.config.js @@ -0,0 +1,3 @@ +// Conventional Commits: feat, fix, chore, refactor, docs, test, ci… +// https://www.conventionalcommits.org +export default { extends: ['@commitlint/config-conventional'] }; diff --git a/packages/server/skel/front/package.json b/packages/server/skel/front/package.json index ed8af55f..cb4910ce 100644 --- a/packages/server/skel/front/package.json +++ b/packages/server/skel/front/package.json @@ -10,9 +10,11 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", + "lint": "oxlint", "test": "vitest run", "test:watch": "vitest", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "prepare": "husky" }, "dependencies": { "@tanstack/react-query": "^5.102.0", @@ -21,19 +23,24 @@ "react-router": "^8.3.0" }, "devDependencies": { + "@commitlint/cli": "^21.2.0", + "@commitlint/config-conventional": "^21.2.0", "@tailwindcss/vite": "^4.3.0", "@testing-library/jest-dom": "^6.9.0", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.0", + "@types/node": "^24.13.0", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^6.1.0", + "husky": "^9.1.7", "jsdom": "^30.0.0", + "lint-staged": "^17.5.0", "msw": "^2.12.0", + "oxlint": "^1.81.0", "tailwindcss": "^4.3.0", "typescript": "^7.0.0", "vite": "^8.2.0", - "vitest": "^5.0.0", - "@types/node": "^24.13.0" + "vitest": "^5.0.0" } } From 9cc99b3116eac78e5bf224551ad7990d5e5ae18f Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Mon, 7 Sep 2026 15:25:28 +0200 Subject: [PATCH 17/80] feat(server): squelette fullstack et E2E Playwright MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit igo create --skel=fullstack — back/ et front/ en workspaces pnpm dans un dépôt, avec la racine qui démarre, teste et build les deux. Un dépôt unique parce qu'un commit doit porter un front et un back cohérents, et que le déploiement livre un seul artefact — c'est ce que tranche l'ADR chaîne de build. Playwright tourne contre le BUILD du front, pas le serveur de développement : c'est ce qui est déployé. Vérifié de bout en bout — navigateur réel, proxy, API igo, MySQL : 3 tests passent. La CI reprend ce que ladom a appris : MySQL par docker run et non par `services:`, qui ne sait pas passer les flags serveur — igo se connecte en utf8mb4, et un serveur en latin1 casse au premier accent. Deux pièges corrigés au passage : - `vite preview` n'hérite pas de `server.proxy` : sans un bloc `preview`, le build servi aux E2E n'aurait aucune API derrière lui. - vite écoute sur localhost (IPv6) ; l'URL en 127.0.0.1 que Playwright interroge n'était jamais joignable, d'où --host. deploy/nginx.conf.example porte les deux réglages que l'ADR signale comme à ne pas découvrir en production : index.html non caché, try_files pour le routage client. Co-Authored-By: Claude Opus 5 (1M context) --- packages/server/cli/create.js | 2 +- .../server/skel/api/_.github/workflows/ci.yml | 73 +++++++-- packages/server/skel/front/vite.config.ts | 23 ++- packages/server/skel/fullstack/CLAUDE.md | 67 ++++++++ packages/server/skel/fullstack/README.md | 75 +++++++++ .../skel/fullstack/_.github/workflows/ci.yml | 150 ++++++++++++++++++ packages/server/skel/fullstack/_.gitignore | 10 ++ .../server/skel/fullstack/_.husky/commit-msg | 1 + .../server/skel/fullstack/_.husky/pre-commit | 1 + .../server/skel/fullstack/_.lintstagedrc.json | 3 + packages/server/skel/fullstack/_.nvmrc | 1 + .../server/skel/fullstack/back/.oxlintrc.json | 11 ++ .../server/skel/fullstack/back/_.env.example | 26 +++ .../server/skel/fullstack/back/_.mocharc.json | 12 ++ packages/server/skel/fullstack/back/app.ts | 4 + .../back/app/api/books/books.controller.ts | 61 +++++++ .../fullstack/back/app/api/books/books.dto.ts | 41 +++++ .../back/app/api/books/books.routes.ts | 14 ++ .../server/skel/fullstack/back/app/config.ts | 6 + .../skel/fullstack/back/app/models/Book.ts | 28 ++++ .../server/skel/fullstack/back/app/routes.ts | 17 ++ .../back/locales/en/translation.json | 3 + .../server/skel/fullstack/back/package.json | 36 +++++ .../fullstack/back/sql/20260101-books.sql | 9 ++ .../skel/fullstack/back/test/api/BooksTest.ts | 95 +++++++++++ .../server/skel/fullstack/back/tsconfig.json | 17 ++ .../skel/fullstack/commitlint.config.cjs | 3 + .../skel/fullstack/deploy/nginx.conf.example | 44 +++++ .../server/skel/fullstack/e2e/books.spec.ts | 34 ++++ .../skel/fullstack/front/.oxlintrc.json | 24 +++ .../server/skel/fullstack/front/_.env.example | 3 + .../server/skel/fullstack/front/index.html | 12 ++ .../server/skel/fullstack/front/package.json | 46 ++++++ .../src/components/layout/app-layout.tsx | 9 ++ .../fullstack/front/src/features/books/api.ts | 26 +++ .../books/components/books-list.test.tsx | 23 +++ .../features/books/components/books-list.tsx | 23 +++ .../features/books/pages/books-page.test.tsx | 62 ++++++++ .../src/features/books/pages/books-page.tsx | 28 ++++ .../books/sections/add-book-section.tsx | 53 +++++++ .../front/src/features/books/types.ts | 23 +++ .../server/skel/fullstack/front/src/index.css | 1 + .../fullstack/front/src/lib/api-client.ts | 49 ++++++ .../fullstack/front/src/lib/query-client.ts | 14 ++ .../server/skel/fullstack/front/src/main.tsx | 17 ++ .../skel/fullstack/front/src/routes.tsx | 13 ++ .../skel/fullstack/front/src/test/handlers.ts | 25 +++ .../fullstack/front/src/test/msw-server.ts | 5 + .../skel/fullstack/front/src/test/render.tsx | 15 ++ .../skel/fullstack/front/src/test/setup.ts | 14 ++ .../skel/fullstack/front/src/vite-env.d.ts | 1 + .../server/skel/fullstack/front/tsconfig.json | 39 +++++ .../skel/fullstack/front/vite.config.ts | 35 ++++ .../skel/fullstack/front/vitest.config.ts | 13 ++ packages/server/skel/fullstack/package.json | 28 ++++ .../skel/fullstack/playwright.config.ts | 34 ++++ .../server/skel/fullstack/pnpm-workspace.yaml | 10 ++ packages/server/test/CreateTest.js | 2 +- 58 files changed, 1487 insertions(+), 27 deletions(-) create mode 100644 packages/server/skel/fullstack/CLAUDE.md create mode 100644 packages/server/skel/fullstack/README.md create mode 100644 packages/server/skel/fullstack/_.github/workflows/ci.yml create mode 100644 packages/server/skel/fullstack/_.gitignore create mode 100644 packages/server/skel/fullstack/_.husky/commit-msg create mode 100644 packages/server/skel/fullstack/_.husky/pre-commit create mode 100644 packages/server/skel/fullstack/_.lintstagedrc.json create mode 100644 packages/server/skel/fullstack/_.nvmrc create mode 100644 packages/server/skel/fullstack/back/.oxlintrc.json create mode 100644 packages/server/skel/fullstack/back/_.env.example create mode 100644 packages/server/skel/fullstack/back/_.mocharc.json create mode 100644 packages/server/skel/fullstack/back/app.ts create mode 100644 packages/server/skel/fullstack/back/app/api/books/books.controller.ts create mode 100644 packages/server/skel/fullstack/back/app/api/books/books.dto.ts create mode 100644 packages/server/skel/fullstack/back/app/api/books/books.routes.ts create mode 100644 packages/server/skel/fullstack/back/app/config.ts create mode 100644 packages/server/skel/fullstack/back/app/models/Book.ts create mode 100644 packages/server/skel/fullstack/back/app/routes.ts create mode 100644 packages/server/skel/fullstack/back/locales/en/translation.json create mode 100644 packages/server/skel/fullstack/back/package.json create mode 100644 packages/server/skel/fullstack/back/sql/20260101-books.sql create mode 100644 packages/server/skel/fullstack/back/test/api/BooksTest.ts create mode 100644 packages/server/skel/fullstack/back/tsconfig.json create mode 100644 packages/server/skel/fullstack/commitlint.config.cjs create mode 100644 packages/server/skel/fullstack/deploy/nginx.conf.example create mode 100644 packages/server/skel/fullstack/e2e/books.spec.ts create mode 100644 packages/server/skel/fullstack/front/.oxlintrc.json create mode 100644 packages/server/skel/fullstack/front/_.env.example create mode 100644 packages/server/skel/fullstack/front/index.html create mode 100644 packages/server/skel/fullstack/front/package.json create mode 100644 packages/server/skel/fullstack/front/src/components/layout/app-layout.tsx create mode 100644 packages/server/skel/fullstack/front/src/features/books/api.ts create mode 100644 packages/server/skel/fullstack/front/src/features/books/components/books-list.test.tsx create mode 100644 packages/server/skel/fullstack/front/src/features/books/components/books-list.tsx create mode 100644 packages/server/skel/fullstack/front/src/features/books/pages/books-page.test.tsx create mode 100644 packages/server/skel/fullstack/front/src/features/books/pages/books-page.tsx create mode 100644 packages/server/skel/fullstack/front/src/features/books/sections/add-book-section.tsx create mode 100644 packages/server/skel/fullstack/front/src/features/books/types.ts create mode 100644 packages/server/skel/fullstack/front/src/index.css create mode 100644 packages/server/skel/fullstack/front/src/lib/api-client.ts create mode 100644 packages/server/skel/fullstack/front/src/lib/query-client.ts create mode 100644 packages/server/skel/fullstack/front/src/main.tsx create mode 100644 packages/server/skel/fullstack/front/src/routes.tsx create mode 100644 packages/server/skel/fullstack/front/src/test/handlers.ts create mode 100644 packages/server/skel/fullstack/front/src/test/msw-server.ts create mode 100644 packages/server/skel/fullstack/front/src/test/render.tsx create mode 100644 packages/server/skel/fullstack/front/src/test/setup.ts create mode 100644 packages/server/skel/fullstack/front/src/vite-env.d.ts create mode 100644 packages/server/skel/fullstack/front/tsconfig.json create mode 100644 packages/server/skel/fullstack/front/vite.config.ts create mode 100644 packages/server/skel/fullstack/front/vitest.config.ts create mode 100644 packages/server/skel/fullstack/package.json create mode 100644 packages/server/skel/fullstack/playwright.config.ts create mode 100644 packages/server/skel/fullstack/pnpm-workspace.yaml diff --git a/packages/server/cli/create.js b/packages/server/cli/create.js index e54ff1c2..7be588a0 100644 --- a/packages/server/cli/create.js +++ b/packages/server/cli/create.js @@ -59,7 +59,7 @@ const replaceInDirectory = async (dir, replacements) => { }; // igo create -const SKELETONS = ['tailwind', 'api', 'front']; +const SKELETONS = ['tailwind', 'api', 'front', 'fullstack']; module.exports = async function (argv) { const args = argv._; diff --git a/packages/server/skel/api/_.github/workflows/ci.yml b/packages/server/skel/api/_.github/workflows/ci.yml index c04f06b9..06050ccc 100644 --- a/packages/server/skel/api/_.github/workflows/ci.yml +++ b/packages/server/skel/api/_.github/workflows/ci.yml @@ -9,20 +9,29 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +env: + MYSQL_DATABASE: test + jobs: - check: - name: Lint, typecheck & tests + lint: + name: Lint & typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm lint + - run: pnpm typecheck + + test: + name: Tests runs-on: ubuntu-latest services: - mysql: - image: mysql:8 - env: - MYSQL_ALLOW_EMPTY_PASSWORD: "yes" - MYSQL_DATABASE: test - ports: - - 3306:3306 - options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 redis: image: redis ports: @@ -30,19 +39,49 @@ jobs: options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=3 steps: - - uses: actions/checkout@v4 + # `services:` cannot pass server flags, and igo connects in utf8mb4: + # a mismatched server charset breaks on the first accented character. + - name: Start MySQL + run: | + docker run -d --name mysql \ + -e MYSQL_ALLOW_EMPTY_PASSWORD=yes \ + -e MYSQL_DATABASE=${{ env.MYSQL_DATABASE }} \ + -p 3306:3306 \ + mysql:8 \ + --character-set-server=utf8mb4 \ + --collation-server=utf8mb4_unicode_ci \ + --innodb-default-row-format=dynamic \ + --innodb-strict-mode=0 + - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 with: - node-version: 24 + node-version-file: .nvmrc cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm lint - - run: pnpm typecheck + - name: Wait for MySQL + run: | + for i in $(seq 1 60); do + docker exec mysql mysqladmin ping --silent 2>/dev/null && exit 0 + sleep 2 + done + echo "MySQL never became ready" && exit 1 - # tests run against the real database, isolated by transaction rollback + # the test database is dropped, recreated and migrated by dev.test() - run: pnpm test + + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + - run: pnpm install --frozen-lockfile + # a build that fails here is a deployment that would have failed + - run: pnpm build diff --git a/packages/server/skel/front/vite.config.ts b/packages/server/skel/front/vite.config.ts index 72d493b2..e8c102a0 100644 --- a/packages/server/skel/front/vite.config.ts +++ b/packages/server/skel/front/vite.config.ts @@ -4,6 +4,11 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; +const API_PROXY = { + target: process.env.API_URL || 'http://127.0.0.1:3000', + changeOrigin: false, +}; + export default defineConfig({ plugins: [react(), tailwindcss()], @@ -14,15 +19,17 @@ export default defineConfig({ }, }, + // The browser sees a single origin, so the igo session cookie is sent like + // any same-origin cookie — no CORS, no credentials handling. In production + // nginx plays this role. server: { port: 5173, - proxy: { - // The browser sees a single origin, so the igo session cookie is sent - // like any same-origin cookie — no CORS, no credentials handling. - '/api': { - target: process.env.API_URL || 'http://127.0.0.1:3000', - changeOrigin: false, - }, - }, + proxy: { '/api': API_PROXY }, + }, + + // `vite preview` does not inherit server.proxy: without this, a build served + // for E2E tests would have no API behind it. + preview: { + proxy: { '/api': API_PROXY }, }, }); diff --git a/packages/server/skel/fullstack/CLAUDE.md b/packages/server/skel/fullstack/CLAUDE.md new file mode 100644 index 00000000..14ce029b --- /dev/null +++ b/packages/server/skel/fullstack/CLAUDE.md @@ -0,0 +1,67 @@ +# {project.name} + +API JSON igo + SPA React dans un seul dépôt. TypeScript, Node 24, pnpm. + +**Les conventions de code sont dans `back/CLAUDE.md` et `front/CLAUDE.md`.** Ce +fichier ne couvre que ce qui concerne les deux. + +## Commandes + +```bash +pnpm dev # back :3000 + front :5173 +pnpm build # back/dist + front/dist +pnpm lint # oxlint sur les deux +pnpm typecheck +pnpm test # tests back et front +pnpm test:e2e # Playwright contre le build +pnpm migrate +``` + +Une commande ciblée passe par un filtre : `pnpm --filter ./back test`. + +## Le contrat front/back + +Le back expose du JSON sous `/api`, le front le consomme par des **URL +relatives**. Jamais de base URL absolue — c'est ce qui permet au même build de +tourner sur tous les environnements. + +En développement, Vite proxifie `/api` vers igo ; en production, c'est nginx. Le +navigateur ne voit qu'une origine dans les deux cas, donc le cookie de session +passe sans CORS. + +**Les erreurs suivent RFC 9457.** Le back les émet avec `sendProblem`, le front +les lit avec `ApiError` : `fieldError(champ)` donne le message à afficher sous +un input. Le client teste `type` et `errors[].code`, jamais les libellés. + +**Les types du front reflètent les DTO du back**, écrits à la main. S'ils +dérivent, ce sont les tests de feature qui le montrent. + +## Ajouter un domaine + +Les deux côtés se répondent : + +``` +back/app/api// routes, controller, dto +front/src/features// api.ts, types.ts, pages, sections, components +``` + +Le back sert d'abord — le front consomme un contrat qui existe. + +## Les tests + +| Niveau | Où | Quand | +|---|---|---| +| Intégration back | `back/test/` | tout contrôleur API | +| Composant, feature | `front/src/**/*.test.tsx` | tout composant, toute section | +| E2E | `e2e/` | chemins critiques seulement | + +Les E2E tournent contre le **build**, pas le serveur de développement. Ils sont +lents : tout ce qui peut être couvert plus bas doit l'être plus bas. + +## Commits + +[Conventional Commits](https://www.conventionalcommits.org), vérifiés par un +hook. Le pre-commit passe oxlint sur les fichiers indexés. + +Un commit qui touche les deux côtés est normal — c'est l'intérêt du dépôt +unique : front et back restent cohérents par construction. diff --git a/packages/server/skel/fullstack/README.md b/packages/server/skel/fullstack/README.md new file mode 100644 index 00000000..6d844db5 --- /dev/null +++ b/packages/server/skel/fullstack/README.md @@ -0,0 +1,75 @@ +# {project.name} + +API JSON [igo](https://github.com/igocreate/igo) et SPA React, dans un seul +dépôt. TypeScript, Node 24, pnpm. + +## Démarrer + +```bash +pnpm install +pnpm migrate # crée les tables +pnpm dev # back sur :3000, front sur :5173 +``` + +Ouvrir http://localhost:5173. Le front proxifie `/api` vers le back : le +navigateur ne voit qu'une seule origine, donc **le cookie de session passe sans +CORS**. + +MySQL et Redis doivent tourner en local. + +## Commandes + +```bash +pnpm dev # les deux en parallèle +pnpm build # back -> back/dist, front -> front/dist +pnpm lint # oxlint sur les deux +pnpm typecheck # tsc sur les deux +pnpm test # tests back et front +pnpm test:e2e # Playwright contre le build +pnpm migrate # migrations SQL +``` + +## Structure + +``` +back/ API igo — voir back/CLAUDE.md +front/ SPA React — voir front/CLAUDE.md +e2e/ parcours Playwright +``` + +Deux paquets pnpm dans un dépôt : **un commit porte un front et un back +cohérents**, et le déploiement livre un seul artefact. + +## Les tests + +| Niveau | Où | Ce qu'il couvre | +|---|---|---| +| Intégration back | `back/test/` | route → contrôleur → DTO → base | +| Composant, feature | `front/src/**/*.test.tsx` | rendu, API simulée par MSW | +| E2E | `e2e/` | le câblage complet, navigateur réel | + +Les E2E tournent contre le **build** du front, pas le serveur de développement — +c'est ce qui est déployé. Ils restent peu nombreux : tout ce qui peut être +couvert plus bas doit l'être. + +## Déploiement + +Un seul artefact. Le build produit `back/dist` et `front/dist`. + +**nginx sert les statiques**, pas igo — `front/dist` va dans le répertoire servi +par nginx, et `/api` est passé au process Node. Deux réglages à ne pas +découvrir en production : + +- une `location = /index.html` **sans `expires max`** : sinon l'utilisateur + garde un fichier qui référence des assets disparus ; +- un `try_files` qui retombe sur `index.html`, sans quoi le routage client + renvoie des 404 sur rechargement. + +Voir `deploy/nginx.conf.example`. + +## Conventions + +[Conventional Commits](https://www.conventionalcommits.org), vérifiés par un +hook. Le pre-commit passe oxlint sur les fichiers indexés. + +Les conventions de code sont dans `back/CLAUDE.md` et `front/CLAUDE.md`. diff --git a/packages/server/skel/fullstack/_.github/workflows/ci.yml b/packages/server/skel/fullstack/_.github/workflows/ci.yml new file mode 100644 index 00000000..33b519a0 --- /dev/null +++ b/packages/server/skel/fullstack/_.github/workflows/ci.yml @@ -0,0 +1,150 @@ +name: CI + +on: + pull_request: + push: + branches: [main, master] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + MYSQL_DATABASE: test + +jobs: + lint: + name: Lint & typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm lint + - run: pnpm typecheck + + test: + name: Unit & integration tests + runs-on: ubuntu-latest + + services: + redis: + image: redis + ports: + - 6379:6379 + options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=3 + + steps: + # `services:` cannot pass server flags, and igo connects in utf8mb4: + # a mismatched server charset breaks on the first accented character. + - name: Start MySQL + run: | + docker run -d --name mysql \ + -e MYSQL_ALLOW_EMPTY_PASSWORD=yes \ + -e MYSQL_DATABASE=${{ env.MYSQL_DATABASE }} \ + -p 3306:3306 \ + mysql:8 \ + --character-set-server=utf8mb4 \ + --collation-server=utf8mb4_unicode_ci \ + --innodb-default-row-format=dynamic \ + --innodb-strict-mode=0 + + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + - run: pnpm install --frozen-lockfile + + - name: Wait for MySQL + run: | + for i in $(seq 1 60); do + docker exec mysql mysqladmin ping --silent 2>/dev/null && exit 0 + sleep 2 + done + echo "MySQL never became ready" && exit 1 + + - run: pnpm test + + e2e: + name: E2E + runs-on: ubuntu-latest + timeout-minutes: 15 + + services: + redis: + image: redis + ports: + - 6379:6379 + options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=3 + + steps: + - name: Start MySQL + run: | + docker run -d --name mysql \ + -e MYSQL_ALLOW_EMPTY_PASSWORD=yes \ + -e MYSQL_DATABASE=${{ env.MYSQL_DATABASE }} \ + -p 3306:3306 \ + mysql:8 \ + --character-set-server=utf8mb4 \ + --collation-server=utf8mb4_unicode_ci \ + --innodb-default-row-format=dynamic \ + --innodb-strict-mode=0 + + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + - run: pnpm install --frozen-lockfile + + - name: Wait for MySQL + run: | + for i in $(seq 1 60); do + docker exec mysql mysqladmin ping --silent 2>/dev/null && exit 0 + sleep 2 + done + echo "MySQL never became ready" && exit 1 + + # E2E runs against the built front, not the dev server: that is what + # ships, and a build-only failure would otherwise reach production. + - run: pnpm migrate + env: + MYSQL_DATABASE: ${{ env.MYSQL_DATABASE }} + + - run: pnpm build + + - name: Install Playwright browsers + run: pnpm exec playwright install --with-deps chromium + + # `serve` runs from dist/, where the compiled app/routes.js lives + - name: Start the API + run: pnpm --filter ./back serve & + env: + NODE_ENV: production + MYSQL_DATABASE: ${{ env.MYSQL_DATABASE }} + COOKIE_SECRET: e2e-cookie-secret + COOKIE_SESSION_KEYS: e2e-session-key + + - name: Wait for the API + run: | + for i in $(seq 1 30); do + curl -sf http://127.0.0.1:3000/api/books > /dev/null && exit 0 + sleep 1 + done + echo "API failed to start" && exit 1 + + - run: pnpm test:e2e + + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 14 diff --git a/packages/server/skel/fullstack/_.gitignore b/packages/server/skel/fullstack/_.gitignore new file mode 100644 index 00000000..bcaf901f --- /dev/null +++ b/packages/server/skel/fullstack/_.gitignore @@ -0,0 +1,10 @@ + +node_modules + +.env +.DS_Store +*.log + +dist +playwright-report +test-results diff --git a/packages/server/skel/fullstack/_.husky/commit-msg b/packages/server/skel/fullstack/_.husky/commit-msg new file mode 100644 index 00000000..9ef41ae4 --- /dev/null +++ b/packages/server/skel/fullstack/_.husky/commit-msg @@ -0,0 +1 @@ +pnpm commitlint --edit "$1" diff --git a/packages/server/skel/fullstack/_.husky/pre-commit b/packages/server/skel/fullstack/_.husky/pre-commit new file mode 100644 index 00000000..cb2c84d5 --- /dev/null +++ b/packages/server/skel/fullstack/_.husky/pre-commit @@ -0,0 +1 @@ +pnpm lint-staged diff --git a/packages/server/skel/fullstack/_.lintstagedrc.json b/packages/server/skel/fullstack/_.lintstagedrc.json new file mode 100644 index 00000000..2cd3053b --- /dev/null +++ b/packages/server/skel/fullstack/_.lintstagedrc.json @@ -0,0 +1,3 @@ +{ + "*.{ts,tsx}": "oxlint" +} diff --git a/packages/server/skel/fullstack/_.nvmrc b/packages/server/skel/fullstack/_.nvmrc new file mode 100644 index 00000000..a45fd52c --- /dev/null +++ b/packages/server/skel/fullstack/_.nvmrc @@ -0,0 +1 @@ +24 diff --git a/packages/server/skel/fullstack/back/.oxlintrc.json b/packages/server/skel/fullstack/back/.oxlintrc.json new file mode 100644 index 00000000..8ff8c2a3 --- /dev/null +++ b/packages/server/skel/fullstack/back/.oxlintrc.json @@ -0,0 +1,11 @@ +{ + "plugins": ["typescript", "unicorn", "oxc"], + "categories": { + "correctness": "error", + "suspicious": "warn" + }, + "rules": { + "no-console": "warn" + }, + "ignorePatterns": ["dist", "node_modules"] +} diff --git a/packages/server/skel/fullstack/back/_.env.example b/packages/server/skel/fullstack/back/_.env.example new file mode 100644 index 00000000..6e5d3ba2 --- /dev/null +++ b/packages/server/skel/fullstack/back/_.env.example @@ -0,0 +1,26 @@ +# Copy to .env — never commit .env +NODE_ENV=dev +HTTP_PORT=3000 + +# Sessions: generate with `openssl rand -hex 32`. igo refuses to start in +# production with the default values. +COOKIE_SECRET= +COOKIE_SESSION_KEYS= + +MYSQL_HOST=127.0.0.1 +MYSQL_PORT=3306 +MYSQL_USERNAME=root +MYSQL_PASSWORD= +MYSQL_DATABASE= + +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 + +# json in production, human elsewhere +# LOG_FORMAT=json +# LOG_LEVEL=info + +# SMTP_HOST= +# SMTP_USER= +# SMTP_PASSWORD= +# SMTP_FROM= diff --git a/packages/server/skel/fullstack/back/_.mocharc.json b/packages/server/skel/fullstack/back/_.mocharc.json new file mode 100644 index 00000000..f9317747 --- /dev/null +++ b/packages/server/skel/fullstack/back/_.mocharc.json @@ -0,0 +1,12 @@ +{ + "recursive": true, + "extension": [ + "ts" + ], + "require": [ + "tsx" + ], + "reporter": "dot", + "exit": true, + "timeout": 10000 +} diff --git a/packages/server/skel/fullstack/back/app.ts b/packages/server/skel/fullstack/back/app.ts new file mode 100644 index 00000000..159e50f0 --- /dev/null +++ b/packages/server/skel/fullstack/back/app.ts @@ -0,0 +1,4 @@ + +import { app } from '@igojs/server'; + +app.run(); diff --git a/packages/server/skel/fullstack/back/app/api/books/books.controller.ts b/packages/server/skel/fullstack/back/app/api/books/books.controller.ts new file mode 100644 index 00000000..c2fe0e44 --- /dev/null +++ b/packages/server/skel/fullstack/back/app/api/books/books.controller.ts @@ -0,0 +1,61 @@ + +import { sendProblem } from '@igojs/server'; +import type { ApiHandler } from '@igojs/server'; + +import Book from '../../models/Book'; +import * as dto from './books.dto'; + +// The schemas below give req.body and req.query their types: no shape is +// declared twice, and a field that is not in the schema is a compile error. +export const index: ApiHandler<{ query: typeof dto.ListBooks }> = async (req, res) => { + const { page, limit, published } = req.query; + + let query = Book.order('created_at desc'); + if (published !== undefined) { + query = query.where({ published }); + } + + const { rows, pagination } = await query.page(page, limit).list(); + res.json({ + books: rows.map(dto.serialize), + page: dto.serializePage(pagination), + }); +}; +index.query = dto.ListBooks; + +// +export const show: ApiHandler = async (req, res) => { + const book = await Book.find(req.params.id); + if (!book) { + return void sendProblem(res, 404, { detail: 'Book not found' }); + } + res.json(dto.serialize(book)); +}; + +// +export const create: ApiHandler<{ body: typeof dto.CreateBook }> = async (req, res) => { + const book = await Book.create(req.body); + res.status(201).json(dto.serialize(book)); +}; +create.body = dto.CreateBook; + +// +export const update: ApiHandler<{ body: typeof dto.UpdateBook }> = async (req, res) => { + const book = await Book.find(req.params.id); + if (!book) { + return void sendProblem(res, 404, { detail: 'Book not found' }); + } + await book.update(req.body); + res.json(dto.serialize(book)); +}; +update.body = dto.UpdateBook; + +// +export const destroy: ApiHandler = async (req, res) => { + const book = await Book.find(req.params.id); + if (!book) { + return void sendProblem(res, 404, { detail: 'Book not found' }); + } + await book.delete(); + res.status(204).end(); +}; diff --git a/packages/server/skel/fullstack/back/app/api/books/books.dto.ts b/packages/server/skel/fullstack/back/app/api/books/books.dto.ts new file mode 100644 index 00000000..efac875b --- /dev/null +++ b/packages/server/skel/fullstack/back/app/api/books/books.dto.ts @@ -0,0 +1,41 @@ + +import { z } from 'zod'; +import type { BookRow } from '../../models/Book'; + +// Incoming: what the API accepts. Coercion and defaults are applied before the +// controller runs, so req.body and req.query already hold the right types. +export const CreateBook = z.object({ + title: z.string().min(1).max(255), + author: z.string().min(1).max(255), + pages: z.number().int().positive(), + published: z.boolean().default(false), +}); + +export const UpdateBook = CreateBook.partial(); + +export const ListBooks = z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(25), + // z.coerce.boolean() would turn 'false' into true: URL flags need this form + published: z.enum(['true', 'false']).transform(v => v === 'true').optional(), +}); + +// Outgoing: the barrier between the ORM model and the API. Adding a column to +// the model exposes nothing until it is named here. +export const serialize = (book: BookRow) => ({ + id: book.id, + title: book.title, + author: book.author, + pages: book.pages, + published: book.published, + createdAt: book.created_at, +}); + +// The ORM pagination also carries `links`, meant for rendering page numbers in +// a template: an API client builds its own navigation. +export const serializePage = (pagination: { page: number; nb: number; nb_pages: number; count: number }) => ({ + page: pagination.page, + perPage: pagination.nb, + pages: pagination.nb_pages, + total: pagination.count, +}); diff --git a/packages/server/skel/fullstack/back/app/api/books/books.routes.ts b/packages/server/skel/fullstack/back/app/api/books/books.routes.ts new file mode 100644 index 00000000..a6ff1f37 --- /dev/null +++ b/packages/server/skel/fullstack/back/app/api/books/books.routes.ts @@ -0,0 +1,14 @@ + +import { express } from '@igojs/server'; + +import * as controller from './books.controller'; + +const router = express.Router(); + +router.get('/', controller.index); +router.post('/', controller.create); +router.get('/:id', controller.show); +router.put('/:id', controller.update); +router.delete('/:id', controller.destroy); + +export default router; diff --git a/packages/server/skel/fullstack/back/app/config.ts b/packages/server/skel/fullstack/back/app/config.ts new file mode 100644 index 00000000..30ba2f62 --- /dev/null +++ b/packages/server/skel/fullstack/back/app/config.ts @@ -0,0 +1,6 @@ +import type { Config } from '@igojs/server'; + +export const init = (config: Config) => { + config.cookieSecret = '{RANDOM_1}'; + config.cookieSession.keys = [ '{RANDOM_2}' ]; +}; diff --git a/packages/server/skel/fullstack/back/app/models/Book.ts b/packages/server/skel/fullstack/back/app/models/Book.ts new file mode 100644 index 00000000..047a0a82 --- /dev/null +++ b/packages/server/skel/fullstack/back/app/models/Book.ts @@ -0,0 +1,28 @@ + +const { Model } = require('@igojs/db'); + +const schema = { + table: 'books', + columns: [ + 'id', + 'title', + 'author', + 'pages', + { name: 'published', type: 'boolean' }, + 'created_at', + ], +}; + +export interface BookRow { + id: number; + title: string; + author: string; + pages: number; + published: boolean; + created_at: Date; +} + +class Book extends Model(schema) { +} + +export default Book; diff --git a/packages/server/skel/fullstack/back/app/routes.ts b/packages/server/skel/fullstack/back/app/routes.ts new file mode 100644 index 00000000..4af76e2a --- /dev/null +++ b/packages/server/skel/fullstack/back/app/routes.ts @@ -0,0 +1,17 @@ +// Define your routes here +// Check http://expressjs.com/en/guide/routing.html for documentation + +import type { Express } from 'express'; + +import books from './api/books/books.routes'; + +// +export const init = (app: Express) => { + + // mounted under config.api.prefix -> /api/books + app.api('/books', books); + + app.get('/', (req, res) => { + res.json({ name: '{project.name}', status: 'running' }); + }); +}; diff --git a/packages/server/skel/fullstack/back/locales/en/translation.json b/packages/server/skel/fullstack/back/locales/en/translation.json new file mode 100644 index 00000000..f42a0b2a --- /dev/null +++ b/packages/server/skel/fullstack/back/locales/en/translation.json @@ -0,0 +1,3 @@ +{ + "title": "Igo running here" +} diff --git a/packages/server/skel/fullstack/back/package.json b/packages/server/skel/fullstack/back/package.json new file mode 100644 index 00000000..f7b96e2f --- /dev/null +++ b/packages/server/skel/fullstack/back/package.json @@ -0,0 +1,36 @@ +{ + "name": "{project.name}-back", + "version": "0.0.1", + "description": "", + "main": "dist/app.js", + "scripts": { + "build": "tsc && cp -R sql locales dist/", + "start": "tsx watch app.ts", + "serve": "cd dist && node app.js", + "lint": "oxlint", + "test": "mocha", + "typecheck": "tsc --noEmit", + "prepare": "husky" + }, + "author": "", + "license": "ISC", + "engines": { + "node": ">=24" + }, + "dependencies": { + "@igojs/igo": "{igo.version}", + "zod": "^4.5.4" + }, + "devDependencies": { + "@commitlint/cli": "^21.2.0", + "@commitlint/config-conventional": "^21.2.0", + "@types/express": "^5.0.6", + "@types/mocha": "^10.0.10", + "@types/node": "^24.0.0", + "husky": "^9.1.7", + "lint-staged": "^17.5.0", + "oxlint": "^1.81.0", + "tsx": "^4.20.0", + "typescript": "^7.0.0" + } +} diff --git a/packages/server/skel/fullstack/back/sql/20260101-books.sql b/packages/server/skel/fullstack/back/sql/20260101-books.sql new file mode 100644 index 00000000..5f0ab4b7 --- /dev/null +++ b/packages/server/skel/fullstack/back/sql/20260101-books.sql @@ -0,0 +1,9 @@ +CREATE TABLE books ( + id INT NOT NULL AUTO_INCREMENT, + title VARCHAR(255) NOT NULL, + author VARCHAR(255) NOT NULL, + pages INT NOT NULL, + published TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id) +); diff --git a/packages/server/skel/fullstack/back/test/api/BooksTest.ts b/packages/server/skel/fullstack/back/test/api/BooksTest.ts new file mode 100644 index 00000000..eb2e5c88 --- /dev/null +++ b/packages/server/skel/fullstack/back/test/api/BooksTest.ts @@ -0,0 +1,95 @@ +import { dev } from '@igojs/server'; +import assert from 'assert'; + +import Book from '../../app/models/Book'; + +dev.test(); + +const agent = dev.agent; + +const createBook = (values = {}) => Book.create({ + title: 'Dune', author: 'Frank Herbert', pages: 412, ...values +}); + +describe('api/books', function() { + + describe('GET /api/books', function() { + + it('should list the books', async () => { + await createBook(); + + const res = await agent.get('/api/books'); + + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(res.data.books.length, 1); + assert.strictEqual(res.data.books[0].title, 'Dune'); + assert.strictEqual(res.data.page.total, 1); + }); + + it('should reject an invalid query param', async () => { + const res = await agent.get('/api/books?page=0'); + + assert.strictEqual(res.statusCode, 400); + assert.deepStrictEqual(res.data.errors.map((e: { path: string }) => e.path), ['page']); + }); + }); + + describe('GET /api/books/:id', function() { + + it('should expose only the serialized fields', async () => { + const book = await createBook(); + + const res = await agent.get(`/api/books/${book.id}`); + + assert.strictEqual(res.statusCode, 200); + assert.deepStrictEqual(Object.keys(res.data).toSorted(), + ['author', 'createdAt', 'id', 'pages', 'published', 'title']); + }); + + it('should answer 404 for an unknown id', async () => { + const res = await agent.get('/api/books/999999'); + + assert.strictEqual(res.statusCode, 404); + assert.strictEqual(res.data.status, 404); + }); + }); + + describe('POST /api/books', function() { + + it('should create a book', async () => { + const res = await agent.post('/api/books', { + body: { title: 'Dune', author: 'Frank Herbert', pages: 412 } + }); + + assert.strictEqual(res.statusCode, 201); + assert.strictEqual(res.data.title, 'Dune'); + assert.strictEqual(res.data.published, false); + + const book = await Book.find(res.data.id); + assert.strictEqual(book.title, 'Dune'); + }); + + it('should reject an invalid body', async () => { + const res = await agent.post('/api/books', { body: { title: '', pages: 'many' } }); + + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(res.data.title, 'Validation failed'); + assert.deepStrictEqual( + res.data.errors.map((e: { path: string }) => e.path).toSorted(), + ['author', 'pages', 'title'] + ); + }); + }); + + describe('DELETE /api/books/:id', function() { + + it('should delete the book', async () => { + const book = await createBook(); + + const res = await agent.delete(`/api/books/${book.id}`); + + assert.strictEqual(res.statusCode, 204); + assert.strictEqual(await Book.find(book.id), null); + }); + }); +}); diff --git a/packages/server/skel/fullstack/back/tsconfig.json b/packages/server/skel/fullstack/back/tsconfig.json new file mode 100644 index 00000000..4ebd94ff --- /dev/null +++ b/packages/server/skel/fullstack/back/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "allowJs": true, + "resolveJsonModule": true, + "outDir": "dist", + "rootDir": ".", + "sourceMap": true, + "skipLibCheck": true, + "types": ["node", "mocha"] + }, + "include": ["app.ts", "app/**/*.ts", "test/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/server/skel/fullstack/commitlint.config.cjs b/packages/server/skel/fullstack/commitlint.config.cjs new file mode 100644 index 00000000..d3da6028 --- /dev/null +++ b/packages/server/skel/fullstack/commitlint.config.cjs @@ -0,0 +1,3 @@ +// Conventional Commits: feat, fix, chore, refactor, docs, test, ci… +// https://www.conventionalcommits.org +module.exports = { extends: ['@commitlint/config-conventional'] }; diff --git a/packages/server/skel/fullstack/deploy/nginx.conf.example b/packages/server/skel/fullstack/deploy/nginx.conf.example new file mode 100644 index 00000000..32418bd9 --- /dev/null +++ b/packages/server/skel/fullstack/deploy/nginx.conf.example @@ -0,0 +1,44 @@ +# Serving {project.name}: nginx serves the built front, Node serves the API. +# +# Copy, adjust the paths and the server_name, and drop into sites-available. + +upstream app { + server 127.0.0.1:3000; + keepalive 16; +} + +server { + listen 80; + server_name example.com; + + root /var/www/{project.name}/front/dist; + + # The API is the Node process. Everything else is a static file. + location /api/ { + proxy_pass http://app; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + # igo reuses an inbound request id, which ties nginx and app logs together + proxy_set_header X-Request-Id $request_id; + } + + # Hashed filenames: safe to cache forever. + location /assets/ { + expires max; + add_header Cache-Control "public, immutable"; + } + + # index.html must NOT be cached: it is the file that names the hashed assets. + # Cached, a returning user keeps pointing at assets that no longer exist. + location = /index.html { + add_header Cache-Control "no-cache"; + } + + # Client-side routing: an unknown path is a route, not a missing file. + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/packages/server/skel/fullstack/e2e/books.spec.ts b/packages/server/skel/fullstack/e2e/books.spec.ts new file mode 100644 index 00000000..a83efbf9 --- /dev/null +++ b/packages/server/skel/fullstack/e2e/books.spec.ts @@ -0,0 +1,34 @@ +import { expect, test } from '@playwright/test'; + +// E2E covers the wiring end to end — browser, front build, proxy, API, database. +// Everything below that is already covered faster by the front and back tests, +// so this file stays short on purpose. +test.describe('books', () => { + + test('should list the books served by the API', async ({ page }) => { + await page.goto('/'); + + await expect(page.getByRole('heading', { name: 'Books' })).toBeVisible(); + await expect(page.getByText(/loading/i)).toBeHidden(); + }); + + test('should add a book and show it in the list', async ({ page }) => { + await page.goto('/'); + + const title = `Dune ${Date.now()}`; + await page.getByLabel('title').fill(title); + await page.getByLabel('author').fill('Frank Herbert'); + await page.getByLabel('pages').fill('412'); + await page.getByRole('button', { name: /add book/i }).click(); + + await expect(page.getByText(title)).toBeVisible(); + }); + + test('should show the validation errors the server returns', async ({ page }) => { + await page.goto('/'); + + await page.getByRole('button', { name: /add book/i }).click(); + + await expect(page.getByRole('alert').first()).toBeVisible(); + }); +}); diff --git a/packages/server/skel/fullstack/front/.oxlintrc.json b/packages/server/skel/fullstack/front/.oxlintrc.json new file mode 100644 index 00000000..0adaeddc --- /dev/null +++ b/packages/server/skel/fullstack/front/.oxlintrc.json @@ -0,0 +1,24 @@ +{ + "plugins": [ + "typescript", + "unicorn", + "oxc", + "react", + "react-perf", + "jsx-a11y", + "import" + ], + "categories": { + "correctness": "error", + "suspicious": "warn" + }, + "rules": { + "no-console": "warn", + "react/react-in-jsx-scope": "off", + "import/no-unassigned-import": "off" + }, + "ignorePatterns": [ + "dist", + "node_modules" + ] +} diff --git a/packages/server/skel/fullstack/front/_.env.example b/packages/server/skel/fullstack/front/_.env.example new file mode 100644 index 00000000..47c15306 --- /dev/null +++ b/packages/server/skel/fullstack/front/_.env.example @@ -0,0 +1,3 @@ +# Copy to .env — never commit .env +# Where the dev proxy sends /api. Defaults to http://127.0.0.1:3000. +# API_URL=http://127.0.0.1:3000 diff --git a/packages/server/skel/fullstack/front/index.html b/packages/server/skel/fullstack/front/index.html new file mode 100644 index 00000000..9620d098 --- /dev/null +++ b/packages/server/skel/fullstack/front/index.html @@ -0,0 +1,12 @@ + + + + + + {project.name} + + +
+ + + diff --git a/packages/server/skel/fullstack/front/package.json b/packages/server/skel/fullstack/front/package.json new file mode 100644 index 00000000..cb4910ce --- /dev/null +++ b/packages/server/skel/fullstack/front/package.json @@ -0,0 +1,46 @@ +{ + "name": "{project.name}-front", + "private": true, + "version": "0.0.1", + "type": "module", + "engines": { + "node": ">=24" + }, + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "oxlint", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "prepare": "husky" + }, + "dependencies": { + "@tanstack/react-query": "^5.102.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router": "^8.3.0" + }, + "devDependencies": { + "@commitlint/cli": "^21.2.0", + "@commitlint/config-conventional": "^21.2.0", + "@tailwindcss/vite": "^4.3.0", + "@testing-library/jest-dom": "^6.9.0", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.0", + "@types/node": "^24.13.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^6.1.0", + "husky": "^9.1.7", + "jsdom": "^30.0.0", + "lint-staged": "^17.5.0", + "msw": "^2.12.0", + "oxlint": "^1.81.0", + "tailwindcss": "^4.3.0", + "typescript": "^7.0.0", + "vite": "^8.2.0", + "vitest": "^5.0.0" + } +} diff --git a/packages/server/skel/fullstack/front/src/components/layout/app-layout.tsx b/packages/server/skel/fullstack/front/src/components/layout/app-layout.tsx new file mode 100644 index 00000000..c9164f15 --- /dev/null +++ b/packages/server/skel/fullstack/front/src/components/layout/app-layout.tsx @@ -0,0 +1,9 @@ +import { Outlet } from 'react-router'; + +export function AppLayout() { + return ( +
+ +
+ ); +} diff --git a/packages/server/skel/fullstack/front/src/features/books/api.ts b/packages/server/skel/fullstack/front/src/features/books/api.ts new file mode 100644 index 00000000..8abdbd0b --- /dev/null +++ b/packages/server/skel/fullstack/front/src/features/books/api.ts @@ -0,0 +1,26 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { apiClient } from '@/lib/api-client'; + +import type { Book, BooksPage, CreateBook } from './types'; + +const keys = { + all: ['books'] as const, + list: (page: number) => ['books', { page }] as const, +}; + +export function useBooks(page = 1) { + return useQuery({ + queryKey: keys.list(page), + queryFn: () => apiClient.get(`/api/books?page=${page}`), + }); +} + +export function useCreateBook() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (book: CreateBook) => apiClient.post('/api/books', book), + onSuccess: () => queryClient.invalidateQueries({ queryKey: keys.all }), + }); +} diff --git a/packages/server/skel/fullstack/front/src/features/books/components/books-list.test.tsx b/packages/server/skel/fullstack/front/src/features/books/components/books-list.test.tsx new file mode 100644 index 00000000..91bb41a3 --- /dev/null +++ b/packages/server/skel/fullstack/front/src/features/books/components/books-list.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { aBook } from '@/test/handlers'; + +import { BooksList } from './books-list'; + +// A pure component needs no providers: props in, markup out. +describe('BooksList', () => { + + it('should list every book', () => { + render(); + + expect(screen.getByText('Dune')).toBeInTheDocument(); + expect(screen.getByText('Neuromancer')).toBeInTheDocument(); + }); + + it('should say so when there is nothing to show', () => { + render(); + + expect(screen.getByText(/no book yet/i)).toBeInTheDocument(); + }); +}); diff --git a/packages/server/skel/fullstack/front/src/features/books/components/books-list.tsx b/packages/server/skel/fullstack/front/src/features/books/components/books-list.tsx new file mode 100644 index 00000000..f4428937 --- /dev/null +++ b/packages/server/skel/fullstack/front/src/features/books/components/books-list.tsx @@ -0,0 +1,23 @@ +import type { Book } from '../types'; + +// Pure: everything arrives through props. No useQuery here — see the data +// injection rule in the front conventions. +export function BooksList({ books }: { books: Book[] }) { + if (books.length === 0) { + return

No book yet.

; + } + + return ( +
    + {books.map(book => ( +
  • +
    + {book.title} + {book.author} +
    + {book.pages} pages +
  • + ))} +
+ ); +} diff --git a/packages/server/skel/fullstack/front/src/features/books/pages/books-page.test.tsx b/packages/server/skel/fullstack/front/src/features/books/pages/books-page.test.tsx new file mode 100644 index 00000000..a6a75787 --- /dev/null +++ b/packages/server/skel/fullstack/front/src/features/books/pages/books-page.test.tsx @@ -0,0 +1,62 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { describe, expect, it } from 'vitest'; + +import { renderWithProviders } from '@/test/render'; +import { server } from '@/test/msw-server'; + +import { BooksPage } from './books-page'; + +describe('BooksPage', () => { + + it('should show the books once loaded', async () => { + renderWithProviders(); + + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + expect(await screen.findByText('Dune')).toBeInTheDocument(); + }); + + it('should report a server error instead of loading forever', async () => { + server.use(http.get('/api/books', () => + HttpResponse.json( + { type: 'about:blank', title: 'Internal Server Error', status: 500 }, + { status: 500 } + ) + )); + + renderWithProviders(); + + expect(await screen.findByRole('alert')).toHaveTextContent(/internal server error/i); + }); + + it('should show validation errors under the fields the server named', async () => { + server.use(http.post('/api/books', () => + HttpResponse.json({ + type: 'urn:igo:validation-failed', + title: 'Validation failed', + status: 400, + errors: [{ path: 'title', code: 'too_small', message: 'Too small' }], + }, { status: 400 }) + )); + + renderWithProviders(); + await screen.findByText('Dune'); + + await userEvent.click(screen.getByRole('button', { name: /add book/i })); + + expect(await screen.findByText('Too small')).toBeInTheDocument(); + }); + + it('should add a book and refresh the list', async () => { + renderWithProviders(); + await screen.findByText('Dune'); + + await userEvent.type(screen.getByLabelText(/title/i), 'Neuromancer'); + await userEvent.type(screen.getByLabelText(/author/i), 'Gibson'); + await userEvent.type(screen.getByLabelText(/pages/i), '271'); + await userEvent.click(screen.getByRole('button', { name: /add book/i })); + + await waitFor(() => expect(screen.getByLabelText(/title/i)).toHaveValue('')); + }); +}); diff --git a/packages/server/skel/fullstack/front/src/features/books/pages/books-page.tsx b/packages/server/skel/fullstack/front/src/features/books/pages/books-page.tsx new file mode 100644 index 00000000..6c2bf0c5 --- /dev/null +++ b/packages/server/skel/fullstack/front/src/features/books/pages/books-page.tsx @@ -0,0 +1,28 @@ +import { useBooks } from '../api'; +import { BooksList } from '../components/books-list'; +import { AddBookSection } from '../sections/add-book-section'; + +// A page assembles. Loading and error states are handled explicitly rather +// than left to a spinner that never resolves. +export function BooksPage() { + const { data, isPending, isError, error } = useBooks(); + + return ( + <> +

Books

+ + + + {isPending &&

Loading…

} + {isError &&

{error.message}

} + {data && ( + <> + +

{data.page.total} in total

+ + )} + + ); +} + +export const Component = BooksPage; diff --git a/packages/server/skel/fullstack/front/src/features/books/sections/add-book-section.tsx b/packages/server/skel/fullstack/front/src/features/books/sections/add-book-section.tsx new file mode 100644 index 00000000..4f170c22 --- /dev/null +++ b/packages/server/skel/fullstack/front/src/features/books/sections/add-book-section.tsx @@ -0,0 +1,53 @@ +import { useState } from 'react'; + +import { ApiError } from '@/lib/api-client'; + +import { useCreateBook } from '../api'; + +const EMPTY = { title: '', author: '', pages: '' }; + +// A section owns its mutation. The server is the authority on validity: its +// per-field errors are displayed as they come, without being re-derived here. +export function AddBookSection() { + const [form, setForm] = useState(EMPTY); + const createBook = useCreateBook(); + + const error = createBook.error instanceof ApiError ? createBook.error : null; + + const submit = (event: React.FormEvent) => { + event.preventDefault(); + createBook.mutate( + { title: form.title, author: form.author, pages: Number(form.pages) }, + { onSuccess: () => setForm(EMPTY) } + ); + }; + + return ( +
+ {(['title', 'author', 'pages'] as const).map(field => ( +
+ + setForm({ ...form, [field]: e.target.value })} + className="mt-1 w-full rounded border border-slate-300 px-3 py-2" + /> + {error?.fieldError(field) && ( +

{error.fieldError(field)}

+ )} +
+ ))} + + +
+ ); +} diff --git a/packages/server/skel/fullstack/front/src/features/books/types.ts b/packages/server/skel/fullstack/front/src/features/books/types.ts new file mode 100644 index 00000000..8b83ee9d --- /dev/null +++ b/packages/server/skel/fullstack/front/src/features/books/types.ts @@ -0,0 +1,23 @@ +// Mirrors the DTO the back serializes. Kept by hand: the back is JavaScript, +// so there is no contract to generate from — a mismatch shows up in the +// feature tests, which run against the real payload shape. +export interface Book { + id: number; + title: string; + author: string; + pages: number; + published: boolean; + createdAt: string; +} + +export interface BooksPage { + books: Book[]; + page: { page: number; perPage: number; pages: number; total: number }; +} + +export interface CreateBook { + title: string; + author: string; + pages: number; + published?: boolean; +} diff --git a/packages/server/skel/fullstack/front/src/index.css b/packages/server/skel/fullstack/front/src/index.css new file mode 100644 index 00000000..f1d8c73c --- /dev/null +++ b/packages/server/skel/fullstack/front/src/index.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/packages/server/skel/fullstack/front/src/lib/api-client.ts b/packages/server/skel/fullstack/front/src/lib/api-client.ts new file mode 100644 index 00000000..b1cac65a --- /dev/null +++ b/packages/server/skel/fullstack/front/src/lib/api-client.ts @@ -0,0 +1,49 @@ +// RFC 9457 problem document, as returned by igo on every API error. +export interface Problem { + type: string; + title: string; + status: number; + detail?: string; + errors?: { path: string; code?: string; message: string }[]; +} + +export class ApiError extends Error { + readonly problem: Problem; + + constructor(problem: Problem) { + super(problem.detail || problem.title); + this.name = 'ApiError'; + this.problem = problem; + } + + /** Message for one field, to sit under the input that caused it. */ + fieldError(path: string): string | undefined { + return this.problem.errors?.find(e => e.path === path)?.message; + } +} + +// Relative URLs on purpose: the same build then runs against every +// environment, behind the dev proxy or behind nginx. +const request = async (method: string, path: string, body?: unknown): Promise => { + const response = await fetch(path, { + method, + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + const problem = await response.json().catch(() => ({ + type: 'about:blank', title: response.statusText, status: response.status, + })); + throw new ApiError(problem as Problem); + } + + return response.status === 204 ? (undefined as T) : response.json(); +}; + +export const apiClient = { + get: (path: string) => request('GET', path), + post: (path: string, body: unknown) => request('POST', path, body), + put: (path: string, body: unknown) => request('PUT', path, body), + delete: (path: string) => request('DELETE', path), +}; diff --git a/packages/server/skel/fullstack/front/src/lib/query-client.ts b/packages/server/skel/fullstack/front/src/lib/query-client.ts new file mode 100644 index 00000000..815485d0 --- /dev/null +++ b/packages/server/skel/fullstack/front/src/lib/query-client.ts @@ -0,0 +1,14 @@ +import { QueryClient } from '@tanstack/react-query'; + +import { ApiError } from './api-client'; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + // a 404 or a validation error will not fix itself on retry + retry: (failureCount, error) => + !(error instanceof ApiError && error.problem.status < 500) && failureCount < 2, + }, + }, +}); diff --git a/packages/server/skel/fullstack/front/src/main.tsx b/packages/server/skel/fullstack/front/src/main.tsx new file mode 100644 index 00000000..a017a72e --- /dev/null +++ b/packages/server/skel/fullstack/front/src/main.tsx @@ -0,0 +1,17 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { RouterProvider } from 'react-router'; + +import { queryClient } from '@/lib/query-client'; +import { router } from '@/routes'; + +import './index.css'; + +createRoot(document.getElementById('root')!).render( + + + + + +); diff --git a/packages/server/skel/fullstack/front/src/routes.tsx b/packages/server/skel/fullstack/front/src/routes.tsx new file mode 100644 index 00000000..d04a59bc --- /dev/null +++ b/packages/server/skel/fullstack/front/src/routes.tsx @@ -0,0 +1,13 @@ +import { createBrowserRouter } from 'react-router'; + +import { AppLayout } from '@/components/layout/app-layout'; + +export const router = createBrowserRouter([ + { + element: , + children: [ + // lazy per feature: a route is only downloaded when it is visited + { index: true, lazy: () => import('@/features/books/pages/books-page') }, + ], + }, +]); diff --git a/packages/server/skel/fullstack/front/src/test/handlers.ts b/packages/server/skel/fullstack/front/src/test/handlers.ts new file mode 100644 index 00000000..5defd591 --- /dev/null +++ b/packages/server/skel/fullstack/front/src/test/handlers.ts @@ -0,0 +1,25 @@ +import { http, HttpResponse } from 'msw'; + +import type { Book } from '@/features/books/types'; + +export const aBook = (overrides: Partial = {}): Book => ({ + id: 1, title: 'Dune', author: 'Frank Herbert', pages: 412, + published: true, createdAt: '2026-01-01T00:00:00.000Z', + ...overrides, +}); + +// Default handlers describe the happy path; a test overrides the one case it +// is about with server.use(). +export const handlers = [ + http.get('/api/books', () => + HttpResponse.json({ + books: [aBook()], + page: { page: 1, perPage: 25, pages: 1, total: 1 }, + }) + ), + + http.post('/api/books', async ({ request }) => { + const body = (await request.json()) as Partial; + return HttpResponse.json(aBook({ id: 2, ...body }), { status: 201 }); + }), +]; diff --git a/packages/server/skel/fullstack/front/src/test/msw-server.ts b/packages/server/skel/fullstack/front/src/test/msw-server.ts new file mode 100644 index 00000000..5ac9204f --- /dev/null +++ b/packages/server/skel/fullstack/front/src/test/msw-server.ts @@ -0,0 +1,5 @@ +import { setupServer } from 'msw/node'; + +import { handlers } from './handlers'; + +export const server = setupServer(...handlers); diff --git a/packages/server/skel/fullstack/front/src/test/render.tsx b/packages/server/skel/fullstack/front/src/test/render.tsx new file mode 100644 index 00000000..0a2d8370 --- /dev/null +++ b/packages/server/skel/fullstack/front/src/test/render.tsx @@ -0,0 +1,15 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render } from '@testing-library/react'; +import type { ReactElement } from 'react'; + +// A fresh client per test: a cache shared between tests makes them pass or +// fail depending on their order. Retries off so an error surfaces at once. +export function renderWithProviders(ui: ReactElement) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + + return render( + {ui} + ); +} diff --git a/packages/server/skel/fullstack/front/src/test/setup.ts b/packages/server/skel/fullstack/front/src/test/setup.ts new file mode 100644 index 00000000..91f1d1e7 --- /dev/null +++ b/packages/server/skel/fullstack/front/src/test/setup.ts @@ -0,0 +1,14 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup } from '@testing-library/react'; +import { afterAll, afterEach, beforeAll } from 'vitest'; + +import { server } from './msw-server'; + +// MSW intercepts at the network level, so the production apiClient runs +// untouched: swapping the HTTP wrapper does not break these tests. +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterEach(() => { + server.resetHandlers(); + cleanup(); +}); +afterAll(() => server.close()); diff --git a/packages/server/skel/fullstack/front/src/vite-env.d.ts b/packages/server/skel/fullstack/front/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/packages/server/skel/fullstack/front/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/server/skel/fullstack/front/tsconfig.json b/packages/server/skel/fullstack/front/tsconfig.json new file mode 100644 index 00000000..341289fc --- /dev/null +++ b/packages/server/skel/fullstack/front/tsconfig.json @@ -0,0 +1,39 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": [ + "ES2023", + "DOM", + "DOM.Iterable" + ], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "types": [ + "vitest/globals", + "@testing-library/jest-dom" + ], + "paths": { + "@/*": [ + "./src/*" + ] + }, + "moduleDetection": "force", + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "erasableSyntaxOnly": true + }, + "include": [ + "src", + "vite.config.ts", + "vitest.config.ts" + ] +} diff --git a/packages/server/skel/fullstack/front/vite.config.ts b/packages/server/skel/fullstack/front/vite.config.ts new file mode 100644 index 00000000..e8c102a0 --- /dev/null +++ b/packages/server/skel/fullstack/front/vite.config.ts @@ -0,0 +1,35 @@ +import { fileURLToPath, URL } from 'node:url'; + +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; + +const API_PROXY = { + target: process.env.API_URL || 'http://127.0.0.1:3000', + changeOrigin: false, +}; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + + // tsconfig paths are for the type checker only: the bundler needs its own + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + + // The browser sees a single origin, so the igo session cookie is sent like + // any same-origin cookie — no CORS, no credentials handling. In production + // nginx plays this role. + server: { + port: 5173, + proxy: { '/api': API_PROXY }, + }, + + // `vite preview` does not inherit server.proxy: without this, a build served + // for E2E tests would have no API behind it. + preview: { + proxy: { '/api': API_PROXY }, + }, +}); diff --git a/packages/server/skel/fullstack/front/vitest.config.ts b/packages/server/skel/fullstack/front/vitest.config.ts new file mode 100644 index 00000000..abbfbac1 --- /dev/null +++ b/packages/server/skel/fullstack/front/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig, mergeConfig } from 'vitest/config'; + +import viteConfig from './vite.config.ts'; + +// Vitest 5 no longer accepts a `test` key in vite's defineConfig: the test +// setup lives in its own file and reuses the app config. +export default mergeConfig(viteConfig, defineConfig({ + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test/setup.ts'], + }, +})); diff --git a/packages/server/skel/fullstack/package.json b/packages/server/skel/fullstack/package.json new file mode 100644 index 00000000..42e0e8b3 --- /dev/null +++ b/packages/server/skel/fullstack/package.json @@ -0,0 +1,28 @@ +{ + "name": "{project.name}", + "private": true, + "version": "0.0.1", + "type": "module", + "engines": { + "node": ">=24" + }, + "scripts": { + "dev": "concurrently -n back,front -c blue,magenta \"pnpm --filter ./back start\" \"pnpm --filter ./front dev\"", + "build": "pnpm --filter ./back build && pnpm --filter ./front build", + "lint": "pnpm -r lint", + "typecheck": "pnpm -r typecheck", + "test": "pnpm -r test", + "test:e2e": "playwright test", + "migrate": "pnpm --filter ./back exec igo db migrate", + "prepare": "husky" + }, + "devDependencies": { + "@commitlint/cli": "^21.2.0", + "@commitlint/config-conventional": "^21.2.0", + "@playwright/test": "^1.63.0", + "concurrently": "^10.0.0", + "husky": "^9.1.7", + "lint-staged": "^17.5.0", + "oxlint": "^1.81.0" + } +} diff --git a/packages/server/skel/fullstack/playwright.config.ts b/packages/server/skel/fullstack/playwright.config.ts new file mode 100644 index 00000000..f83f048b --- /dev/null +++ b/packages/server/skel/fullstack/playwright.config.ts @@ -0,0 +1,34 @@ +import { defineConfig, devices } from '@playwright/test'; + +const PORT = Number(process.env.E2E_PORT ?? 4173); +const BASE_URL = process.env.E2E_BASE_URL ?? `http://127.0.0.1:${PORT}`; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? [['html'], ['github']] : 'list', + + use: { + baseURL: BASE_URL, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + ], + + // Serves the built front and proxies /api to the back, the way nginx does in + // production — so the tests exercise the real single-origin setup. + webServer: process.env.E2E_BASE_URL ? undefined : { + // --host binds 127.0.0.1 too: vite listens on localhost (IPv6) by default, + // which the url below would never reach. + command: `pnpm --filter ./front exec vite preview --port ${PORT} --strictPort --host 127.0.0.1`, + url: BASE_URL, + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, +}); diff --git a/packages/server/skel/fullstack/pnpm-workspace.yaml b/packages/server/skel/fullstack/pnpm-workspace.yaml new file mode 100644 index 00000000..b58be1b8 --- /dev/null +++ b/packages/server/skel/fullstack/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +packages: + - back + - front + +# pnpm blocks postinstall scripts unless a package is listed here. These +# compile or download a native binary, which they need to run at all. +allowBuilds: + esbuild: true + '@parcel/watcher': true + msw: true diff --git a/packages/server/test/CreateTest.js b/packages/server/test/CreateTest.js index 560afbcc..478a80a3 100644 --- a/packages/server/test/CreateTest.js +++ b/packages/server/test/CreateTest.js @@ -7,7 +7,7 @@ const path = require('path'); const create = require('@igojs/server/cli/create'); -const SKELETONS = ['tailwind', 'api', 'front']; +const SKELETONS = ['tailwind', 'api', 'front', 'fullstack']; describe('cli/create', function() { this.timeout(20000); From 70ecf2953798c9eb02a7bd57bd55aba2a611056d Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Mon, 7 Sep 2026 15:41:18 +0200 Subject: [PATCH 18/80] feat(server): formatage automatique par oxfmt dans les squelettes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le formatage manquait, et c'est ce qui produit les débats de style en revue. oxfmt plutôt que Prettier : même équipe et même parseur qu'oxlint, sortie vérifiée identique à Prettier sur notre propre code, et les clés de configuration sont celles de Prettier — basculer coûterait un renommage de paquet. Les scaffolds récents (Vite, Next) ne livrent aucun formateur ; NestJS livre Prettier. Le choix se joue donc sur la cohérence d'outillage, et oxlint recommande lui-même de sortir le formatage du linter au profit d'oxfmt. Appliqué au pre-commit : oxfmt réécrit les fichiers indexés, oxlint valide, et c'est la version formatée qui est commitée. La CI vérifie avec format:check. Conséquence assumée : l'alignement en colonnes du style igo disparaît des projets générés. La frontière est nette — le framework garde son style, les projets applicatifs suivent le formateur. Les sources des trois squelettes sont formatées, donc un projet généré passe format:check dès son premier commit. Co-Authored-By: Claude Opus 5 (1M context) --- .../server/skel/api/_.github/workflows/ci.yml | 1 + packages/server/skel/api/_.lintstagedrc.json | 2 +- packages/server/skel/api/_.mocharc.json | 8 +-- packages/server/skel/api/_.oxfmtrc.json | 5 ++ packages/server/skel/api/app.ts | 1 - .../api/app/api/books/books.controller.ts | 3 +- .../skel/api/app/api/books/books.dto.ts | 39 ++++++++------ .../skel/api/app/api/books/books.routes.ts | 9 ++-- packages/server/skel/api/app/config.ts | 4 +- packages/server/skel/api/app/models/Book.ts | 25 +++------ packages/server/skel/api/app/routes.ts | 1 - packages/server/skel/api/package.json | 15 +++--- .../server/skel/api/test/api/BooksTest.ts | 51 +++++++++++-------- packages/server/skel/front/.oxlintrc.json | 15 +----- .../skel/front/_.github/workflows/ci.yml | 1 + .../server/skel/front/_.lintstagedrc.json | 3 +- packages/server/skel/front/_.oxfmtrc.json | 5 ++ packages/server/skel/front/package.json | 13 +++-- .../skel/front/src/features/books/api.ts | 6 +-- .../books/components/books-list.test.tsx | 1 - .../features/books/components/books-list.tsx | 2 +- .../features/books/pages/books-page.test.tsx | 36 +++++++------ .../src/features/books/pages/books-page.tsx | 8 ++- .../books/sections/add-book-section.tsx | 10 ++-- .../skel/front/src/features/books/types.ts | 16 +++--- packages/server/skel/front/src/index.css | 2 +- .../server/skel/front/src/lib/api-client.ts | 24 +++++---- packages/server/skel/front/src/main.tsx | 2 +- .../server/skel/front/src/test/handlers.ts | 12 +++-- .../server/skel/front/src/test/render.tsx | 4 +- packages/server/skel/front/tsconfig.json | 21 ++------ packages/server/skel/front/vitest.config.ts | 17 ++++--- packages/server/skel/fullstack/CLAUDE.md | 8 +-- packages/server/skel/fullstack/README.md | 10 ++-- .../skel/fullstack/_.github/workflows/ci.yml | 1 + .../server/skel/fullstack/_.lintstagedrc.json | 3 +- packages/server/skel/fullstack/_.oxfmtrc.json | 5 ++ .../server/skel/fullstack/back/_.mocharc.json | 8 +-- packages/server/skel/fullstack/back/app.ts | 1 - .../back/app/api/books/books.controller.ts | 3 +- .../fullstack/back/app/api/books/books.dto.ts | 39 ++++++++------ .../back/app/api/books/books.routes.ts | 9 ++-- .../server/skel/fullstack/back/app/config.ts | 4 +- .../skel/fullstack/back/app/models/Book.ts | 25 +++------ .../server/skel/fullstack/back/app/routes.ts | 1 - .../server/skel/fullstack/back/package.json | 15 +++--- .../skel/fullstack/back/test/api/BooksTest.ts | 51 +++++++++++-------- .../server/skel/fullstack/e2e/books.spec.ts | 1 - .../skel/fullstack/front/.oxlintrc.json | 15 +----- .../server/skel/fullstack/front/package.json | 13 +++-- .../fullstack/front/src/features/books/api.ts | 6 +-- .../books/components/books-list.test.tsx | 1 - .../features/books/components/books-list.tsx | 2 +- .../features/books/pages/books-page.test.tsx | 36 +++++++------ .../src/features/books/pages/books-page.tsx | 8 ++- .../books/sections/add-book-section.tsx | 10 ++-- .../front/src/features/books/types.ts | 16 +++--- .../server/skel/fullstack/front/src/index.css | 2 +- .../fullstack/front/src/lib/api-client.ts | 24 +++++---- .../server/skel/fullstack/front/src/main.tsx | 2 +- .../skel/fullstack/front/src/test/handlers.ts | 12 +++-- .../skel/fullstack/front/src/test/render.tsx | 4 +- .../server/skel/fullstack/front/tsconfig.json | 21 ++------ .../skel/fullstack/front/vitest.config.ts | 17 ++++--- packages/server/skel/fullstack/package.json | 13 +++-- .../skel/fullstack/playwright.config.ts | 24 ++++----- 66 files changed, 396 insertions(+), 376 deletions(-) create mode 100644 packages/server/skel/api/_.oxfmtrc.json create mode 100644 packages/server/skel/front/_.oxfmtrc.json create mode 100644 packages/server/skel/fullstack/_.oxfmtrc.json diff --git a/packages/server/skel/api/_.github/workflows/ci.yml b/packages/server/skel/api/_.github/workflows/ci.yml index 06050ccc..e64c540e 100644 --- a/packages/server/skel/api/_.github/workflows/ci.yml +++ b/packages/server/skel/api/_.github/workflows/ci.yml @@ -25,6 +25,7 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm lint + - run: pnpm format:check - run: pnpm typecheck test: diff --git a/packages/server/skel/api/_.lintstagedrc.json b/packages/server/skel/api/_.lintstagedrc.json index 8302f925..5e71d739 100644 --- a/packages/server/skel/api/_.lintstagedrc.json +++ b/packages/server/skel/api/_.lintstagedrc.json @@ -1,3 +1,3 @@ { - "*.ts": "oxlint" + "*.ts": ["oxfmt", "oxlint"] } diff --git a/packages/server/skel/api/_.mocharc.json b/packages/server/skel/api/_.mocharc.json index f9317747..20bdfa84 100644 --- a/packages/server/skel/api/_.mocharc.json +++ b/packages/server/skel/api/_.mocharc.json @@ -1,11 +1,7 @@ { "recursive": true, - "extension": [ - "ts" - ], - "require": [ - "tsx" - ], + "extension": ["ts"], + "require": ["tsx"], "reporter": "dot", "exit": true, "timeout": 10000 diff --git a/packages/server/skel/api/_.oxfmtrc.json b/packages/server/skel/api/_.oxfmtrc.json new file mode 100644 index 00000000..63109c1d --- /dev/null +++ b/packages/server/skel/api/_.oxfmtrc.json @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "printWidth": 100, + "ignorePatterns": ["dist", "node_modules", "*.md", "pnpm-lock.yaml"] +} diff --git a/packages/server/skel/api/app.ts b/packages/server/skel/api/app.ts index 159e50f0..f9e11e89 100644 --- a/packages/server/skel/api/app.ts +++ b/packages/server/skel/api/app.ts @@ -1,4 +1,3 @@ - import { app } from '@igojs/server'; app.run(); diff --git a/packages/server/skel/api/app/api/books/books.controller.ts b/packages/server/skel/api/app/api/books/books.controller.ts index c2fe0e44..28c554f6 100644 --- a/packages/server/skel/api/app/api/books/books.controller.ts +++ b/packages/server/skel/api/app/api/books/books.controller.ts @@ -1,4 +1,3 @@ - import { sendProblem } from '@igojs/server'; import type { ApiHandler } from '@igojs/server'; @@ -18,7 +17,7 @@ export const index: ApiHandler<{ query: typeof dto.ListBooks }> = async (req, re const { rows, pagination } = await query.page(page, limit).list(); res.json({ books: rows.map(dto.serialize), - page: dto.serializePage(pagination), + page: dto.serializePage(pagination), }); }; index.query = dto.ListBooks; diff --git a/packages/server/skel/api/app/api/books/books.dto.ts b/packages/server/skel/api/app/api/books/books.dto.ts index efac875b..82a660f4 100644 --- a/packages/server/skel/api/app/api/books/books.dto.ts +++ b/packages/server/skel/api/app/api/books/books.dto.ts @@ -1,41 +1,48 @@ - import { z } from 'zod'; import type { BookRow } from '../../models/Book'; // Incoming: what the API accepts. Coercion and defaults are applied before the // controller runs, so req.body and req.query already hold the right types. export const CreateBook = z.object({ - title: z.string().min(1).max(255), - author: z.string().min(1).max(255), - pages: z.number().int().positive(), - published: z.boolean().default(false), + title: z.string().min(1).max(255), + author: z.string().min(1).max(255), + pages: z.number().int().positive(), + published: z.boolean().default(false), }); export const UpdateBook = CreateBook.partial(); export const ListBooks = z.object({ - page: z.coerce.number().int().min(1).default(1), - limit: z.coerce.number().int().min(1).max(100).default(25), + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(25), // z.coerce.boolean() would turn 'false' into true: URL flags need this form - published: z.enum(['true', 'false']).transform(v => v === 'true').optional(), + published: z + .enum(['true', 'false']) + .transform((v) => v === 'true') + .optional(), }); // Outgoing: the barrier between the ORM model and the API. Adding a column to // the model exposes nothing until it is named here. export const serialize = (book: BookRow) => ({ - id: book.id, - title: book.title, - author: book.author, - pages: book.pages, + id: book.id, + title: book.title, + author: book.author, + pages: book.pages, published: book.published, createdAt: book.created_at, }); // The ORM pagination also carries `links`, meant for rendering page numbers in // a template: an API client builds its own navigation. -export const serializePage = (pagination: { page: number; nb: number; nb_pages: number; count: number }) => ({ - page: pagination.page, +export const serializePage = (pagination: { + page: number; + nb: number; + nb_pages: number; + count: number; +}) => ({ + page: pagination.page, perPage: pagination.nb, - pages: pagination.nb_pages, - total: pagination.count, + pages: pagination.nb_pages, + total: pagination.count, }); diff --git a/packages/server/skel/api/app/api/books/books.routes.ts b/packages/server/skel/api/app/api/books/books.routes.ts index a6ff1f37..0e484a05 100644 --- a/packages/server/skel/api/app/api/books/books.routes.ts +++ b/packages/server/skel/api/app/api/books/books.routes.ts @@ -1,14 +1,13 @@ - import { express } from '@igojs/server'; import * as controller from './books.controller'; const router = express.Router(); -router.get('/', controller.index); -router.post('/', controller.create); -router.get('/:id', controller.show); -router.put('/:id', controller.update); +router.get('/', controller.index); +router.post('/', controller.create); +router.get('/:id', controller.show); +router.put('/:id', controller.update); router.delete('/:id', controller.destroy); export default router; diff --git a/packages/server/skel/api/app/config.ts b/packages/server/skel/api/app/config.ts index 30ba2f62..0221d7db 100644 --- a/packages/server/skel/api/app/config.ts +++ b/packages/server/skel/api/app/config.ts @@ -1,6 +1,6 @@ import type { Config } from '@igojs/server'; export const init = (config: Config) => { - config.cookieSecret = '{RANDOM_1}'; - config.cookieSession.keys = [ '{RANDOM_2}' ]; + config.cookieSecret = '{RANDOM_1}'; + config.cookieSession.keys = ['{RANDOM_2}']; }; diff --git a/packages/server/skel/api/app/models/Book.ts b/packages/server/skel/api/app/models/Book.ts index 047a0a82..e9358ab1 100644 --- a/packages/server/skel/api/app/models/Book.ts +++ b/packages/server/skel/api/app/models/Book.ts @@ -1,28 +1,19 @@ - const { Model } = require('@igojs/db'); const schema = { - table: 'books', - columns: [ - 'id', - 'title', - 'author', - 'pages', - { name: 'published', type: 'boolean' }, - 'created_at', - ], + table: 'books', + columns: ['id', 'title', 'author', 'pages', { name: 'published', type: 'boolean' }, 'created_at'], }; export interface BookRow { - id: number; - title: string; - author: string; - pages: number; - published: boolean; + id: number; + title: string; + author: string; + pages: number; + published: boolean; created_at: Date; } -class Book extends Model(schema) { -} +class Book extends Model(schema) {} export default Book; diff --git a/packages/server/skel/api/app/routes.ts b/packages/server/skel/api/app/routes.ts index 4af76e2a..ccde27eb 100644 --- a/packages/server/skel/api/app/routes.ts +++ b/packages/server/skel/api/app/routes.ts @@ -7,7 +7,6 @@ import books from './api/books/books.routes'; // export const init = (app: Express) => { - // mounted under config.api.prefix -> /api/books app.api('/books', books); diff --git a/packages/server/skel/api/package.json b/packages/server/skel/api/package.json index 30cf540a..46cb985a 100644 --- a/packages/server/skel/api/package.json +++ b/packages/server/skel/api/package.json @@ -2,6 +2,8 @@ "name": "{project.name}", "version": "0.0.1", "description": "", + "license": "ISC", + "author": "", "main": "dist/app.js", "scripts": { "build": "tsc && cp -R sql locales dist/", @@ -10,12 +12,9 @@ "lint": "oxlint", "test": "mocha", "typecheck": "tsc --noEmit", - "prepare": "husky" - }, - "author": "", - "license": "ISC", - "engines": { - "node": ">=24" + "prepare": "husky", + "format": "oxfmt", + "format:check": "oxfmt --check" }, "dependencies": { "@igojs/igo": "{igo.version}", @@ -29,8 +28,12 @@ "@types/node": "^24.0.0", "husky": "^9.1.7", "lint-staged": "^17.5.0", + "oxfmt": "^0.66.0", "oxlint": "^1.81.0", "tsx": "^4.20.0", "typescript": "^7.0.0" + }, + "engines": { + "node": ">=24" } } diff --git a/packages/server/skel/api/test/api/BooksTest.ts b/packages/server/skel/api/test/api/BooksTest.ts index eb2e5c88..01c8402a 100644 --- a/packages/server/skel/api/test/api/BooksTest.ts +++ b/packages/server/skel/api/test/api/BooksTest.ts @@ -7,14 +7,16 @@ dev.test(); const agent = dev.agent; -const createBook = (values = {}) => Book.create({ - title: 'Dune', author: 'Frank Herbert', pages: 412, ...values -}); - -describe('api/books', function() { - - describe('GET /api/books', function() { +const createBook = (values = {}) => + Book.create({ + title: 'Dune', + author: 'Frank Herbert', + pages: 412, + ...values, + }); +describe('api/books', function () { + describe('GET /api/books', function () { it('should list the books', async () => { await createBook(); @@ -30,20 +32,28 @@ describe('api/books', function() { const res = await agent.get('/api/books?page=0'); assert.strictEqual(res.statusCode, 400); - assert.deepStrictEqual(res.data.errors.map((e: { path: string }) => e.path), ['page']); + assert.deepStrictEqual( + res.data.errors.map((e: { path: string }) => e.path), + ['page'], + ); }); }); - describe('GET /api/books/:id', function() { - + describe('GET /api/books/:id', function () { it('should expose only the serialized fields', async () => { const book = await createBook(); const res = await agent.get(`/api/books/${book.id}`); assert.strictEqual(res.statusCode, 200); - assert.deepStrictEqual(Object.keys(res.data).toSorted(), - ['author', 'createdAt', 'id', 'pages', 'published', 'title']); + assert.deepStrictEqual(Object.keys(res.data).toSorted(), [ + 'author', + 'createdAt', + 'id', + 'pages', + 'published', + 'title', + ]); }); it('should answer 404 for an unknown id', async () => { @@ -54,11 +64,10 @@ describe('api/books', function() { }); }); - describe('POST /api/books', function() { - + describe('POST /api/books', function () { it('should create a book', async () => { const res = await agent.post('/api/books', { - body: { title: 'Dune', author: 'Frank Herbert', pages: 412 } + body: { title: 'Dune', author: 'Frank Herbert', pages: 412 }, }); assert.strictEqual(res.statusCode, 201); @@ -74,15 +83,15 @@ describe('api/books', function() { assert.strictEqual(res.statusCode, 400); assert.strictEqual(res.data.title, 'Validation failed'); - assert.deepStrictEqual( - res.data.errors.map((e: { path: string }) => e.path).toSorted(), - ['author', 'pages', 'title'] - ); + assert.deepStrictEqual(res.data.errors.map((e: { path: string }) => e.path).toSorted(), [ + 'author', + 'pages', + 'title', + ]); }); }); - describe('DELETE /api/books/:id', function() { - + describe('DELETE /api/books/:id', function () { it('should delete the book', async () => { const book = await createBook(); diff --git a/packages/server/skel/front/.oxlintrc.json b/packages/server/skel/front/.oxlintrc.json index 0adaeddc..c7dd1769 100644 --- a/packages/server/skel/front/.oxlintrc.json +++ b/packages/server/skel/front/.oxlintrc.json @@ -1,13 +1,5 @@ { - "plugins": [ - "typescript", - "unicorn", - "oxc", - "react", - "react-perf", - "jsx-a11y", - "import" - ], + "plugins": ["typescript", "unicorn", "oxc", "react", "react-perf", "jsx-a11y", "import"], "categories": { "correctness": "error", "suspicious": "warn" @@ -17,8 +9,5 @@ "react/react-in-jsx-scope": "off", "import/no-unassigned-import": "off" }, - "ignorePatterns": [ - "dist", - "node_modules" - ] + "ignorePatterns": ["dist", "node_modules"] } diff --git a/packages/server/skel/front/_.github/workflows/ci.yml b/packages/server/skel/front/_.github/workflows/ci.yml index ec0b2492..5c64c8d8 100644 --- a/packages/server/skel/front/_.github/workflows/ci.yml +++ b/packages/server/skel/front/_.github/workflows/ci.yml @@ -27,6 +27,7 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm lint + - run: pnpm format:check - run: pnpm typecheck - run: pnpm test diff --git a/packages/server/skel/front/_.lintstagedrc.json b/packages/server/skel/front/_.lintstagedrc.json index 2cd3053b..4c2d70a7 100644 --- a/packages/server/skel/front/_.lintstagedrc.json +++ b/packages/server/skel/front/_.lintstagedrc.json @@ -1,3 +1,4 @@ { - "*.{ts,tsx}": "oxlint" + "*.{ts,tsx}": ["oxfmt", "oxlint"], + "*.{css,json}": "oxfmt" } diff --git a/packages/server/skel/front/_.oxfmtrc.json b/packages/server/skel/front/_.oxfmtrc.json new file mode 100644 index 00000000..63109c1d --- /dev/null +++ b/packages/server/skel/front/_.oxfmtrc.json @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "printWidth": 100, + "ignorePatterns": ["dist", "node_modules", "*.md", "pnpm-lock.yaml"] +} diff --git a/packages/server/skel/front/package.json b/packages/server/skel/front/package.json index cb4910ce..c9bd3a86 100644 --- a/packages/server/skel/front/package.json +++ b/packages/server/skel/front/package.json @@ -1,11 +1,8 @@ { "name": "{project.name}-front", - "private": true, "version": "0.0.1", + "private": true, "type": "module", - "engines": { - "node": ">=24" - }, "scripts": { "dev": "vite", "build": "tsc -b && vite build", @@ -14,7 +11,9 @@ "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit", - "prepare": "husky" + "prepare": "husky", + "format": "oxfmt", + "format:check": "oxfmt --check" }, "dependencies": { "@tanstack/react-query": "^5.102.0", @@ -37,10 +36,14 @@ "jsdom": "^30.0.0", "lint-staged": "^17.5.0", "msw": "^2.12.0", + "oxfmt": "^0.66.0", "oxlint": "^1.81.0", "tailwindcss": "^4.3.0", "typescript": "^7.0.0", "vite": "^8.2.0", "vitest": "^5.0.0" + }, + "engines": { + "node": ">=24" } } diff --git a/packages/server/skel/front/src/features/books/api.ts b/packages/server/skel/front/src/features/books/api.ts index 8abdbd0b..b14c8b1f 100644 --- a/packages/server/skel/front/src/features/books/api.ts +++ b/packages/server/skel/front/src/features/books/api.ts @@ -5,14 +5,14 @@ import { apiClient } from '@/lib/api-client'; import type { Book, BooksPage, CreateBook } from './types'; const keys = { - all: ['books'] as const, + all: ['books'] as const, list: (page: number) => ['books', { page }] as const, }; export function useBooks(page = 1) { return useQuery({ queryKey: keys.list(page), - queryFn: () => apiClient.get(`/api/books?page=${page}`), + queryFn: () => apiClient.get(`/api/books?page=${page}`), }); } @@ -21,6 +21,6 @@ export function useCreateBook() { return useMutation({ mutationFn: (book: CreateBook) => apiClient.post('/api/books', book), - onSuccess: () => queryClient.invalidateQueries({ queryKey: keys.all }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: keys.all }), }); } diff --git a/packages/server/skel/front/src/features/books/components/books-list.test.tsx b/packages/server/skel/front/src/features/books/components/books-list.test.tsx index 91bb41a3..46b48553 100644 --- a/packages/server/skel/front/src/features/books/components/books-list.test.tsx +++ b/packages/server/skel/front/src/features/books/components/books-list.test.tsx @@ -7,7 +7,6 @@ import { BooksList } from './books-list'; // A pure component needs no providers: props in, markup out. describe('BooksList', () => { - it('should list every book', () => { render(); diff --git a/packages/server/skel/front/src/features/books/components/books-list.tsx b/packages/server/skel/front/src/features/books/components/books-list.tsx index f4428937..877d9ac1 100644 --- a/packages/server/skel/front/src/features/books/components/books-list.tsx +++ b/packages/server/skel/front/src/features/books/components/books-list.tsx @@ -9,7 +9,7 @@ export function BooksList({ books }: { books: Book[] }) { return (
    - {books.map(book => ( + {books.map((book) => (
  • {book.title} diff --git a/packages/server/skel/front/src/features/books/pages/books-page.test.tsx b/packages/server/skel/front/src/features/books/pages/books-page.test.tsx index a6a75787..26bd32a6 100644 --- a/packages/server/skel/front/src/features/books/pages/books-page.test.tsx +++ b/packages/server/skel/front/src/features/books/pages/books-page.test.tsx @@ -9,7 +9,6 @@ import { server } from '@/test/msw-server'; import { BooksPage } from './books-page'; describe('BooksPage', () => { - it('should show the books once loaded', async () => { renderWithProviders(); @@ -18,12 +17,14 @@ describe('BooksPage', () => { }); it('should report a server error instead of loading forever', async () => { - server.use(http.get('/api/books', () => - HttpResponse.json( - { type: 'about:blank', title: 'Internal Server Error', status: 500 }, - { status: 500 } - ) - )); + server.use( + http.get('/api/books', () => + HttpResponse.json( + { type: 'about:blank', title: 'Internal Server Error', status: 500 }, + { status: 500 }, + ), + ), + ); renderWithProviders(); @@ -31,14 +32,19 @@ describe('BooksPage', () => { }); it('should show validation errors under the fields the server named', async () => { - server.use(http.post('/api/books', () => - HttpResponse.json({ - type: 'urn:igo:validation-failed', - title: 'Validation failed', - status: 400, - errors: [{ path: 'title', code: 'too_small', message: 'Too small' }], - }, { status: 400 }) - )); + server.use( + http.post('/api/books', () => + HttpResponse.json( + { + type: 'urn:igo:validation-failed', + title: 'Validation failed', + status: 400, + errors: [{ path: 'title', code: 'too_small', message: 'Too small' }], + }, + { status: 400 }, + ), + ), + ); renderWithProviders(); await screen.findByText('Dune'); diff --git a/packages/server/skel/front/src/features/books/pages/books-page.tsx b/packages/server/skel/front/src/features/books/pages/books-page.tsx index 6c2bf0c5..e42d29c8 100644 --- a/packages/server/skel/front/src/features/books/pages/books-page.tsx +++ b/packages/server/skel/front/src/features/books/pages/books-page.tsx @@ -14,8 +14,12 @@ export function BooksPage() { {isPending &&

    Loading…

    } - {isError &&

    {error.message}

    } - {data && ( + {isError && ( +

    + {error.message} +

    + )} + {data && ( <>

    {data.page.total} in total

    diff --git a/packages/server/skel/front/src/features/books/sections/add-book-section.tsx b/packages/server/skel/front/src/features/books/sections/add-book-section.tsx index 4f170c22..2bc7bf8f 100644 --- a/packages/server/skel/front/src/features/books/sections/add-book-section.tsx +++ b/packages/server/skel/front/src/features/books/sections/add-book-section.tsx @@ -18,13 +18,13 @@ export function AddBookSection() { event.preventDefault(); createBook.mutate( { title: form.title, author: form.author, pages: Number(form.pages) }, - { onSuccess: () => setForm(EMPTY) } + { onSuccess: () => setForm(EMPTY) }, ); }; return (
    - {(['title', 'author', 'pages'] as const).map(field => ( + {(['title', 'author', 'pages'] as const).map((field) => (
    ))} diff --git a/packages/server/skel/front/src/features/books/types.ts b/packages/server/skel/front/src/features/books/types.ts index 8b83ee9d..3f6281f5 100644 --- a/packages/server/skel/front/src/features/books/types.ts +++ b/packages/server/skel/front/src/features/books/types.ts @@ -2,22 +2,22 @@ // so there is no contract to generate from — a mismatch shows up in the // feature tests, which run against the real payload shape. export interface Book { - id: number; - title: string; - author: string; - pages: number; + id: number; + title: string; + author: string; + pages: number; published: boolean; createdAt: string; } export interface BooksPage { books: Book[]; - page: { page: number; perPage: number; pages: number; total: number }; + page: { page: number; perPage: number; pages: number; total: number }; } export interface CreateBook { - title: string; - author: string; - pages: number; + title: string; + author: string; + pages: number; published?: boolean; } diff --git a/packages/server/skel/front/src/index.css b/packages/server/skel/front/src/index.css index f1d8c73c..d4b50785 100644 --- a/packages/server/skel/front/src/index.css +++ b/packages/server/skel/front/src/index.css @@ -1 +1 @@ -@import "tailwindcss"; +@import 'tailwindcss'; diff --git a/packages/server/skel/front/src/lib/api-client.ts b/packages/server/skel/front/src/lib/api-client.ts index b1cac65a..5289bfea 100644 --- a/packages/server/skel/front/src/lib/api-client.ts +++ b/packages/server/skel/front/src/lib/api-client.ts @@ -1,8 +1,8 @@ // RFC 9457 problem document, as returned by igo on every API error. export interface Problem { - type: string; - title: string; - status: number; + type: string; + title: string; + status: number; detail?: string; errors?: { path: string; code?: string; message: string }[]; } @@ -12,13 +12,13 @@ export class ApiError extends Error { constructor(problem: Problem) { super(problem.detail || problem.title); - this.name = 'ApiError'; + this.name = 'ApiError'; this.problem = problem; } /** Message for one field, to sit under the input that caused it. */ fieldError(path: string): string | undefined { - return this.problem.errors?.find(e => e.path === path)?.message; + return this.problem.errors?.find((e) => e.path === path)?.message; } } @@ -28,12 +28,14 @@ const request = async (method: string, path: string, body?: unknown): Promise const response = await fetch(path, { method, headers: body ? { 'Content-Type': 'application/json' } : undefined, - body: body ? JSON.stringify(body) : undefined, + body: body ? JSON.stringify(body) : undefined, }); if (!response.ok) { const problem = await response.json().catch(() => ({ - type: 'about:blank', title: response.statusText, status: response.status, + type: 'about:blank', + title: response.statusText, + status: response.status, })); throw new ApiError(problem as Problem); } @@ -42,8 +44,8 @@ const request = async (method: string, path: string, body?: unknown): Promise }; export const apiClient = { - get: (path: string) => request('GET', path), - post: (path: string, body: unknown) => request('POST', path, body), - put: (path: string, body: unknown) => request('PUT', path, body), - delete: (path: string) => request('DELETE', path), + get: (path: string) => request('GET', path), + post: (path: string, body: unknown) => request('POST', path, body), + put: (path: string, body: unknown) => request('PUT', path, body), + delete: (path: string) => request('DELETE', path), }; diff --git a/packages/server/skel/front/src/main.tsx b/packages/server/skel/front/src/main.tsx index a017a72e..9a89edca 100644 --- a/packages/server/skel/front/src/main.tsx +++ b/packages/server/skel/front/src/main.tsx @@ -13,5 +13,5 @@ createRoot(document.getElementById('root')!).render( - + , ); diff --git a/packages/server/skel/front/src/test/handlers.ts b/packages/server/skel/front/src/test/handlers.ts index 5defd591..b9d25c23 100644 --- a/packages/server/skel/front/src/test/handlers.ts +++ b/packages/server/skel/front/src/test/handlers.ts @@ -3,8 +3,12 @@ import { http, HttpResponse } from 'msw'; import type { Book } from '@/features/books/types'; export const aBook = (overrides: Partial = {}): Book => ({ - id: 1, title: 'Dune', author: 'Frank Herbert', pages: 412, - published: true, createdAt: '2026-01-01T00:00:00.000Z', + id: 1, + title: 'Dune', + author: 'Frank Herbert', + pages: 412, + published: true, + createdAt: '2026-01-01T00:00:00.000Z', ...overrides, }); @@ -14,8 +18,8 @@ export const handlers = [ http.get('/api/books', () => HttpResponse.json({ books: [aBook()], - page: { page: 1, perPage: 25, pages: 1, total: 1 }, - }) + page: { page: 1, perPage: 25, pages: 1, total: 1 }, + }), ), http.post('/api/books', async ({ request }) => { diff --git a/packages/server/skel/front/src/test/render.tsx b/packages/server/skel/front/src/test/render.tsx index 0a2d8370..430113a9 100644 --- a/packages/server/skel/front/src/test/render.tsx +++ b/packages/server/skel/front/src/test/render.tsx @@ -9,7 +9,5 @@ export function renderWithProviders(ui: ReactElement) { defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); - return render( - {ui} - ); + return render({ui}); } diff --git a/packages/server/skel/front/tsconfig.json b/packages/server/skel/front/tsconfig.json index 341289fc..46739c4f 100644 --- a/packages/server/skel/front/tsconfig.json +++ b/packages/server/skel/front/tsconfig.json @@ -1,11 +1,7 @@ { "compilerOptions": { "target": "ES2023", - "lib": [ - "ES2023", - "DOM", - "DOM.Iterable" - ], + "lib": ["ES2023", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "bundler", "jsx": "react-jsx", @@ -16,14 +12,9 @@ "isolatedModules": true, "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, - "types": [ - "vitest/globals", - "@testing-library/jest-dom" - ], + "types": ["vitest/globals", "@testing-library/jest-dom"], "paths": { - "@/*": [ - "./src/*" - ] + "@/*": ["./src/*"] }, "moduleDetection": "force", "noUnusedLocals": true, @@ -31,9 +22,5 @@ "noFallthroughCasesInSwitch": true, "erasableSyntaxOnly": true }, - "include": [ - "src", - "vite.config.ts", - "vitest.config.ts" - ] + "include": ["src", "vite.config.ts", "vitest.config.ts"] } diff --git a/packages/server/skel/front/vitest.config.ts b/packages/server/skel/front/vitest.config.ts index abbfbac1..5e4a1f8e 100644 --- a/packages/server/skel/front/vitest.config.ts +++ b/packages/server/skel/front/vitest.config.ts @@ -4,10 +4,13 @@ import viteConfig from './vite.config.ts'; // Vitest 5 no longer accepts a `test` key in vite's defineConfig: the test // setup lives in its own file and reuses the app config. -export default mergeConfig(viteConfig, defineConfig({ - test: { - environment: 'jsdom', - globals: true, - setupFiles: ['./src/test/setup.ts'], - }, -})); +export default mergeConfig( + viteConfig, + defineConfig({ + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test/setup.ts'], + }, + }), +); diff --git a/packages/server/skel/fullstack/CLAUDE.md b/packages/server/skel/fullstack/CLAUDE.md index 14ce029b..2cc3285d 100644 --- a/packages/server/skel/fullstack/CLAUDE.md +++ b/packages/server/skel/fullstack/CLAUDE.md @@ -49,11 +49,11 @@ Le back sert d'abord — le front consomme un contrat qui existe. ## Les tests -| Niveau | Où | Quand | -|---|---|---| -| Intégration back | `back/test/` | tout contrôleur API | +| Niveau | Où | Quand | +| ------------------ | ------------------------- | ----------------------------- | +| Intégration back | `back/test/` | tout contrôleur API | | Composant, feature | `front/src/**/*.test.tsx` | tout composant, toute section | -| E2E | `e2e/` | chemins critiques seulement | +| E2E | `e2e/` | chemins critiques seulement | Les E2E tournent contre le **build**, pas le serveur de développement. Ils sont lents : tout ce qui peut être couvert plus bas doit l'être plus bas. diff --git a/packages/server/skel/fullstack/README.md b/packages/server/skel/fullstack/README.md index 6d844db5..ce202c3c 100644 --- a/packages/server/skel/fullstack/README.md +++ b/packages/server/skel/fullstack/README.md @@ -42,11 +42,11 @@ cohérents**, et le déploiement livre un seul artefact. ## Les tests -| Niveau | Où | Ce qu'il couvre | -|---|---|---| -| Intégration back | `back/test/` | route → contrôleur → DTO → base | -| Composant, feature | `front/src/**/*.test.tsx` | rendu, API simulée par MSW | -| E2E | `e2e/` | le câblage complet, navigateur réel | +| Niveau | Où | Ce qu'il couvre | +| ------------------ | ------------------------- | ----------------------------------- | +| Intégration back | `back/test/` | route → contrôleur → DTO → base | +| Composant, feature | `front/src/**/*.test.tsx` | rendu, API simulée par MSW | +| E2E | `e2e/` | le câblage complet, navigateur réel | Les E2E tournent contre le **build** du front, pas le serveur de développement — c'est ce qui est déployé. Ils restent peu nombreux : tout ce qui peut être diff --git a/packages/server/skel/fullstack/_.github/workflows/ci.yml b/packages/server/skel/fullstack/_.github/workflows/ci.yml index 33b519a0..9187bf12 100644 --- a/packages/server/skel/fullstack/_.github/workflows/ci.yml +++ b/packages/server/skel/fullstack/_.github/workflows/ci.yml @@ -25,6 +25,7 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm lint + - run: pnpm format:check - run: pnpm typecheck test: diff --git a/packages/server/skel/fullstack/_.lintstagedrc.json b/packages/server/skel/fullstack/_.lintstagedrc.json index 2cd3053b..4c2d70a7 100644 --- a/packages/server/skel/fullstack/_.lintstagedrc.json +++ b/packages/server/skel/fullstack/_.lintstagedrc.json @@ -1,3 +1,4 @@ { - "*.{ts,tsx}": "oxlint" + "*.{ts,tsx}": ["oxfmt", "oxlint"], + "*.{css,json}": "oxfmt" } diff --git a/packages/server/skel/fullstack/_.oxfmtrc.json b/packages/server/skel/fullstack/_.oxfmtrc.json new file mode 100644 index 00000000..63109c1d --- /dev/null +++ b/packages/server/skel/fullstack/_.oxfmtrc.json @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "printWidth": 100, + "ignorePatterns": ["dist", "node_modules", "*.md", "pnpm-lock.yaml"] +} diff --git a/packages/server/skel/fullstack/back/_.mocharc.json b/packages/server/skel/fullstack/back/_.mocharc.json index f9317747..20bdfa84 100644 --- a/packages/server/skel/fullstack/back/_.mocharc.json +++ b/packages/server/skel/fullstack/back/_.mocharc.json @@ -1,11 +1,7 @@ { "recursive": true, - "extension": [ - "ts" - ], - "require": [ - "tsx" - ], + "extension": ["ts"], + "require": ["tsx"], "reporter": "dot", "exit": true, "timeout": 10000 diff --git a/packages/server/skel/fullstack/back/app.ts b/packages/server/skel/fullstack/back/app.ts index 159e50f0..f9e11e89 100644 --- a/packages/server/skel/fullstack/back/app.ts +++ b/packages/server/skel/fullstack/back/app.ts @@ -1,4 +1,3 @@ - import { app } from '@igojs/server'; app.run(); diff --git a/packages/server/skel/fullstack/back/app/api/books/books.controller.ts b/packages/server/skel/fullstack/back/app/api/books/books.controller.ts index c2fe0e44..28c554f6 100644 --- a/packages/server/skel/fullstack/back/app/api/books/books.controller.ts +++ b/packages/server/skel/fullstack/back/app/api/books/books.controller.ts @@ -1,4 +1,3 @@ - import { sendProblem } from '@igojs/server'; import type { ApiHandler } from '@igojs/server'; @@ -18,7 +17,7 @@ export const index: ApiHandler<{ query: typeof dto.ListBooks }> = async (req, re const { rows, pagination } = await query.page(page, limit).list(); res.json({ books: rows.map(dto.serialize), - page: dto.serializePage(pagination), + page: dto.serializePage(pagination), }); }; index.query = dto.ListBooks; diff --git a/packages/server/skel/fullstack/back/app/api/books/books.dto.ts b/packages/server/skel/fullstack/back/app/api/books/books.dto.ts index efac875b..82a660f4 100644 --- a/packages/server/skel/fullstack/back/app/api/books/books.dto.ts +++ b/packages/server/skel/fullstack/back/app/api/books/books.dto.ts @@ -1,41 +1,48 @@ - import { z } from 'zod'; import type { BookRow } from '../../models/Book'; // Incoming: what the API accepts. Coercion and defaults are applied before the // controller runs, so req.body and req.query already hold the right types. export const CreateBook = z.object({ - title: z.string().min(1).max(255), - author: z.string().min(1).max(255), - pages: z.number().int().positive(), - published: z.boolean().default(false), + title: z.string().min(1).max(255), + author: z.string().min(1).max(255), + pages: z.number().int().positive(), + published: z.boolean().default(false), }); export const UpdateBook = CreateBook.partial(); export const ListBooks = z.object({ - page: z.coerce.number().int().min(1).default(1), - limit: z.coerce.number().int().min(1).max(100).default(25), + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(25), // z.coerce.boolean() would turn 'false' into true: URL flags need this form - published: z.enum(['true', 'false']).transform(v => v === 'true').optional(), + published: z + .enum(['true', 'false']) + .transform((v) => v === 'true') + .optional(), }); // Outgoing: the barrier between the ORM model and the API. Adding a column to // the model exposes nothing until it is named here. export const serialize = (book: BookRow) => ({ - id: book.id, - title: book.title, - author: book.author, - pages: book.pages, + id: book.id, + title: book.title, + author: book.author, + pages: book.pages, published: book.published, createdAt: book.created_at, }); // The ORM pagination also carries `links`, meant for rendering page numbers in // a template: an API client builds its own navigation. -export const serializePage = (pagination: { page: number; nb: number; nb_pages: number; count: number }) => ({ - page: pagination.page, +export const serializePage = (pagination: { + page: number; + nb: number; + nb_pages: number; + count: number; +}) => ({ + page: pagination.page, perPage: pagination.nb, - pages: pagination.nb_pages, - total: pagination.count, + pages: pagination.nb_pages, + total: pagination.count, }); diff --git a/packages/server/skel/fullstack/back/app/api/books/books.routes.ts b/packages/server/skel/fullstack/back/app/api/books/books.routes.ts index a6ff1f37..0e484a05 100644 --- a/packages/server/skel/fullstack/back/app/api/books/books.routes.ts +++ b/packages/server/skel/fullstack/back/app/api/books/books.routes.ts @@ -1,14 +1,13 @@ - import { express } from '@igojs/server'; import * as controller from './books.controller'; const router = express.Router(); -router.get('/', controller.index); -router.post('/', controller.create); -router.get('/:id', controller.show); -router.put('/:id', controller.update); +router.get('/', controller.index); +router.post('/', controller.create); +router.get('/:id', controller.show); +router.put('/:id', controller.update); router.delete('/:id', controller.destroy); export default router; diff --git a/packages/server/skel/fullstack/back/app/config.ts b/packages/server/skel/fullstack/back/app/config.ts index 30ba2f62..0221d7db 100644 --- a/packages/server/skel/fullstack/back/app/config.ts +++ b/packages/server/skel/fullstack/back/app/config.ts @@ -1,6 +1,6 @@ import type { Config } from '@igojs/server'; export const init = (config: Config) => { - config.cookieSecret = '{RANDOM_1}'; - config.cookieSession.keys = [ '{RANDOM_2}' ]; + config.cookieSecret = '{RANDOM_1}'; + config.cookieSession.keys = ['{RANDOM_2}']; }; diff --git a/packages/server/skel/fullstack/back/app/models/Book.ts b/packages/server/skel/fullstack/back/app/models/Book.ts index 047a0a82..e9358ab1 100644 --- a/packages/server/skel/fullstack/back/app/models/Book.ts +++ b/packages/server/skel/fullstack/back/app/models/Book.ts @@ -1,28 +1,19 @@ - const { Model } = require('@igojs/db'); const schema = { - table: 'books', - columns: [ - 'id', - 'title', - 'author', - 'pages', - { name: 'published', type: 'boolean' }, - 'created_at', - ], + table: 'books', + columns: ['id', 'title', 'author', 'pages', { name: 'published', type: 'boolean' }, 'created_at'], }; export interface BookRow { - id: number; - title: string; - author: string; - pages: number; - published: boolean; + id: number; + title: string; + author: string; + pages: number; + published: boolean; created_at: Date; } -class Book extends Model(schema) { -} +class Book extends Model(schema) {} export default Book; diff --git a/packages/server/skel/fullstack/back/app/routes.ts b/packages/server/skel/fullstack/back/app/routes.ts index 4af76e2a..ccde27eb 100644 --- a/packages/server/skel/fullstack/back/app/routes.ts +++ b/packages/server/skel/fullstack/back/app/routes.ts @@ -7,7 +7,6 @@ import books from './api/books/books.routes'; // export const init = (app: Express) => { - // mounted under config.api.prefix -> /api/books app.api('/books', books); diff --git a/packages/server/skel/fullstack/back/package.json b/packages/server/skel/fullstack/back/package.json index f7b96e2f..a2f8bcff 100644 --- a/packages/server/skel/fullstack/back/package.json +++ b/packages/server/skel/fullstack/back/package.json @@ -2,6 +2,8 @@ "name": "{project.name}-back", "version": "0.0.1", "description": "", + "license": "ISC", + "author": "", "main": "dist/app.js", "scripts": { "build": "tsc && cp -R sql locales dist/", @@ -10,12 +12,9 @@ "lint": "oxlint", "test": "mocha", "typecheck": "tsc --noEmit", - "prepare": "husky" - }, - "author": "", - "license": "ISC", - "engines": { - "node": ">=24" + "prepare": "husky", + "format": "oxfmt", + "format:check": "oxfmt --check" }, "dependencies": { "@igojs/igo": "{igo.version}", @@ -29,8 +28,12 @@ "@types/node": "^24.0.0", "husky": "^9.1.7", "lint-staged": "^17.5.0", + "oxfmt": "^0.66.0", "oxlint": "^1.81.0", "tsx": "^4.20.0", "typescript": "^7.0.0" + }, + "engines": { + "node": ">=24" } } diff --git a/packages/server/skel/fullstack/back/test/api/BooksTest.ts b/packages/server/skel/fullstack/back/test/api/BooksTest.ts index eb2e5c88..01c8402a 100644 --- a/packages/server/skel/fullstack/back/test/api/BooksTest.ts +++ b/packages/server/skel/fullstack/back/test/api/BooksTest.ts @@ -7,14 +7,16 @@ dev.test(); const agent = dev.agent; -const createBook = (values = {}) => Book.create({ - title: 'Dune', author: 'Frank Herbert', pages: 412, ...values -}); - -describe('api/books', function() { - - describe('GET /api/books', function() { +const createBook = (values = {}) => + Book.create({ + title: 'Dune', + author: 'Frank Herbert', + pages: 412, + ...values, + }); +describe('api/books', function () { + describe('GET /api/books', function () { it('should list the books', async () => { await createBook(); @@ -30,20 +32,28 @@ describe('api/books', function() { const res = await agent.get('/api/books?page=0'); assert.strictEqual(res.statusCode, 400); - assert.deepStrictEqual(res.data.errors.map((e: { path: string }) => e.path), ['page']); + assert.deepStrictEqual( + res.data.errors.map((e: { path: string }) => e.path), + ['page'], + ); }); }); - describe('GET /api/books/:id', function() { - + describe('GET /api/books/:id', function () { it('should expose only the serialized fields', async () => { const book = await createBook(); const res = await agent.get(`/api/books/${book.id}`); assert.strictEqual(res.statusCode, 200); - assert.deepStrictEqual(Object.keys(res.data).toSorted(), - ['author', 'createdAt', 'id', 'pages', 'published', 'title']); + assert.deepStrictEqual(Object.keys(res.data).toSorted(), [ + 'author', + 'createdAt', + 'id', + 'pages', + 'published', + 'title', + ]); }); it('should answer 404 for an unknown id', async () => { @@ -54,11 +64,10 @@ describe('api/books', function() { }); }); - describe('POST /api/books', function() { - + describe('POST /api/books', function () { it('should create a book', async () => { const res = await agent.post('/api/books', { - body: { title: 'Dune', author: 'Frank Herbert', pages: 412 } + body: { title: 'Dune', author: 'Frank Herbert', pages: 412 }, }); assert.strictEqual(res.statusCode, 201); @@ -74,15 +83,15 @@ describe('api/books', function() { assert.strictEqual(res.statusCode, 400); assert.strictEqual(res.data.title, 'Validation failed'); - assert.deepStrictEqual( - res.data.errors.map((e: { path: string }) => e.path).toSorted(), - ['author', 'pages', 'title'] - ); + assert.deepStrictEqual(res.data.errors.map((e: { path: string }) => e.path).toSorted(), [ + 'author', + 'pages', + 'title', + ]); }); }); - describe('DELETE /api/books/:id', function() { - + describe('DELETE /api/books/:id', function () { it('should delete the book', async () => { const book = await createBook(); diff --git a/packages/server/skel/fullstack/e2e/books.spec.ts b/packages/server/skel/fullstack/e2e/books.spec.ts index a83efbf9..60f63475 100644 --- a/packages/server/skel/fullstack/e2e/books.spec.ts +++ b/packages/server/skel/fullstack/e2e/books.spec.ts @@ -4,7 +4,6 @@ import { expect, test } from '@playwright/test'; // Everything below that is already covered faster by the front and back tests, // so this file stays short on purpose. test.describe('books', () => { - test('should list the books served by the API', async ({ page }) => { await page.goto('/'); diff --git a/packages/server/skel/fullstack/front/.oxlintrc.json b/packages/server/skel/fullstack/front/.oxlintrc.json index 0adaeddc..c7dd1769 100644 --- a/packages/server/skel/fullstack/front/.oxlintrc.json +++ b/packages/server/skel/fullstack/front/.oxlintrc.json @@ -1,13 +1,5 @@ { - "plugins": [ - "typescript", - "unicorn", - "oxc", - "react", - "react-perf", - "jsx-a11y", - "import" - ], + "plugins": ["typescript", "unicorn", "oxc", "react", "react-perf", "jsx-a11y", "import"], "categories": { "correctness": "error", "suspicious": "warn" @@ -17,8 +9,5 @@ "react/react-in-jsx-scope": "off", "import/no-unassigned-import": "off" }, - "ignorePatterns": [ - "dist", - "node_modules" - ] + "ignorePatterns": ["dist", "node_modules"] } diff --git a/packages/server/skel/fullstack/front/package.json b/packages/server/skel/fullstack/front/package.json index cb4910ce..c9bd3a86 100644 --- a/packages/server/skel/fullstack/front/package.json +++ b/packages/server/skel/fullstack/front/package.json @@ -1,11 +1,8 @@ { "name": "{project.name}-front", - "private": true, "version": "0.0.1", + "private": true, "type": "module", - "engines": { - "node": ">=24" - }, "scripts": { "dev": "vite", "build": "tsc -b && vite build", @@ -14,7 +11,9 @@ "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit", - "prepare": "husky" + "prepare": "husky", + "format": "oxfmt", + "format:check": "oxfmt --check" }, "dependencies": { "@tanstack/react-query": "^5.102.0", @@ -37,10 +36,14 @@ "jsdom": "^30.0.0", "lint-staged": "^17.5.0", "msw": "^2.12.0", + "oxfmt": "^0.66.0", "oxlint": "^1.81.0", "tailwindcss": "^4.3.0", "typescript": "^7.0.0", "vite": "^8.2.0", "vitest": "^5.0.0" + }, + "engines": { + "node": ">=24" } } diff --git a/packages/server/skel/fullstack/front/src/features/books/api.ts b/packages/server/skel/fullstack/front/src/features/books/api.ts index 8abdbd0b..b14c8b1f 100644 --- a/packages/server/skel/fullstack/front/src/features/books/api.ts +++ b/packages/server/skel/fullstack/front/src/features/books/api.ts @@ -5,14 +5,14 @@ import { apiClient } from '@/lib/api-client'; import type { Book, BooksPage, CreateBook } from './types'; const keys = { - all: ['books'] as const, + all: ['books'] as const, list: (page: number) => ['books', { page }] as const, }; export function useBooks(page = 1) { return useQuery({ queryKey: keys.list(page), - queryFn: () => apiClient.get(`/api/books?page=${page}`), + queryFn: () => apiClient.get(`/api/books?page=${page}`), }); } @@ -21,6 +21,6 @@ export function useCreateBook() { return useMutation({ mutationFn: (book: CreateBook) => apiClient.post('/api/books', book), - onSuccess: () => queryClient.invalidateQueries({ queryKey: keys.all }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: keys.all }), }); } diff --git a/packages/server/skel/fullstack/front/src/features/books/components/books-list.test.tsx b/packages/server/skel/fullstack/front/src/features/books/components/books-list.test.tsx index 91bb41a3..46b48553 100644 --- a/packages/server/skel/fullstack/front/src/features/books/components/books-list.test.tsx +++ b/packages/server/skel/fullstack/front/src/features/books/components/books-list.test.tsx @@ -7,7 +7,6 @@ import { BooksList } from './books-list'; // A pure component needs no providers: props in, markup out. describe('BooksList', () => { - it('should list every book', () => { render(); diff --git a/packages/server/skel/fullstack/front/src/features/books/components/books-list.tsx b/packages/server/skel/fullstack/front/src/features/books/components/books-list.tsx index f4428937..877d9ac1 100644 --- a/packages/server/skel/fullstack/front/src/features/books/components/books-list.tsx +++ b/packages/server/skel/fullstack/front/src/features/books/components/books-list.tsx @@ -9,7 +9,7 @@ export function BooksList({ books }: { books: Book[] }) { return (
      - {books.map(book => ( + {books.map((book) => (
    • {book.title} diff --git a/packages/server/skel/fullstack/front/src/features/books/pages/books-page.test.tsx b/packages/server/skel/fullstack/front/src/features/books/pages/books-page.test.tsx index a6a75787..26bd32a6 100644 --- a/packages/server/skel/fullstack/front/src/features/books/pages/books-page.test.tsx +++ b/packages/server/skel/fullstack/front/src/features/books/pages/books-page.test.tsx @@ -9,7 +9,6 @@ import { server } from '@/test/msw-server'; import { BooksPage } from './books-page'; describe('BooksPage', () => { - it('should show the books once loaded', async () => { renderWithProviders(); @@ -18,12 +17,14 @@ describe('BooksPage', () => { }); it('should report a server error instead of loading forever', async () => { - server.use(http.get('/api/books', () => - HttpResponse.json( - { type: 'about:blank', title: 'Internal Server Error', status: 500 }, - { status: 500 } - ) - )); + server.use( + http.get('/api/books', () => + HttpResponse.json( + { type: 'about:blank', title: 'Internal Server Error', status: 500 }, + { status: 500 }, + ), + ), + ); renderWithProviders(); @@ -31,14 +32,19 @@ describe('BooksPage', () => { }); it('should show validation errors under the fields the server named', async () => { - server.use(http.post('/api/books', () => - HttpResponse.json({ - type: 'urn:igo:validation-failed', - title: 'Validation failed', - status: 400, - errors: [{ path: 'title', code: 'too_small', message: 'Too small' }], - }, { status: 400 }) - )); + server.use( + http.post('/api/books', () => + HttpResponse.json( + { + type: 'urn:igo:validation-failed', + title: 'Validation failed', + status: 400, + errors: [{ path: 'title', code: 'too_small', message: 'Too small' }], + }, + { status: 400 }, + ), + ), + ); renderWithProviders(); await screen.findByText('Dune'); diff --git a/packages/server/skel/fullstack/front/src/features/books/pages/books-page.tsx b/packages/server/skel/fullstack/front/src/features/books/pages/books-page.tsx index 6c2bf0c5..e42d29c8 100644 --- a/packages/server/skel/fullstack/front/src/features/books/pages/books-page.tsx +++ b/packages/server/skel/fullstack/front/src/features/books/pages/books-page.tsx @@ -14,8 +14,12 @@ export function BooksPage() { {isPending &&

      Loading…

      } - {isError &&

      {error.message}

      } - {data && ( + {isError && ( +

      + {error.message} +

      + )} + {data && ( <>

      {data.page.total} in total

      diff --git a/packages/server/skel/fullstack/front/src/features/books/sections/add-book-section.tsx b/packages/server/skel/fullstack/front/src/features/books/sections/add-book-section.tsx index 4f170c22..2bc7bf8f 100644 --- a/packages/server/skel/fullstack/front/src/features/books/sections/add-book-section.tsx +++ b/packages/server/skel/fullstack/front/src/features/books/sections/add-book-section.tsx @@ -18,13 +18,13 @@ export function AddBookSection() { event.preventDefault(); createBook.mutate( { title: form.title, author: form.author, pages: Number(form.pages) }, - { onSuccess: () => setForm(EMPTY) } + { onSuccess: () => setForm(EMPTY) }, ); }; return ( - {(['title', 'author', 'pages'] as const).map(field => ( + {(['title', 'author', 'pages'] as const).map((field) => (
      ))} diff --git a/packages/server/skel/fullstack/front/src/features/books/types.ts b/packages/server/skel/fullstack/front/src/features/books/types.ts index 8b83ee9d..3f6281f5 100644 --- a/packages/server/skel/fullstack/front/src/features/books/types.ts +++ b/packages/server/skel/fullstack/front/src/features/books/types.ts @@ -2,22 +2,22 @@ // so there is no contract to generate from — a mismatch shows up in the // feature tests, which run against the real payload shape. export interface Book { - id: number; - title: string; - author: string; - pages: number; + id: number; + title: string; + author: string; + pages: number; published: boolean; createdAt: string; } export interface BooksPage { books: Book[]; - page: { page: number; perPage: number; pages: number; total: number }; + page: { page: number; perPage: number; pages: number; total: number }; } export interface CreateBook { - title: string; - author: string; - pages: number; + title: string; + author: string; + pages: number; published?: boolean; } diff --git a/packages/server/skel/fullstack/front/src/index.css b/packages/server/skel/fullstack/front/src/index.css index f1d8c73c..d4b50785 100644 --- a/packages/server/skel/fullstack/front/src/index.css +++ b/packages/server/skel/fullstack/front/src/index.css @@ -1 +1 @@ -@import "tailwindcss"; +@import 'tailwindcss'; diff --git a/packages/server/skel/fullstack/front/src/lib/api-client.ts b/packages/server/skel/fullstack/front/src/lib/api-client.ts index b1cac65a..5289bfea 100644 --- a/packages/server/skel/fullstack/front/src/lib/api-client.ts +++ b/packages/server/skel/fullstack/front/src/lib/api-client.ts @@ -1,8 +1,8 @@ // RFC 9457 problem document, as returned by igo on every API error. export interface Problem { - type: string; - title: string; - status: number; + type: string; + title: string; + status: number; detail?: string; errors?: { path: string; code?: string; message: string }[]; } @@ -12,13 +12,13 @@ export class ApiError extends Error { constructor(problem: Problem) { super(problem.detail || problem.title); - this.name = 'ApiError'; + this.name = 'ApiError'; this.problem = problem; } /** Message for one field, to sit under the input that caused it. */ fieldError(path: string): string | undefined { - return this.problem.errors?.find(e => e.path === path)?.message; + return this.problem.errors?.find((e) => e.path === path)?.message; } } @@ -28,12 +28,14 @@ const request = async (method: string, path: string, body?: unknown): Promise const response = await fetch(path, { method, headers: body ? { 'Content-Type': 'application/json' } : undefined, - body: body ? JSON.stringify(body) : undefined, + body: body ? JSON.stringify(body) : undefined, }); if (!response.ok) { const problem = await response.json().catch(() => ({ - type: 'about:blank', title: response.statusText, status: response.status, + type: 'about:blank', + title: response.statusText, + status: response.status, })); throw new ApiError(problem as Problem); } @@ -42,8 +44,8 @@ const request = async (method: string, path: string, body?: unknown): Promise }; export const apiClient = { - get: (path: string) => request('GET', path), - post: (path: string, body: unknown) => request('POST', path, body), - put: (path: string, body: unknown) => request('PUT', path, body), - delete: (path: string) => request('DELETE', path), + get: (path: string) => request('GET', path), + post: (path: string, body: unknown) => request('POST', path, body), + put: (path: string, body: unknown) => request('PUT', path, body), + delete: (path: string) => request('DELETE', path), }; diff --git a/packages/server/skel/fullstack/front/src/main.tsx b/packages/server/skel/fullstack/front/src/main.tsx index a017a72e..9a89edca 100644 --- a/packages/server/skel/fullstack/front/src/main.tsx +++ b/packages/server/skel/fullstack/front/src/main.tsx @@ -13,5 +13,5 @@ createRoot(document.getElementById('root')!).render( - + , ); diff --git a/packages/server/skel/fullstack/front/src/test/handlers.ts b/packages/server/skel/fullstack/front/src/test/handlers.ts index 5defd591..b9d25c23 100644 --- a/packages/server/skel/fullstack/front/src/test/handlers.ts +++ b/packages/server/skel/fullstack/front/src/test/handlers.ts @@ -3,8 +3,12 @@ import { http, HttpResponse } from 'msw'; import type { Book } from '@/features/books/types'; export const aBook = (overrides: Partial = {}): Book => ({ - id: 1, title: 'Dune', author: 'Frank Herbert', pages: 412, - published: true, createdAt: '2026-01-01T00:00:00.000Z', + id: 1, + title: 'Dune', + author: 'Frank Herbert', + pages: 412, + published: true, + createdAt: '2026-01-01T00:00:00.000Z', ...overrides, }); @@ -14,8 +18,8 @@ export const handlers = [ http.get('/api/books', () => HttpResponse.json({ books: [aBook()], - page: { page: 1, perPage: 25, pages: 1, total: 1 }, - }) + page: { page: 1, perPage: 25, pages: 1, total: 1 }, + }), ), http.post('/api/books', async ({ request }) => { diff --git a/packages/server/skel/fullstack/front/src/test/render.tsx b/packages/server/skel/fullstack/front/src/test/render.tsx index 0a2d8370..430113a9 100644 --- a/packages/server/skel/fullstack/front/src/test/render.tsx +++ b/packages/server/skel/fullstack/front/src/test/render.tsx @@ -9,7 +9,5 @@ export function renderWithProviders(ui: ReactElement) { defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); - return render( - {ui} - ); + return render({ui}); } diff --git a/packages/server/skel/fullstack/front/tsconfig.json b/packages/server/skel/fullstack/front/tsconfig.json index 341289fc..46739c4f 100644 --- a/packages/server/skel/fullstack/front/tsconfig.json +++ b/packages/server/skel/fullstack/front/tsconfig.json @@ -1,11 +1,7 @@ { "compilerOptions": { "target": "ES2023", - "lib": [ - "ES2023", - "DOM", - "DOM.Iterable" - ], + "lib": ["ES2023", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "bundler", "jsx": "react-jsx", @@ -16,14 +12,9 @@ "isolatedModules": true, "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, - "types": [ - "vitest/globals", - "@testing-library/jest-dom" - ], + "types": ["vitest/globals", "@testing-library/jest-dom"], "paths": { - "@/*": [ - "./src/*" - ] + "@/*": ["./src/*"] }, "moduleDetection": "force", "noUnusedLocals": true, @@ -31,9 +22,5 @@ "noFallthroughCasesInSwitch": true, "erasableSyntaxOnly": true }, - "include": [ - "src", - "vite.config.ts", - "vitest.config.ts" - ] + "include": ["src", "vite.config.ts", "vitest.config.ts"] } diff --git a/packages/server/skel/fullstack/front/vitest.config.ts b/packages/server/skel/fullstack/front/vitest.config.ts index abbfbac1..5e4a1f8e 100644 --- a/packages/server/skel/fullstack/front/vitest.config.ts +++ b/packages/server/skel/fullstack/front/vitest.config.ts @@ -4,10 +4,13 @@ import viteConfig from './vite.config.ts'; // Vitest 5 no longer accepts a `test` key in vite's defineConfig: the test // setup lives in its own file and reuses the app config. -export default mergeConfig(viteConfig, defineConfig({ - test: { - environment: 'jsdom', - globals: true, - setupFiles: ['./src/test/setup.ts'], - }, -})); +export default mergeConfig( + viteConfig, + defineConfig({ + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test/setup.ts'], + }, + }), +); diff --git a/packages/server/skel/fullstack/package.json b/packages/server/skel/fullstack/package.json index 42e0e8b3..8aabba2a 100644 --- a/packages/server/skel/fullstack/package.json +++ b/packages/server/skel/fullstack/package.json @@ -1,11 +1,8 @@ { "name": "{project.name}", - "private": true, "version": "0.0.1", + "private": true, "type": "module", - "engines": { - "node": ">=24" - }, "scripts": { "dev": "concurrently -n back,front -c blue,magenta \"pnpm --filter ./back start\" \"pnpm --filter ./front dev\"", "build": "pnpm --filter ./back build && pnpm --filter ./front build", @@ -14,7 +11,9 @@ "test": "pnpm -r test", "test:e2e": "playwright test", "migrate": "pnpm --filter ./back exec igo db migrate", - "prepare": "husky" + "prepare": "husky", + "format": "oxfmt", + "format:check": "oxfmt --check" }, "devDependencies": { "@commitlint/cli": "^21.2.0", @@ -23,6 +22,10 @@ "concurrently": "^10.0.0", "husky": "^9.1.7", "lint-staged": "^17.5.0", + "oxfmt": "^0.66.0", "oxlint": "^1.81.0" + }, + "engines": { + "node": ">=24" } } diff --git a/packages/server/skel/fullstack/playwright.config.ts b/packages/server/skel/fullstack/playwright.config.ts index f83f048b..a088e7bd 100644 --- a/packages/server/skel/fullstack/playwright.config.ts +++ b/packages/server/skel/fullstack/playwright.config.ts @@ -1,6 +1,6 @@ import { defineConfig, devices } from '@playwright/test'; -const PORT = Number(process.env.E2E_PORT ?? 4173); +const PORT = Number(process.env.E2E_PORT ?? 4173); const BASE_URL = process.env.E2E_BASE_URL ?? `http://127.0.0.1:${PORT}`; export default defineConfig({ @@ -17,18 +17,18 @@ export default defineConfig({ screenshot: 'only-on-failure', }, - projects: [ - { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, - ], + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], // Serves the built front and proxies /api to the back, the way nginx does in // production — so the tests exercise the real single-origin setup. - webServer: process.env.E2E_BASE_URL ? undefined : { - // --host binds 127.0.0.1 too: vite listens on localhost (IPv6) by default, - // which the url below would never reach. - command: `pnpm --filter ./front exec vite preview --port ${PORT} --strictPort --host 127.0.0.1`, - url: BASE_URL, - reuseExistingServer: !process.env.CI, - timeout: 60_000, - }, + webServer: process.env.E2E_BASE_URL + ? undefined + : { + // --host binds 127.0.0.1 too: vite listens on localhost (IPv6) by default, + // which the url below would never reach. + command: `pnpm --filter ./front exec vite preview --port ${PORT} --strictPort --host 127.0.0.1`, + url: BASE_URL, + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, }); From fd462640b1b5dbbeb956a0221efb5e9f335da8b3 Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Mon, 7 Sep 2026 15:49:51 +0200 Subject: [PATCH 19/80] refactor(server)!: organiser les squelettes par feature, back devient api MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'ADR organisation des sources distingue deux trajectoires : app/api/ pour les refontes, app/features/ pour le greenfield, avec le modèle DANS la feature. Les squelettes servent des projets neufs et suivaient pourtant la structure de refonte — app/api/books/ à côté d'un app/models/ séparé. Un domaine est maintenant auto-contenu : routes, contrôleur, DTO et modèle côte à côte. Ce qui devient transversal migre dans shared/. Le dossier back/ du squelette fullstack devient api/ : il contient une API, et back ne se définissait que par opposition au front. Le nom aligne aussi le fullstack sur le squelette autonome. Vérifié à blanc sur les deux squelettes : format, lint, typecheck, 7 tests back, 6 tests front, build, et 3 tests E2E contre la vraie chaîne. Co-Authored-By: Claude Opus 5 (1M context) --- packages/server/skel/api/CLAUDE.md | 12 ++- packages/server/skel/api/README.md | 11 ++- .../app/{models => features/books}/Book.ts | 0 .../books/books.controller.ts | 2 +- .../app/features}/books/books.dto.ts | 2 +- .../{api => features}/books/books.routes.ts | 0 packages/server/skel/api/app/routes.ts | 2 +- .../test/features/books}/BooksTest.ts | 2 +- packages/server/skel/fullstack/CLAUDE.md | 12 +-- packages/server/skel/fullstack/README.md | 12 +-- .../skel/fullstack/_.github/workflows/ci.yml | 2 +- .../fullstack/{back => api}/.oxlintrc.json | 0 packages/server/skel/fullstack/api/CLAUDE.md | 93 +++++++++++++++++++ .../fullstack/{back => api}/_.env.example | 0 .../fullstack/{back => api}/_.mocharc.json | 0 .../skel/fullstack/{back => api}/app.ts | 0 .../fullstack/{back => api}/app/config.ts | 0 .../models => api/app/features/books}/Book.ts | 0 .../app/features}/books/books.controller.ts | 2 +- .../api/app/features}/books/books.dto.ts | 2 +- .../app/features}/books/books.routes.ts | 0 .../fullstack/{back => api}/app/routes.ts | 2 +- .../{back => api}/locales/en/translation.json | 0 .../skel/fullstack/{back => api}/package.json | 2 +- .../{back => api}/sql/20260101-books.sql | 0 .../api/test/features/books}/BooksTest.ts | 2 +- .../fullstack/{back => api}/tsconfig.json | 0 packages/server/skel/fullstack/package.json | 6 +- .../server/skel/fullstack/pnpm-workspace.yaml | 2 +- packages/server/test/CreateTest.js | 2 +- 30 files changed, 138 insertions(+), 32 deletions(-) rename packages/server/skel/api/app/{models => features/books}/Book.ts (100%) rename packages/server/skel/api/app/{api => features}/books/books.controller.ts (97%) rename packages/server/skel/{fullstack/back/app/api => api/app/features}/books/books.dto.ts (96%) rename packages/server/skel/api/app/{api => features}/books/books.routes.ts (100%) rename packages/server/skel/{fullstack/back/test/api => api/test/features/books}/BooksTest.ts (98%) rename packages/server/skel/fullstack/{back => api}/.oxlintrc.json (100%) create mode 100644 packages/server/skel/fullstack/api/CLAUDE.md rename packages/server/skel/fullstack/{back => api}/_.env.example (100%) rename packages/server/skel/fullstack/{back => api}/_.mocharc.json (100%) rename packages/server/skel/fullstack/{back => api}/app.ts (100%) rename packages/server/skel/fullstack/{back => api}/app/config.ts (100%) rename packages/server/skel/fullstack/{back/app/models => api/app/features/books}/Book.ts (100%) rename packages/server/skel/fullstack/{back/app/api => api/app/features}/books/books.controller.ts (97%) rename packages/server/skel/{api/app/api => fullstack/api/app/features}/books/books.dto.ts (96%) rename packages/server/skel/fullstack/{back/app/api => api/app/features}/books/books.routes.ts (100%) rename packages/server/skel/fullstack/{back => api}/app/routes.ts (87%) rename packages/server/skel/fullstack/{back => api}/locales/en/translation.json (100%) rename packages/server/skel/fullstack/{back => api}/package.json (96%) rename packages/server/skel/fullstack/{back => api}/sql/20260101-books.sql (100%) rename packages/server/skel/{api/test/api => fullstack/api/test/features/books}/BooksTest.ts (98%) rename packages/server/skel/fullstack/{back => api}/tsconfig.json (100%) diff --git a/packages/server/skel/api/CLAUDE.md b/packages/server/skel/api/CLAUDE.md index 66949805..0ed9f42f 100644 --- a/packages/server/skel/api/CLAUDE.md +++ b/packages/server/skel/api/CLAUDE.md @@ -19,17 +19,25 @@ migrée à chaque exécution. ``` app/ - api// un dossier par domaine exposé + features// un dossier par domaine, auto-contenu .routes.ts les endpoints .controller.ts thin : service ou modèle -> DTO .dto.ts schémas entrants + serialize sortant - models/ modèles ORM + .service.ts logique métier, dès qu'elle branche + .ts le modèle ORM du domaine + shared/ ce qui est transversal — models, services, utils config.ts surcharge de la config igo routes.ts montage sql/ migrations, une par fichier daté test/ miroir de app/ ``` +Une feature possède son modèle. Un modèle importé par la majorité des features +migre dans `shared/models/` — c'est la seule règle, et elle demande du jugement. + +Une feature peut importer chez une autre (`../dossiers/Dossier`) : l'organisation +porte la propriété, pas l'isolation. + ## Conventions **Les routes API se montent avec `app.api()`** — le préfixe `/api` vient de diff --git a/packages/server/skel/api/README.md b/packages/server/skel/api/README.md index c21eb121..a3f0ad3d 100644 --- a/packages/server/skel/api/README.md +++ b/packages/server/skel/api/README.md @@ -17,17 +17,22 @@ npm run serve # lance le build ``` app/ - api/ - books/ ← un dossier par domaine + features/ + books/ ← un dossier par domaine, auto-contenu books.routes.ts ← les endpoints books.controller.ts ← thin : service/modèle → DTO books.dto.ts ← schémas entrants + sérialisation sortante - models/ ← modèles ORM + Book.ts ← le modèle ORM du domaine + shared/ ← transversal : models, services, utils config.ts routes.ts ← montage des routes sql/ ← migrations ``` +Une feature regroupe tout son domaine, modèle compris. Ce qui devient +transversal — un modèle importé par la majorité des features — migre dans +`shared/`. + ## Conventions **Les routes API se montent avec `app.api()`** — le préfixe (`/api`) vient de diff --git a/packages/server/skel/api/app/models/Book.ts b/packages/server/skel/api/app/features/books/Book.ts similarity index 100% rename from packages/server/skel/api/app/models/Book.ts rename to packages/server/skel/api/app/features/books/Book.ts diff --git a/packages/server/skel/api/app/api/books/books.controller.ts b/packages/server/skel/api/app/features/books/books.controller.ts similarity index 97% rename from packages/server/skel/api/app/api/books/books.controller.ts rename to packages/server/skel/api/app/features/books/books.controller.ts index 28c554f6..b58a2a2b 100644 --- a/packages/server/skel/api/app/api/books/books.controller.ts +++ b/packages/server/skel/api/app/features/books/books.controller.ts @@ -1,7 +1,7 @@ import { sendProblem } from '@igojs/server'; import type { ApiHandler } from '@igojs/server'; -import Book from '../../models/Book'; +import Book from './Book'; import * as dto from './books.dto'; // The schemas below give req.body and req.query their types: no shape is diff --git a/packages/server/skel/fullstack/back/app/api/books/books.dto.ts b/packages/server/skel/api/app/features/books/books.dto.ts similarity index 96% rename from packages/server/skel/fullstack/back/app/api/books/books.dto.ts rename to packages/server/skel/api/app/features/books/books.dto.ts index 82a660f4..2f4210c9 100644 --- a/packages/server/skel/fullstack/back/app/api/books/books.dto.ts +++ b/packages/server/skel/api/app/features/books/books.dto.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import type { BookRow } from '../../models/Book'; +import type { BookRow } from './Book'; // Incoming: what the API accepts. Coercion and defaults are applied before the // controller runs, so req.body and req.query already hold the right types. diff --git a/packages/server/skel/api/app/api/books/books.routes.ts b/packages/server/skel/api/app/features/books/books.routes.ts similarity index 100% rename from packages/server/skel/api/app/api/books/books.routes.ts rename to packages/server/skel/api/app/features/books/books.routes.ts diff --git a/packages/server/skel/api/app/routes.ts b/packages/server/skel/api/app/routes.ts index ccde27eb..ae61cc09 100644 --- a/packages/server/skel/api/app/routes.ts +++ b/packages/server/skel/api/app/routes.ts @@ -3,7 +3,7 @@ import type { Express } from 'express'; -import books from './api/books/books.routes'; +import books from './features/books/books.routes'; // export const init = (app: Express) => { diff --git a/packages/server/skel/fullstack/back/test/api/BooksTest.ts b/packages/server/skel/api/test/features/books/BooksTest.ts similarity index 98% rename from packages/server/skel/fullstack/back/test/api/BooksTest.ts rename to packages/server/skel/api/test/features/books/BooksTest.ts index 01c8402a..0eb0f899 100644 --- a/packages/server/skel/fullstack/back/test/api/BooksTest.ts +++ b/packages/server/skel/api/test/features/books/BooksTest.ts @@ -1,7 +1,7 @@ import { dev } from '@igojs/server'; import assert from 'assert'; -import Book from '../../app/models/Book'; +import Book from '../../../app/features/books/Book'; dev.test(); diff --git a/packages/server/skel/fullstack/CLAUDE.md b/packages/server/skel/fullstack/CLAUDE.md index 2cc3285d..bfc3e36d 100644 --- a/packages/server/skel/fullstack/CLAUDE.md +++ b/packages/server/skel/fullstack/CLAUDE.md @@ -2,14 +2,14 @@ API JSON igo + SPA React dans un seul dépôt. TypeScript, Node 24, pnpm. -**Les conventions de code sont dans `back/CLAUDE.md` et `front/CLAUDE.md`.** Ce +**Les conventions de code sont dans `api/CLAUDE.md` et `front/CLAUDE.md`.** Ce fichier ne couvre que ce qui concerne les deux. ## Commandes ```bash -pnpm dev # back :3000 + front :5173 -pnpm build # back/dist + front/dist +pnpm dev # api :3000 + front :5173 +pnpm build # api/dist + front/dist pnpm lint # oxlint sur les deux pnpm typecheck pnpm test # tests back et front @@ -17,7 +17,7 @@ pnpm test:e2e # Playwright contre le build pnpm migrate ``` -Une commande ciblée passe par un filtre : `pnpm --filter ./back test`. +Une commande ciblée passe par un filtre : `pnpm --filter ./api test`. ## Le contrat front/back @@ -41,7 +41,7 @@ dérivent, ce sont les tests de feature qui le montrent. Les deux côtés se répondent : ``` -back/app/api// routes, controller, dto +api/app/api// routes, controller, dto front/src/features// api.ts, types.ts, pages, sections, components ``` @@ -51,7 +51,7 @@ Le back sert d'abord — le front consomme un contrat qui existe. | Niveau | Où | Quand | | ------------------ | ------------------------- | ----------------------------- | -| Intégration back | `back/test/` | tout contrôleur API | +| Intégration back | `api/test/` | tout contrôleur API | | Composant, feature | `front/src/**/*.test.tsx` | tout composant, toute section | | E2E | `e2e/` | chemins critiques seulement | diff --git a/packages/server/skel/fullstack/README.md b/packages/server/skel/fullstack/README.md index ce202c3c..67824758 100644 --- a/packages/server/skel/fullstack/README.md +++ b/packages/server/skel/fullstack/README.md @@ -8,7 +8,7 @@ dépôt. TypeScript, Node 24, pnpm. ```bash pnpm install pnpm migrate # crée les tables -pnpm dev # back sur :3000, front sur :5173 +pnpm dev # api sur :3000, front sur :5173 ``` Ouvrir http://localhost:5173. Le front proxifie `/api` vers le back : le @@ -21,7 +21,7 @@ MySQL et Redis doivent tourner en local. ```bash pnpm dev # les deux en parallèle -pnpm build # back -> back/dist, front -> front/dist +pnpm build # api/dist et front/dist pnpm lint # oxlint sur les deux pnpm typecheck # tsc sur les deux pnpm test # tests back et front @@ -32,7 +32,7 @@ pnpm migrate # migrations SQL ## Structure ``` -back/ API igo — voir back/CLAUDE.md +api/ API igo — voir api/CLAUDE.md front/ SPA React — voir front/CLAUDE.md e2e/ parcours Playwright ``` @@ -44,7 +44,7 @@ cohérents**, et le déploiement livre un seul artefact. | Niveau | Où | Ce qu'il couvre | | ------------------ | ------------------------- | ----------------------------------- | -| Intégration back | `back/test/` | route → contrôleur → DTO → base | +| Intégration back | `api/test/` | route → contrôleur → DTO → base | | Composant, feature | `front/src/**/*.test.tsx` | rendu, API simulée par MSW | | E2E | `e2e/` | le câblage complet, navigateur réel | @@ -54,7 +54,7 @@ couvert plus bas doit l'être. ## Déploiement -Un seul artefact. Le build produit `back/dist` et `front/dist`. +Un seul artefact. Le build produit `api/dist` et `front/dist`. **nginx sert les statiques**, pas igo — `front/dist` va dans le répertoire servi par nginx, et `/api` est passé au process Node. Deux réglages à ne pas @@ -72,4 +72,4 @@ Voir `deploy/nginx.conf.example`. [Conventional Commits](https://www.conventionalcommits.org), vérifiés par un hook. Le pre-commit passe oxlint sur les fichiers indexés. -Les conventions de code sont dans `back/CLAUDE.md` et `front/CLAUDE.md`. +Les conventions de code sont dans `api/CLAUDE.md` et `front/CLAUDE.md`. diff --git a/packages/server/skel/fullstack/_.github/workflows/ci.yml b/packages/server/skel/fullstack/_.github/workflows/ci.yml index 9187bf12..39c18c6a 100644 --- a/packages/server/skel/fullstack/_.github/workflows/ci.yml +++ b/packages/server/skel/fullstack/_.github/workflows/ci.yml @@ -126,7 +126,7 @@ jobs: # `serve` runs from dist/, where the compiled app/routes.js lives - name: Start the API - run: pnpm --filter ./back serve & + run: pnpm --filter ./api serve & env: NODE_ENV: production MYSQL_DATABASE: ${{ env.MYSQL_DATABASE }} diff --git a/packages/server/skel/fullstack/back/.oxlintrc.json b/packages/server/skel/fullstack/api/.oxlintrc.json similarity index 100% rename from packages/server/skel/fullstack/back/.oxlintrc.json rename to packages/server/skel/fullstack/api/.oxlintrc.json diff --git a/packages/server/skel/fullstack/api/CLAUDE.md b/packages/server/skel/fullstack/api/CLAUDE.md new file mode 100644 index 00000000..0ed9f42f --- /dev/null +++ b/packages/server/skel/fullstack/api/CLAUDE.md @@ -0,0 +1,93 @@ +# {project.name} + +API JSON sur [igo](https://github.com/igocreate/igo). TypeScript, Node 24, pnpm. + +## Commandes + +```bash +pnpm start # tsx watch +pnpm test # mocha — vraie base, isolée par transaction +pnpm lint # oxlint +pnpm typecheck # tsc --noEmit +pnpm build # -> dist/ +``` + +Les tests ont besoin de MySQL et Redis en local. La base de test est recréée et +migrée à chaque exécution. + +## Structure + +``` +app/ + features// un dossier par domaine, auto-contenu + .routes.ts les endpoints + .controller.ts thin : service ou modèle -> DTO + .dto.ts schémas entrants + serialize sortant + .service.ts logique métier, dès qu'elle branche + .ts le modèle ORM du domaine + shared/ ce qui est transversal — models, services, utils + config.ts surcharge de la config igo + routes.ts montage +sql/ migrations, une par fichier daté +test/ miroir de app/ +``` + +Une feature possède son modèle. Un modèle importé par la majorité des features +migre dans `shared/models/` — c'est la seule règle, et elle demande du jugement. + +Une feature peut importer chez une autre (`../dossiers/Dossier`) : l'organisation +porte la propriété, pas l'isolation. + +## Conventions + +**Les routes API se montent avec `app.api()`** — le préfixe `/api` vient de +`config.api.prefix`, jamais réécrit à la main. + +**La validation est portée par le schéma attaché au handler**, jamais par un +appel dans le contrôleur : + +```ts +export const create: ApiHandler<{ body: typeof dto.CreateBook }> = async (req, res) => { … }; +create.body = dto.CreateBook; +``` + +`req.body` et `req.query` arrivent validés et coercés. Pas de `parseInt`, pas de +garde manuelle sur un champ requis. + +**Le DTO est la barrière.** Un contrôleur ne renvoie jamais un modèle ORM : +`res.json(dto.serialize(book))`. Ajouter une colonne au modèle n'expose rien +tant qu'elle n'est pas nommée dans `serialize()`. + +**Les erreurs sont des documents RFC 9457**, via `sendProblem(res, status, …)`. +Un cas métier mérite son propre `type` (`/problems/out-of-stock`) — c'est ce que +le client teste, jamais le libellé. + +**La logique métier vit dans les services**, pas dans les contrôleurs, dès +qu'elle dépasse un appel au modèle. + +**Les logs portent des champs, pas des phrases** : `logger.info('book created', +{ book_id })` plutôt qu'une chaîne interpolée. L'identifiant de requête est +ajouté tout seul. + +## Tests + +Tout contrôleur API a un test d'intégration couvrant au minimum : le cas +nominal, la validation (400), l'entité absente (404), et l'accès refusé quand la +route est protégée. + +Les tests passent par `dev.agent` contre la vraie base. Les mocks ne servent que +pour les dépendances externes — API tierces, SMTP. + +## Commits + +[Conventional Commits](https://www.conventionalcommits.org), vérifié par un hook : +`feat:`, `fix:`, `chore:`, `refactor:`, `docs:`, `test:`, `ci:`. Le scope entre +parenthèses quand il aide — `fix(books): …`. + +Le hook de pre-commit passe oxlint sur les fichiers indexés. + +## Documentation + +- [Routes et API JSON](https://igocreate.github.io/igo/server/api) +- [ORM](https://igocreate.github.io/igo/db/models) +- [Logs](https://igocreate.github.io/igo/server/logging) diff --git a/packages/server/skel/fullstack/back/_.env.example b/packages/server/skel/fullstack/api/_.env.example similarity index 100% rename from packages/server/skel/fullstack/back/_.env.example rename to packages/server/skel/fullstack/api/_.env.example diff --git a/packages/server/skel/fullstack/back/_.mocharc.json b/packages/server/skel/fullstack/api/_.mocharc.json similarity index 100% rename from packages/server/skel/fullstack/back/_.mocharc.json rename to packages/server/skel/fullstack/api/_.mocharc.json diff --git a/packages/server/skel/fullstack/back/app.ts b/packages/server/skel/fullstack/api/app.ts similarity index 100% rename from packages/server/skel/fullstack/back/app.ts rename to packages/server/skel/fullstack/api/app.ts diff --git a/packages/server/skel/fullstack/back/app/config.ts b/packages/server/skel/fullstack/api/app/config.ts similarity index 100% rename from packages/server/skel/fullstack/back/app/config.ts rename to packages/server/skel/fullstack/api/app/config.ts diff --git a/packages/server/skel/fullstack/back/app/models/Book.ts b/packages/server/skel/fullstack/api/app/features/books/Book.ts similarity index 100% rename from packages/server/skel/fullstack/back/app/models/Book.ts rename to packages/server/skel/fullstack/api/app/features/books/Book.ts diff --git a/packages/server/skel/fullstack/back/app/api/books/books.controller.ts b/packages/server/skel/fullstack/api/app/features/books/books.controller.ts similarity index 97% rename from packages/server/skel/fullstack/back/app/api/books/books.controller.ts rename to packages/server/skel/fullstack/api/app/features/books/books.controller.ts index 28c554f6..b58a2a2b 100644 --- a/packages/server/skel/fullstack/back/app/api/books/books.controller.ts +++ b/packages/server/skel/fullstack/api/app/features/books/books.controller.ts @@ -1,7 +1,7 @@ import { sendProblem } from '@igojs/server'; import type { ApiHandler } from '@igojs/server'; -import Book from '../../models/Book'; +import Book from './Book'; import * as dto from './books.dto'; // The schemas below give req.body and req.query their types: no shape is diff --git a/packages/server/skel/api/app/api/books/books.dto.ts b/packages/server/skel/fullstack/api/app/features/books/books.dto.ts similarity index 96% rename from packages/server/skel/api/app/api/books/books.dto.ts rename to packages/server/skel/fullstack/api/app/features/books/books.dto.ts index 82a660f4..2f4210c9 100644 --- a/packages/server/skel/api/app/api/books/books.dto.ts +++ b/packages/server/skel/fullstack/api/app/features/books/books.dto.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import type { BookRow } from '../../models/Book'; +import type { BookRow } from './Book'; // Incoming: what the API accepts. Coercion and defaults are applied before the // controller runs, so req.body and req.query already hold the right types. diff --git a/packages/server/skel/fullstack/back/app/api/books/books.routes.ts b/packages/server/skel/fullstack/api/app/features/books/books.routes.ts similarity index 100% rename from packages/server/skel/fullstack/back/app/api/books/books.routes.ts rename to packages/server/skel/fullstack/api/app/features/books/books.routes.ts diff --git a/packages/server/skel/fullstack/back/app/routes.ts b/packages/server/skel/fullstack/api/app/routes.ts similarity index 87% rename from packages/server/skel/fullstack/back/app/routes.ts rename to packages/server/skel/fullstack/api/app/routes.ts index ccde27eb..ae61cc09 100644 --- a/packages/server/skel/fullstack/back/app/routes.ts +++ b/packages/server/skel/fullstack/api/app/routes.ts @@ -3,7 +3,7 @@ import type { Express } from 'express'; -import books from './api/books/books.routes'; +import books from './features/books/books.routes'; // export const init = (app: Express) => { diff --git a/packages/server/skel/fullstack/back/locales/en/translation.json b/packages/server/skel/fullstack/api/locales/en/translation.json similarity index 100% rename from packages/server/skel/fullstack/back/locales/en/translation.json rename to packages/server/skel/fullstack/api/locales/en/translation.json diff --git a/packages/server/skel/fullstack/back/package.json b/packages/server/skel/fullstack/api/package.json similarity index 96% rename from packages/server/skel/fullstack/back/package.json rename to packages/server/skel/fullstack/api/package.json index a2f8bcff..b8190b16 100644 --- a/packages/server/skel/fullstack/back/package.json +++ b/packages/server/skel/fullstack/api/package.json @@ -1,5 +1,5 @@ { - "name": "{project.name}-back", + "name": "{project.name}-api", "version": "0.0.1", "description": "", "license": "ISC", diff --git a/packages/server/skel/fullstack/back/sql/20260101-books.sql b/packages/server/skel/fullstack/api/sql/20260101-books.sql similarity index 100% rename from packages/server/skel/fullstack/back/sql/20260101-books.sql rename to packages/server/skel/fullstack/api/sql/20260101-books.sql diff --git a/packages/server/skel/api/test/api/BooksTest.ts b/packages/server/skel/fullstack/api/test/features/books/BooksTest.ts similarity index 98% rename from packages/server/skel/api/test/api/BooksTest.ts rename to packages/server/skel/fullstack/api/test/features/books/BooksTest.ts index 01c8402a..0eb0f899 100644 --- a/packages/server/skel/api/test/api/BooksTest.ts +++ b/packages/server/skel/fullstack/api/test/features/books/BooksTest.ts @@ -1,7 +1,7 @@ import { dev } from '@igojs/server'; import assert from 'assert'; -import Book from '../../app/models/Book'; +import Book from '../../../app/features/books/Book'; dev.test(); diff --git a/packages/server/skel/fullstack/back/tsconfig.json b/packages/server/skel/fullstack/api/tsconfig.json similarity index 100% rename from packages/server/skel/fullstack/back/tsconfig.json rename to packages/server/skel/fullstack/api/tsconfig.json diff --git a/packages/server/skel/fullstack/package.json b/packages/server/skel/fullstack/package.json index 8aabba2a..b8a5cff1 100644 --- a/packages/server/skel/fullstack/package.json +++ b/packages/server/skel/fullstack/package.json @@ -4,13 +4,13 @@ "private": true, "type": "module", "scripts": { - "dev": "concurrently -n back,front -c blue,magenta \"pnpm --filter ./back start\" \"pnpm --filter ./front dev\"", - "build": "pnpm --filter ./back build && pnpm --filter ./front build", + "dev": "concurrently -n api,front -c blue,magenta \"pnpm --filter ./api start\" \"pnpm --filter ./front dev\"", + "build": "pnpm --filter ./api build && pnpm --filter ./front build", "lint": "pnpm -r lint", "typecheck": "pnpm -r typecheck", "test": "pnpm -r test", "test:e2e": "playwright test", - "migrate": "pnpm --filter ./back exec igo db migrate", + "migrate": "pnpm --filter ./api exec igo db migrate", "prepare": "husky", "format": "oxfmt", "format:check": "oxfmt --check" diff --git a/packages/server/skel/fullstack/pnpm-workspace.yaml b/packages/server/skel/fullstack/pnpm-workspace.yaml index b58be1b8..b41c055d 100644 --- a/packages/server/skel/fullstack/pnpm-workspace.yaml +++ b/packages/server/skel/fullstack/pnpm-workspace.yaml @@ -1,5 +1,5 @@ packages: - - back + - api - front # pnpm blocks postinstall scripts unless a package is listed here. These diff --git a/packages/server/test/CreateTest.js b/packages/server/test/CreateTest.js index 478a80a3..fa20982c 100644 --- a/packages/server/test/CreateTest.js +++ b/packages/server/test/CreateTest.js @@ -50,7 +50,7 @@ describe('cli/create', function() { assert(routes.includes('app.api('), 'routes mount through app.api()'); const controller = fs.readFileSync( - path.join(tmp, 'myapi', 'app', 'api', 'books', 'books.controller.ts'), 'utf8'); + path.join(tmp, 'myapi', 'app', 'features', 'books', 'books.controller.ts'), 'utf8'); assert(controller.includes('create.body = dto.CreateBook'), 'schema is attached to the handler'); }); From e23b3e127932f619e2a211d6145af177c91691f3 Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Mon, 7 Sep 2026 16:06:08 +0200 Subject: [PATCH 20/80] fix(server)!: harmoniser les CI des squelettes, MySQL en service, Valkey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MySQL passait par un docker run manuel avec attente explicite, là où Valkey était déclaré en service. La justification reprise de ladom — « services: ne sait pas passer de flags serveur » — est fausse : GitHub Actions expose une clé command, et sa propre documentation l'illustre avec MySQL. Les deux sont maintenant des services, health-checks compris. Valkey remplace Redis : c'est ce qui tourne en production. Le client redis d'igo parle le même protocole, vérifié contre une image valkey:8. Le squelette front avait un job unique là où api en a trois, et figeait node-version: 24 au lieu de lire .nvmrc. Les trois squelettes ont désormais la même structure : lint, test, build. Le squelette api documentait encore npm alors qu'il est passé à pnpm, et aucun ne mentionnait le script format. Co-Authored-By: Claude Opus 5 (1M context) --- packages/server/skel/api/CLAUDE.md | 1 + packages/server/skel/api/README.md | 14 ++-- .../server/skel/api/_.github/workflows/ci.yml | 43 ++++------ packages/server/skel/front/CLAUDE.md | 1 + packages/server/skel/front/README.md | 2 + .../skel/front/_.github/workflows/ci.yml | 36 ++++++-- packages/server/skel/fullstack/CLAUDE.md | 1 + packages/server/skel/fullstack/README.md | 1 + .../skel/fullstack/_.github/workflows/ci.yml | 84 ++++++++----------- 9 files changed, 95 insertions(+), 88 deletions(-) diff --git a/packages/server/skel/api/CLAUDE.md b/packages/server/skel/api/CLAUDE.md index 0ed9f42f..0debd5cf 100644 --- a/packages/server/skel/api/CLAUDE.md +++ b/packages/server/skel/api/CLAUDE.md @@ -8,6 +8,7 @@ API JSON sur [igo](https://github.com/igocreate/igo). TypeScript, Node 24, pnpm. pnpm start # tsx watch pnpm test # mocha — vraie base, isolée par transaction pnpm lint # oxlint +pnpm format # oxfmt pnpm typecheck # tsc --noEmit pnpm build # -> dist/ ``` diff --git a/packages/server/skel/api/README.md b/packages/server/skel/api/README.md index a3f0ad3d..02454034 100644 --- a/packages/server/skel/api/README.md +++ b/packages/server/skel/api/README.md @@ -5,12 +5,14 @@ API JSON TypeScript sur [igo](https://github.com/igocreate/igo). ## Démarrer ```bash -npm install -npm start # tsx watch, rechargement à chaud -npm test # mocha via tsx, base de test recréée à chaque run -npm run typecheck # tsc --noEmit -npm run build # compile vers dist/ -npm run serve # lance le build +pnpm install +pnpm start # tsx watch, rechargement à chaud +pnpm test # mocha via tsx, base de test recréée à chaque run +pnpm lint # oxlint +pnpm format # oxfmt +pnpm typecheck # tsc --noEmit +pnpm build # compile vers dist/ +pnpm serve # lance le build ``` ## Structure diff --git a/packages/server/skel/api/_.github/workflows/ci.yml b/packages/server/skel/api/_.github/workflows/ci.yml index e64c540e..09b7d4d8 100644 --- a/packages/server/skel/api/_.github/workflows/ci.yml +++ b/packages/server/skel/api/_.github/workflows/ci.yml @@ -33,27 +33,28 @@ jobs: runs-on: ubuntu-latest services: - redis: - image: redis + mysql: + image: mysql:8 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: 'yes' + MYSQL_DATABASE: ${{ env.MYSQL_DATABASE }} + ports: + - 3306:3306 + # igo connects in utf8mb4: a server left on its default charset breaks + # on the first accented character. + command: >- + --character-set-server=utf8mb4 + --collation-server=utf8mb4_unicode_ci + --innodb-default-row-format=dynamic + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=5 + + valkey: + image: valkey/valkey:8 ports: - 6379:6379 - options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=3 + options: --health-cmd="valkey-cli ping" --health-interval=10s --health-timeout=5s --health-retries=5 steps: - # `services:` cannot pass server flags, and igo connects in utf8mb4: - # a mismatched server charset breaks on the first accented character. - - name: Start MySQL - run: | - docker run -d --name mysql \ - -e MYSQL_ALLOW_EMPTY_PASSWORD=yes \ - -e MYSQL_DATABASE=${{ env.MYSQL_DATABASE }} \ - -p 3306:3306 \ - mysql:8 \ - --character-set-server=utf8mb4 \ - --collation-server=utf8mb4_unicode_ci \ - --innodb-default-row-format=dynamic \ - --innodb-strict-mode=0 - - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 @@ -62,14 +63,6 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - - name: Wait for MySQL - run: | - for i in $(seq 1 60); do - docker exec mysql mysqladmin ping --silent 2>/dev/null && exit 0 - sleep 2 - done - echo "MySQL never became ready" && exit 1 - # the test database is dropped, recreated and migrated by dev.test() - run: pnpm test diff --git a/packages/server/skel/front/CLAUDE.md b/packages/server/skel/front/CLAUDE.md index 3b726279..12e9fb7f 100644 --- a/packages/server/skel/front/CLAUDE.md +++ b/packages/server/skel/front/CLAUDE.md @@ -8,6 +8,7 @@ SPA React consommant l'API JSON d'igo. Vite, TypeScript, Node 24, pnpm. pnpm dev # http://localhost:5173, proxy /api vers le back pnpm test # vitest + testing library + msw pnpm lint # oxlint +pnpm format # oxfmt pnpm typecheck # tsc --noEmit pnpm build # -> dist/ ``` diff --git a/packages/server/skel/front/README.md b/packages/server/skel/front/README.md index b2d56477..1d607dea 100644 --- a/packages/server/skel/front/README.md +++ b/packages/server/skel/front/README.md @@ -8,6 +8,8 @@ SPA React servie en assets statiques, consommant l'API JSON d'igo. pnpm install pnpm dev # http://localhost:5173, proxy /api vers le back pnpm test # vitest + testing library + msw +pnpm lint # oxlint +pnpm format # oxfmt pnpm typecheck pnpm build # -> dist/ ``` diff --git a/packages/server/skel/front/_.github/workflows/ci.yml b/packages/server/skel/front/_.github/workflows/ci.yml index 5c64c8d8..c870a097 100644 --- a/packages/server/skel/front/_.github/workflows/ci.yml +++ b/packages/server/skel/front/_.github/workflows/ci.yml @@ -10,26 +10,44 @@ concurrency: cancel-in-progress: true jobs: - check: - name: Lint, typecheck, tests & build + lint: + name: Lint & typecheck runs-on: ubuntu-latest - steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 with: - node-version: 24 + node-version-file: .nvmrc cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm lint - run: pnpm format:check - run: pnpm typecheck + + test: + name: Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + - run: pnpm install --frozen-lockfile - run: pnpm test - # a build that fails in CI is a deployment that would have failed + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + - run: pnpm install --frozen-lockfile + # a build that fails here is a deployment that would have failed - run: pnpm build diff --git a/packages/server/skel/fullstack/CLAUDE.md b/packages/server/skel/fullstack/CLAUDE.md index bfc3e36d..3b6fb22f 100644 --- a/packages/server/skel/fullstack/CLAUDE.md +++ b/packages/server/skel/fullstack/CLAUDE.md @@ -11,6 +11,7 @@ fichier ne couvre que ce qui concerne les deux. pnpm dev # api :3000 + front :5173 pnpm build # api/dist + front/dist pnpm lint # oxlint sur les deux +pnpm format # oxfmt sur les deux pnpm typecheck pnpm test # tests back et front pnpm test:e2e # Playwright contre le build diff --git a/packages/server/skel/fullstack/README.md b/packages/server/skel/fullstack/README.md index 67824758..6b14c02d 100644 --- a/packages/server/skel/fullstack/README.md +++ b/packages/server/skel/fullstack/README.md @@ -23,6 +23,7 @@ MySQL et Redis doivent tourner en local. pnpm dev # les deux en parallèle pnpm build # api/dist et front/dist pnpm lint # oxlint sur les deux +pnpm format # oxfmt sur les deux pnpm typecheck # tsc sur les deux pnpm test # tests back et front pnpm test:e2e # Playwright contre le build diff --git a/packages/server/skel/fullstack/_.github/workflows/ci.yml b/packages/server/skel/fullstack/_.github/workflows/ci.yml index 39c18c6a..94b6280c 100644 --- a/packages/server/skel/fullstack/_.github/workflows/ci.yml +++ b/packages/server/skel/fullstack/_.github/workflows/ci.yml @@ -33,27 +33,28 @@ jobs: runs-on: ubuntu-latest services: - redis: - image: redis + mysql: + image: mysql:8 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: 'yes' + MYSQL_DATABASE: ${{ env.MYSQL_DATABASE }} + ports: + - 3306:3306 + # igo connects in utf8mb4: a server left on the default charset breaks + # on the first accented character. + command: >- + --character-set-server=utf8mb4 + --collation-server=utf8mb4_unicode_ci + --innodb-default-row-format=dynamic + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=5 + + valkey: + image: valkey/valkey:8 ports: - 6379:6379 - options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=3 + options: --health-cmd="valkey-cli ping" --health-interval=10s --health-timeout=5s --health-retries=5 steps: - # `services:` cannot pass server flags, and igo connects in utf8mb4: - # a mismatched server charset breaks on the first accented character. - - name: Start MySQL - run: | - docker run -d --name mysql \ - -e MYSQL_ALLOW_EMPTY_PASSWORD=yes \ - -e MYSQL_DATABASE=${{ env.MYSQL_DATABASE }} \ - -p 3306:3306 \ - mysql:8 \ - --character-set-server=utf8mb4 \ - --collation-server=utf8mb4_unicode_ci \ - --innodb-default-row-format=dynamic \ - --innodb-strict-mode=0 - - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 @@ -62,14 +63,6 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - - name: Wait for MySQL - run: | - for i in $(seq 1 60); do - docker exec mysql mysqladmin ping --silent 2>/dev/null && exit 0 - sleep 2 - done - echo "MySQL never became ready" && exit 1 - - run: pnpm test e2e: @@ -78,25 +71,28 @@ jobs: timeout-minutes: 15 services: - redis: - image: redis + mysql: + image: mysql:8 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: 'yes' + MYSQL_DATABASE: ${{ env.MYSQL_DATABASE }} + ports: + - 3306:3306 + # igo connects in utf8mb4: a server left on the default charset breaks + # on the first accented character. + command: >- + --character-set-server=utf8mb4 + --collation-server=utf8mb4_unicode_ci + --innodb-default-row-format=dynamic + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=5 + + valkey: + image: valkey/valkey:8 ports: - 6379:6379 - options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=3 + options: --health-cmd="valkey-cli ping" --health-interval=10s --health-timeout=5s --health-retries=5 steps: - - name: Start MySQL - run: | - docker run -d --name mysql \ - -e MYSQL_ALLOW_EMPTY_PASSWORD=yes \ - -e MYSQL_DATABASE=${{ env.MYSQL_DATABASE }} \ - -p 3306:3306 \ - mysql:8 \ - --character-set-server=utf8mb4 \ - --collation-server=utf8mb4_unicode_ci \ - --innodb-default-row-format=dynamic \ - --innodb-strict-mode=0 - - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 @@ -105,14 +101,6 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - - name: Wait for MySQL - run: | - for i in $(seq 1 60); do - docker exec mysql mysqladmin ping --silent 2>/dev/null && exit 0 - sleep 2 - done - echo "MySQL never became ready" && exit 1 - # E2E runs against the built front, not the dev server: that is what # ships, and a build-only failure would otherwise reach production. - run: pnpm migrate From e93ac858a48eef2282c834e32ce905f0f891e219 Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Mon, 7 Sep 2026 16:09:58 +0200 Subject: [PATCH 21/80] feat(server): E2E Playwright dans le squelette front MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les refontes partent de skel/front avec un back existant : sans Playwright, chacune recâblait sa chaîne E2E. Le webServer sert le build et proxifie /api ; API_URL désigne le back, que le squelette ne peut pas démarrer lui-même. Le job E2E de la CI est désactivé tant que E2E_API_URL n'est pas renseigné — un job rouge par défaut serait vite ignoré. Les seeds et l'authentification restent l'affaire du projet, comme ladom a dû le construire. Le job E2E attend lint et test, des deux côtés : c'est le plus lent et le plus instable, inutile de le payer quand une erreur de typage dit déjà que le build est cassé. lint et test restent parallèles, donc le retour rapide est préservé. vitest ne ramasse plus e2e/ : ses specs importent @playwright/test, qu'il ne sait pas résoudre. Vérifié : les 2 E2E du squelette front passent contre une vraie API igo. Co-Authored-By: Claude Opus 5 (1M context) --- packages/server/skel/front/README.md | 13 ++++++++ .../skel/front/_.github/workflows/ci.yml | 30 +++++++++++++++++ packages/server/skel/front/_.gitignore | 3 ++ packages/server/skel/front/e2e/books.spec.ts | 24 ++++++++++++++ packages/server/skel/front/package.json | 4 ++- .../server/skel/front/playwright.config.ts | 33 +++++++++++++++++++ packages/server/skel/front/vitest.config.ts | 3 ++ .../skel/fullstack/_.github/workflows/ci.yml | 3 ++ .../skel/fullstack/front/vitest.config.ts | 3 ++ 9 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 packages/server/skel/front/e2e/books.spec.ts create mode 100644 packages/server/skel/front/playwright.config.ts diff --git a/packages/server/skel/front/README.md b/packages/server/skel/front/README.md index 1d607dea..ab8243c3 100644 --- a/packages/server/skel/front/README.md +++ b/packages/server/skel/front/README.md @@ -12,6 +12,7 @@ pnpm lint # oxlint pnpm format # oxfmt pnpm typecheck pnpm build # -> dist/ +pnpm test:e2e # Playwright contre le build ``` Le back doit tourner en parallèle (`pnpm dev` côté igo, port 3000 par défaut). @@ -89,6 +90,18 @@ tests — changer de wrapper HTTP ne les casse pas. |---|---|---| | `components/` | rendu avec props | rien | | `sections/`, `pages/` | rendu avec providers | le réseau (MSW) | +| `e2e/` | navigateur réel, contre le build | rien — l'API tourne vraiment | + +`pnpm test:e2e` sert le build et proxifie `/api`, comme nginx en production. +**L'API doit tourner** — `API_URL` la désigne : + +```bash +API_URL=http://127.0.0.1:3000 pnpm test:e2e +``` + +Les seeds et l'authentification sont l'affaire du projet : un squelette ne peut +pas les deviner. Le job E2E de la CI est désactivé tant que `E2E_API_URL` n'est +pas renseigné. ## Le système de design diff --git a/packages/server/skel/front/_.github/workflows/ci.yml b/packages/server/skel/front/_.github/workflows/ci.yml index c870a097..6261c67f 100644 --- a/packages/server/skel/front/_.github/workflows/ci.yml +++ b/packages/server/skel/front/_.github/workflows/ci.yml @@ -38,6 +38,36 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm test + e2e: + name: E2E + runs-on: ubuntu-latest + timeout-minutes: 15 + # The slowest and least stable job: no point paying for it while a type + # error or a failing unit test already tells us the build is broken. + needs: [lint, test] + # The API this front talks to is not part of this repository: point + # E2E_API_URL at a running instance, and drop the `if` to enable the job. + if: false + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm build + - run: pnpm exec playwright install --with-deps chromium + - run: pnpm test:e2e + env: + API_URL: ${{ vars.E2E_API_URL }} + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 14 + build: name: Build runs-on: ubuntu-latest diff --git a/packages/server/skel/front/_.gitignore b/packages/server/skel/front/_.gitignore index f4776d87..13b89dbe 100644 --- a/packages/server/skel/front/_.gitignore +++ b/packages/server/skel/front/_.gitignore @@ -5,3 +5,6 @@ node_modules *.log dist + +playwright-report +test-results diff --git a/packages/server/skel/front/e2e/books.spec.ts b/packages/server/skel/front/e2e/books.spec.ts new file mode 100644 index 00000000..a0891d45 --- /dev/null +++ b/packages/server/skel/front/e2e/books.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from '@playwright/test'; + +// E2E covers the wiring end to end — browser, build, proxy, API. Everything +// below that is already covered faster by the component and feature tests, so +// this file stays short on purpose. +// +// The API must be running (see README). Seeding and authentication are the +// project's own concern: no skeleton can guess them. +test.describe('books', () => { + test('should list the books served by the API', async ({ page }) => { + await page.goto('/'); + + await expect(page.getByRole('heading', { name: 'Books' })).toBeVisible(); + await expect(page.getByText(/loading/i)).toBeHidden(); + }); + + test('should show the validation errors the server returns', async ({ page }) => { + await page.goto('/'); + + await page.getByRole('button', { name: /add book/i }).click(); + + await expect(page.getByRole('alert').first()).toBeVisible(); + }); +}); diff --git a/packages/server/skel/front/package.json b/packages/server/skel/front/package.json index c9bd3a86..cb954915 100644 --- a/packages/server/skel/front/package.json +++ b/packages/server/skel/front/package.json @@ -13,7 +13,8 @@ "typecheck": "tsc --noEmit", "prepare": "husky", "format": "oxfmt", - "format:check": "oxfmt --check" + "format:check": "oxfmt --check", + "test:e2e": "playwright test" }, "dependencies": { "@tanstack/react-query": "^5.102.0", @@ -24,6 +25,7 @@ "devDependencies": { "@commitlint/cli": "^21.2.0", "@commitlint/config-conventional": "^21.2.0", + "@playwright/test": "^1.63.0", "@tailwindcss/vite": "^4.3.0", "@testing-library/jest-dom": "^6.9.0", "@testing-library/react": "^16.3.0", diff --git a/packages/server/skel/front/playwright.config.ts b/packages/server/skel/front/playwright.config.ts new file mode 100644 index 00000000..a6152a08 --- /dev/null +++ b/packages/server/skel/front/playwright.config.ts @@ -0,0 +1,33 @@ +import { defineConfig, devices } from '@playwright/test'; + +const PORT = Number(process.env.E2E_PORT ?? 4173); +const BASE_URL = process.env.E2E_BASE_URL ?? `http://127.0.0.1:${PORT}`; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? [['html'], ['github']] : 'list', + + use: { + baseURL: BASE_URL, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + + // Serves the build and proxies /api to the API, the way nginx does in + // production. The API itself must already be running: point it with API_URL. + // --host binds 127.0.0.1 too, which vite does not do by default. + webServer: process.env.E2E_BASE_URL + ? undefined + : { + command: `pnpm exec vite preview --port ${PORT} --strictPort --host 127.0.0.1`, + url: BASE_URL, + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, +}); diff --git a/packages/server/skel/front/vitest.config.ts b/packages/server/skel/front/vitest.config.ts index 5e4a1f8e..2d6c6eed 100644 --- a/packages/server/skel/front/vitest.config.ts +++ b/packages/server/skel/front/vitest.config.ts @@ -11,6 +11,9 @@ export default mergeConfig( environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'], + // e2e/ belongs to Playwright: vitest would otherwise pick its specs up + // and fail on an import it cannot resolve. + include: ['src/**/*.{test,spec}.{ts,tsx}'], }, }), ); diff --git a/packages/server/skel/fullstack/_.github/workflows/ci.yml b/packages/server/skel/fullstack/_.github/workflows/ci.yml index 94b6280c..f83748a2 100644 --- a/packages/server/skel/fullstack/_.github/workflows/ci.yml +++ b/packages/server/skel/fullstack/_.github/workflows/ci.yml @@ -69,6 +69,9 @@ jobs: name: E2E runs-on: ubuntu-latest timeout-minutes: 15 + # The slowest and least stable job: no point paying for it while a type + # error or a failing unit test already tells us the build is broken. + needs: [lint, test] services: mysql: diff --git a/packages/server/skel/fullstack/front/vitest.config.ts b/packages/server/skel/fullstack/front/vitest.config.ts index 5e4a1f8e..2d6c6eed 100644 --- a/packages/server/skel/fullstack/front/vitest.config.ts +++ b/packages/server/skel/fullstack/front/vitest.config.ts @@ -11,6 +11,9 @@ export default mergeConfig( environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'], + // e2e/ belongs to Playwright: vitest would otherwise pick its specs up + // and fail on an import it cannot resolve. + include: ['src/**/*.{test,spec}.{ts,tsx}'], }, }), ); From 403c138bf319bfb03831107f280048cf6f594617 Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Mon, 7 Sep 2026 16:20:07 +0200 Subject: [PATCH 22/80] =?UTF-8?q?fix(server):=20Playwright=20d=C3=A9marre?= =?UTF-8?q?=20l'API,=20et=20teste=20le=20build=20en=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le back d'une refonte vit dans le même dépôt : la chaîne E2E doit savoir le lancer, pas supposer qu'il tourne déjà. playwright.config.ts démarre maintenant l'API et le front, et les arrête après. Le squelette front ne connaît pas la commande de démarrage du projet, donc E2E_API_COMMAND est vide par défaut et se renseigne une fois. Le fullstack, lui, connaît sa structure : il démarre `pnpm --filter ./api`. En CI c'est le build qui est servi (`serve`), pas tsx watch — c'est ce qui est déployé. En local, tsx watch évite un rebuild à chaque exécution. L'URL de santé sondée était `/api`, à laquelle igo répond 404 : Playwright n'attendait donc jamais que l'API soit prête, et échouait au bout de 120s. Elle pointe sur une route qui répond vraiment. Le job E2E du front attend aussi `build`. Le job build reste : les jobs ne partagent pas de système de fichiers, donc chacun reconstruit — et comme le job E2E est désactivé par défaut, sans lui le build ne serait pas vérifié. Vérifié : 2 tests E2E côté front avec l'API démarrée par Playwright, 3 côté fullstack, et 3 en mode CI contre le build. Co-Authored-By: Claude Opus 5 (1M context) --- packages/server/skel/front/README.md | 16 ++++++-- .../skel/front/_.github/workflows/ci.yml | 9 +++-- .../server/skel/front/playwright.config.ts | 37 +++++++++++++++---- .../skel/fullstack/_.github/workflows/ci.yml | 18 +-------- .../skel/fullstack/playwright.config.ts | 32 +++++++++++----- 5 files changed, 71 insertions(+), 41 deletions(-) diff --git a/packages/server/skel/front/README.md b/packages/server/skel/front/README.md index ab8243c3..fff81e3b 100644 --- a/packages/server/skel/front/README.md +++ b/packages/server/skel/front/README.md @@ -93,15 +93,25 @@ tests — changer de wrapper HTTP ne les casse pas. | `e2e/` | navigateur réel, contre le build | rien — l'API tourne vraiment | `pnpm test:e2e` sert le build et proxifie `/api`, comme nginx en production. -**L'API doit tourner** — `API_URL` la désigne : + +**L'API doit tourner.** Elle vit dans le même dépôt, mais le squelette ne sait +pas comment la démarrer — renseigner `E2E_API_COMMAND` dans +`playwright.config.ts` une bonne fois : + +```ts +const API_COMMAND = process.env.E2E_API_COMMAND ?? 'pnpm --filter ./back start'; +``` + +Playwright la démarre alors avant les tests et l'arrête après. Sinon, la lancer +soi-même et pointer `API_URL` dessus : ```bash API_URL=http://127.0.0.1:3000 pnpm test:e2e ``` Les seeds et l'authentification sont l'affaire du projet : un squelette ne peut -pas les deviner. Le job E2E de la CI est désactivé tant que `E2E_API_URL` n'est -pas renseigné. +pas les deviner. Le job E2E de la CI est désactivé tant que ce câblage n'est pas +fait. ## Le système de design diff --git a/packages/server/skel/front/_.github/workflows/ci.yml b/packages/server/skel/front/_.github/workflows/ci.yml index 6261c67f..c92b9501 100644 --- a/packages/server/skel/front/_.github/workflows/ci.yml +++ b/packages/server/skel/front/_.github/workflows/ci.yml @@ -43,10 +43,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 # The slowest and least stable job: no point paying for it while a type - # error or a failing unit test already tells us the build is broken. - needs: [lint, test] - # The API this front talks to is not part of this repository: point - # E2E_API_URL at a running instance, and drop the `if` to enable the job. + # error, a failing unit test or a broken build already tells us so. + needs: [lint, test, build] + # Needs the project's API: set E2E_API_COMMAND in playwright.config.ts to + # start it here, or point E2E_API_URL at a running one. Then drop this `if`. if: false steps: - uses: actions/checkout@v4 @@ -56,6 +56,7 @@ jobs: node-version-file: .nvmrc cache: pnpm - run: pnpm install --frozen-lockfile + # jobs do not share a filesystem: vite preview needs its own dist/ - run: pnpm build - run: pnpm exec playwright install --with-deps chromium - run: pnpm test:e2e diff --git a/packages/server/skel/front/playwright.config.ts b/packages/server/skel/front/playwright.config.ts index a6152a08..d632bb58 100644 --- a/packages/server/skel/front/playwright.config.ts +++ b/packages/server/skel/front/playwright.config.ts @@ -2,6 +2,15 @@ import { defineConfig, devices } from '@playwright/test'; const PORT = Number(process.env.E2E_PORT ?? 4173); const BASE_URL = process.env.E2E_BASE_URL ?? `http://127.0.0.1:${PORT}`; +const API_URL = process.env.API_URL ?? 'http://127.0.0.1:3000'; +// Polled to know the API is up: any route it answers 2xx on will do. +const API_HEALTH_URL = process.env.E2E_API_HEALTH_URL ?? `${API_URL}/api/books`; + +// The API lives in this repository, but only the project knows how to start +// it: `pnpm --filter ./back start`, `npm start --prefix ../api`, a docker +// compose… Set it here once, or leave it empty to test against an API that is +// already running. +const API_COMMAND = process.env.E2E_API_COMMAND ?? ''; export default defineConfig({ testDir: './e2e', @@ -19,15 +28,27 @@ export default defineConfig({ projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], - // Serves the build and proxies /api to the API, the way nginx does in - // production. The API itself must already be running: point it with API_URL. + // Two servers: the API of this project, and the built front that proxies + // /api to it — the way nginx does in production. // --host binds 127.0.0.1 too, which vite does not do by default. webServer: process.env.E2E_BASE_URL ? undefined - : { - command: `pnpm exec vite preview --port ${PORT} --strictPort --host 127.0.0.1`, - url: BASE_URL, - reuseExistingServer: !process.env.CI, - timeout: 60_000, - }, + : [ + ...(API_COMMAND + ? [ + { + command: API_COMMAND, + url: API_HEALTH_URL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + ] + : []), + { + command: `pnpm exec vite preview --port ${PORT} --strictPort --host 127.0.0.1`, + url: BASE_URL, + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, + ], }); diff --git a/packages/server/skel/fullstack/_.github/workflows/ci.yml b/packages/server/skel/fullstack/_.github/workflows/ci.yml index f83748a2..fd58090f 100644 --- a/packages/server/skel/fullstack/_.github/workflows/ci.yml +++ b/packages/server/skel/fullstack/_.github/workflows/ci.yml @@ -115,24 +115,10 @@ jobs: - name: Install Playwright browsers run: pnpm exec playwright install --with-deps chromium - # `serve` runs from dist/, where the compiled app/routes.js lives - - name: Start the API - run: pnpm --filter ./api serve & + # playwright.config.ts starts the API and the front, and waits for both + - run: pnpm test:e2e env: - NODE_ENV: production MYSQL_DATABASE: ${{ env.MYSQL_DATABASE }} - COOKIE_SECRET: e2e-cookie-secret - COOKIE_SESSION_KEYS: e2e-session-key - - - name: Wait for the API - run: | - for i in $(seq 1 30); do - curl -sf http://127.0.0.1:3000/api/books > /dev/null && exit 0 - sleep 1 - done - echo "API failed to start" && exit 1 - - - run: pnpm test:e2e - uses: actions/upload-artifact@v4 if: ${{ !cancelled() }} diff --git a/packages/server/skel/fullstack/playwright.config.ts b/packages/server/skel/fullstack/playwright.config.ts index a088e7bd..7f5d85d8 100644 --- a/packages/server/skel/fullstack/playwright.config.ts +++ b/packages/server/skel/fullstack/playwright.config.ts @@ -2,6 +2,7 @@ import { defineConfig, devices } from '@playwright/test'; const PORT = Number(process.env.E2E_PORT ?? 4173); const BASE_URL = process.env.E2E_BASE_URL ?? `http://127.0.0.1:${PORT}`; +const API_URL = process.env.API_URL ?? 'http://127.0.0.1:3000'; export default defineConfig({ testDir: './e2e', @@ -19,16 +20,27 @@ export default defineConfig({ projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], - // Serves the built front and proxies /api to the back, the way nginx does in - // production — so the tests exercise the real single-origin setup. + // Two servers: the API, and the built front that proxies /api to it — the + // way nginx does in production. Playwright starts both and stops them after. webServer: process.env.E2E_BASE_URL ? undefined - : { - // --host binds 127.0.0.1 too: vite listens on localhost (IPv6) by default, - // which the url below would never reach. - command: `pnpm --filter ./front exec vite preview --port ${PORT} --strictPort --host 127.0.0.1`, - url: BASE_URL, - reuseExistingServer: !process.env.CI, - timeout: 60_000, - }, + : [ + { + // in CI the build is what ships, so that is what gets tested; + // locally, tsx watch avoids a rebuild on every run + command: process.env.CI ? 'pnpm --filter ./api serve' : 'pnpm --filter ./api start', + // polled to know the API is up: any route it answers 2xx on will do + url: `${API_URL}/api/books`, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + { + // --host binds 127.0.0.1 too: vite listens on localhost (IPv6) by + // default, which the url below would never reach. + command: `pnpm --filter ./front exec vite preview --port ${PORT} --strictPort --host 127.0.0.1`, + url: BASE_URL, + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, + ], }); From b93b60a8e4d2ab61bacda247bc3a51229b4cae4a Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Mon, 7 Sep 2026 16:25:54 +0200 Subject: [PATCH 23/80] revert(server): retirer Playwright du squelette front MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le job E2E arrivait désactivé, avec deux variables à câbler et un spec portant sur un domaine « books » qui n'existera dans aucune refonte. Une refonte devait supprimer le spec, câbler la commande de démarrage, écrire son seed et gérer son authentification — plus de travail que `npx playwright init`. Le squelette front ne peut pas savoir ce qu'il teste : son back a un métier réel, que seul le projet connaît. Le fullstack garde Playwright, lui contrôle les deux moitiés. Vitest + Testing Library + MSW restent : ils couvrent les tests de composants, la zone morte que l'ADR stratégie de test front identifie. Les E2E y sont « chemins critiques seulement ». Co-Authored-By: Claude Opus 5 (1M context) --- packages/server/skel/front/README.md | 25 ++------- .../skel/front/_.github/workflows/ci.yml | 31 ----------- packages/server/skel/front/_.gitignore | 3 -- packages/server/skel/front/e2e/books.spec.ts | 24 --------- packages/server/skel/front/package.json | 4 +- .../server/skel/front/playwright.config.ts | 54 ------------------- packages/server/skel/front/vitest.config.ts | 2 - 7 files changed, 4 insertions(+), 139 deletions(-) delete mode 100644 packages/server/skel/front/e2e/books.spec.ts delete mode 100644 packages/server/skel/front/playwright.config.ts diff --git a/packages/server/skel/front/README.md b/packages/server/skel/front/README.md index fff81e3b..4d132275 100644 --- a/packages/server/skel/front/README.md +++ b/packages/server/skel/front/README.md @@ -12,7 +12,6 @@ pnpm lint # oxlint pnpm format # oxfmt pnpm typecheck pnpm build # -> dist/ -pnpm test:e2e # Playwright contre le build ``` Le back doit tourner en parallèle (`pnpm dev` côté igo, port 3000 par défaut). @@ -90,28 +89,10 @@ tests — changer de wrapper HTTP ne les casse pas. |---|---|---| | `components/` | rendu avec props | rien | | `sections/`, `pages/` | rendu avec providers | le réseau (MSW) | -| `e2e/` | navigateur réel, contre le build | rien — l'API tourne vraiment | -`pnpm test:e2e` sert le build et proxifie `/api`, comme nginx en production. - -**L'API doit tourner.** Elle vit dans le même dépôt, mais le squelette ne sait -pas comment la démarrer — renseigner `E2E_API_COMMAND` dans -`playwright.config.ts` une bonne fois : - -```ts -const API_COMMAND = process.env.E2E_API_COMMAND ?? 'pnpm --filter ./back start'; -``` - -Playwright la démarre alors avant les tests et l'arrête après. Sinon, la lancer -soi-même et pointer `API_URL` dessus : - -```bash -API_URL=http://127.0.0.1:3000 pnpm test:e2e -``` - -Les seeds et l'authentification sont l'affaire du projet : un squelette ne peut -pas les deviner. Le job E2E de la CI est désactivé tant que ce câblage n'est pas -fait. +Pas de Playwright ici : les parcours E2E dépendent du back du projet — son +authentification, ses données de référence — que le squelette ne peut pas +deviner. `npx playwright init` le jour où ils deviennent nécessaires. ## Le système de design diff --git a/packages/server/skel/front/_.github/workflows/ci.yml b/packages/server/skel/front/_.github/workflows/ci.yml index c92b9501..c870a097 100644 --- a/packages/server/skel/front/_.github/workflows/ci.yml +++ b/packages/server/skel/front/_.github/workflows/ci.yml @@ -38,37 +38,6 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm test - e2e: - name: E2E - runs-on: ubuntu-latest - timeout-minutes: 15 - # The slowest and least stable job: no point paying for it while a type - # error, a failing unit test or a broken build already tells us so. - needs: [lint, test, build] - # Needs the project's API: set E2E_API_COMMAND in playwright.config.ts to - # start it here, or point E2E_API_URL at a running one. Then drop this `if`. - if: false - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version-file: .nvmrc - cache: pnpm - - run: pnpm install --frozen-lockfile - # jobs do not share a filesystem: vite preview needs its own dist/ - - run: pnpm build - - run: pnpm exec playwright install --with-deps chromium - - run: pnpm test:e2e - env: - API_URL: ${{ vars.E2E_API_URL }} - - uses: actions/upload-artifact@v4 - if: ${{ !cancelled() }} - with: - name: playwright-report - path: playwright-report/ - retention-days: 14 - build: name: Build runs-on: ubuntu-latest diff --git a/packages/server/skel/front/_.gitignore b/packages/server/skel/front/_.gitignore index 13b89dbe..f4776d87 100644 --- a/packages/server/skel/front/_.gitignore +++ b/packages/server/skel/front/_.gitignore @@ -5,6 +5,3 @@ node_modules *.log dist - -playwright-report -test-results diff --git a/packages/server/skel/front/e2e/books.spec.ts b/packages/server/skel/front/e2e/books.spec.ts deleted file mode 100644 index a0891d45..00000000 --- a/packages/server/skel/front/e2e/books.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { expect, test } from '@playwright/test'; - -// E2E covers the wiring end to end — browser, build, proxy, API. Everything -// below that is already covered faster by the component and feature tests, so -// this file stays short on purpose. -// -// The API must be running (see README). Seeding and authentication are the -// project's own concern: no skeleton can guess them. -test.describe('books', () => { - test('should list the books served by the API', async ({ page }) => { - await page.goto('/'); - - await expect(page.getByRole('heading', { name: 'Books' })).toBeVisible(); - await expect(page.getByText(/loading/i)).toBeHidden(); - }); - - test('should show the validation errors the server returns', async ({ page }) => { - await page.goto('/'); - - await page.getByRole('button', { name: /add book/i }).click(); - - await expect(page.getByRole('alert').first()).toBeVisible(); - }); -}); diff --git a/packages/server/skel/front/package.json b/packages/server/skel/front/package.json index cb954915..c9bd3a86 100644 --- a/packages/server/skel/front/package.json +++ b/packages/server/skel/front/package.json @@ -13,8 +13,7 @@ "typecheck": "tsc --noEmit", "prepare": "husky", "format": "oxfmt", - "format:check": "oxfmt --check", - "test:e2e": "playwright test" + "format:check": "oxfmt --check" }, "dependencies": { "@tanstack/react-query": "^5.102.0", @@ -25,7 +24,6 @@ "devDependencies": { "@commitlint/cli": "^21.2.0", "@commitlint/config-conventional": "^21.2.0", - "@playwright/test": "^1.63.0", "@tailwindcss/vite": "^4.3.0", "@testing-library/jest-dom": "^6.9.0", "@testing-library/react": "^16.3.0", diff --git a/packages/server/skel/front/playwright.config.ts b/packages/server/skel/front/playwright.config.ts deleted file mode 100644 index d632bb58..00000000 --- a/packages/server/skel/front/playwright.config.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { defineConfig, devices } from '@playwright/test'; - -const PORT = Number(process.env.E2E_PORT ?? 4173); -const BASE_URL = process.env.E2E_BASE_URL ?? `http://127.0.0.1:${PORT}`; -const API_URL = process.env.API_URL ?? 'http://127.0.0.1:3000'; -// Polled to know the API is up: any route it answers 2xx on will do. -const API_HEALTH_URL = process.env.E2E_API_HEALTH_URL ?? `${API_URL}/api/books`; - -// The API lives in this repository, but only the project knows how to start -// it: `pnpm --filter ./back start`, `npm start --prefix ../api`, a docker -// compose… Set it here once, or leave it empty to test against an API that is -// already running. -const API_COMMAND = process.env.E2E_API_COMMAND ?? ''; - -export default defineConfig({ - testDir: './e2e', - fullyParallel: true, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, - reporter: process.env.CI ? [['html'], ['github']] : 'list', - - use: { - baseURL: BASE_URL, - trace: 'on-first-retry', - screenshot: 'only-on-failure', - }, - - projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], - - // Two servers: the API of this project, and the built front that proxies - // /api to it — the way nginx does in production. - // --host binds 127.0.0.1 too, which vite does not do by default. - webServer: process.env.E2E_BASE_URL - ? undefined - : [ - ...(API_COMMAND - ? [ - { - command: API_COMMAND, - url: API_HEALTH_URL, - reuseExistingServer: !process.env.CI, - timeout: 120_000, - }, - ] - : []), - { - command: `pnpm exec vite preview --port ${PORT} --strictPort --host 127.0.0.1`, - url: BASE_URL, - reuseExistingServer: !process.env.CI, - timeout: 60_000, - }, - ], -}); diff --git a/packages/server/skel/front/vitest.config.ts b/packages/server/skel/front/vitest.config.ts index 2d6c6eed..ada938c3 100644 --- a/packages/server/skel/front/vitest.config.ts +++ b/packages/server/skel/front/vitest.config.ts @@ -11,8 +11,6 @@ export default mergeConfig( environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'], - // e2e/ belongs to Playwright: vitest would otherwise pick its specs up - // and fail on an import it cannot resolve. include: ['src/**/*.{test,spec}.{ts,tsx}'], }, }), From 9eb0eeecbf0eceb29a3991d311fcacb07f4baddf Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Mon, 7 Sep 2026 16:29:35 +0200 Subject: [PATCH 24/80] =?UTF-8?q?docs(server):=20all=C3=A9ger=20les=20comm?= =?UTF-8?q?entaires=20des=20workflows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un `- run: pnpm build` n'a pas besoin qu'on explique ce qu'il fait. Restent les deux qui portent une raison non déductible : pourquoi les E2E tournent contre le build, et qui démarre les serveurs. Le premier était placé avant `pnpm migrate` alors qu'il parle du build. Co-Authored-By: Claude Opus 5 (1M context) --- packages/server/skel/api/_.github/workflows/ci.yml | 3 --- packages/server/skel/front/_.github/workflows/ci.yml | 1 - packages/server/skel/fullstack/_.github/workflows/ci.yml | 4 +--- 3 files changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/server/skel/api/_.github/workflows/ci.yml b/packages/server/skel/api/_.github/workflows/ci.yml index 09b7d4d8..c7a5de93 100644 --- a/packages/server/skel/api/_.github/workflows/ci.yml +++ b/packages/server/skel/api/_.github/workflows/ci.yml @@ -62,8 +62,6 @@ jobs: node-version-file: .nvmrc cache: pnpm - run: pnpm install --frozen-lockfile - - # the test database is dropped, recreated and migrated by dev.test() - run: pnpm test build: @@ -77,5 +75,4 @@ jobs: node-version-file: .nvmrc cache: pnpm - run: pnpm install --frozen-lockfile - # a build that fails here is a deployment that would have failed - run: pnpm build diff --git a/packages/server/skel/front/_.github/workflows/ci.yml b/packages/server/skel/front/_.github/workflows/ci.yml index c870a097..7c4e8406 100644 --- a/packages/server/skel/front/_.github/workflows/ci.yml +++ b/packages/server/skel/front/_.github/workflows/ci.yml @@ -49,5 +49,4 @@ jobs: node-version-file: .nvmrc cache: pnpm - run: pnpm install --frozen-lockfile - # a build that fails here is a deployment that would have failed - run: pnpm build diff --git a/packages/server/skel/fullstack/_.github/workflows/ci.yml b/packages/server/skel/fullstack/_.github/workflows/ci.yml index fd58090f..c2059546 100644 --- a/packages/server/skel/fullstack/_.github/workflows/ci.yml +++ b/packages/server/skel/fullstack/_.github/workflows/ci.yml @@ -62,7 +62,6 @@ jobs: node-version-file: .nvmrc cache: pnpm - run: pnpm install --frozen-lockfile - - run: pnpm test e2e: @@ -104,12 +103,11 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - # E2E runs against the built front, not the dev server: that is what - # ships, and a build-only failure would otherwise reach production. - run: pnpm migrate env: MYSQL_DATABASE: ${{ env.MYSQL_DATABASE }} + # E2E runs against the build, not the dev server: that is what ships - run: pnpm build - name: Install Playwright browsers From 0fd3ed118eeedb7165f7bdfacc3ae9a64f42ee89 Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Mon, 7 Sep 2026 16:36:26 +0200 Subject: [PATCH 25/80] docs: documenter les trois squelettes API-first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getting-started ne mentionnait que --skel=api : front et fullstack existaient sans que rien ne le dise. Un tableau comparatif dit lequel prendre — front quand l'API existe déjà, fullstack pour démarrer les deux. La feuille de route parlait encore d'un squelette unique et de app/api/ pour le greenfield, que l'ADR réserve aux refontes. Le job E2E migre avant de démarrer : auto_migrate n'est vrai qu'en production et dev.test() ne migre que pour la suite de tests, donc une base CI fraîche n'a aucune table sans ce step. Co-Authored-By: Claude Opus 5 (1M context) --- docs/feuille-de-route-socle-igo.md | 4 ++-- docs/server/getting-started.md | 22 ++++++++++++++----- .../skel/fullstack/_.github/workflows/ci.yml | 2 ++ 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/docs/feuille-de-route-socle-igo.md b/docs/feuille-de-route-socle-igo.md index a597ba87..a9d49b2a 100644 --- a/docs/feuille-de-route-socle-igo.md +++ b/docs/feuille-de-route-socle-igo.md @@ -19,11 +19,11 @@ Les améliorations sont cumulatives : ce qui sert aux refontes sert aussi aux gr | Action | Quoi | Effort | |---|---|---| | **`@igojs/component` en maintenance** | Acte explicite — les 6 écrans certigo sont supportés, pas étendus | Décision | -| **Convention de routes API** | Les routes JSON vivent dans `app/api/` — pas d'alias imposé par igo | Convention | +| **Convention de routes API** | `app/api/` en refonte, `app/features/` en greenfield — pas d'alias imposé par igo | Convention | | **Réponses d'erreur JSON** | Sous le préfixe API, tout répond en JSON au format RFC 9457 — 500, 404 et erreurs de validation | Faible | | **Middleware de validation Zod** | Middleware global monté par igo ; le schéma est attaché au handler, rien à écrire dans les routes | Faible | | **Déclarations TypeScript** | `.d.ts` sur l'API publique — les schémas Zod deviennent la source des types, sans impact sur les projets JS | Moyen | -| **Squelette API** | `skel/api` — TypeScript, à côté de `skel/tailwind` | Moyen | +| **Squelettes** | `skel/api`, `skel/front`, `skel/fullstack` — TypeScript, pnpm, oxlint/oxfmt, hooks git, CI | Moyen | | **Logs structurés** | JSON en production, identifiant de requête propagé, une ligne par requête — prépare l'ingestion Loki sans dépendre de l'outil | Faible | ## Phase 2 — Première refonte front diff --git a/docs/server/getting-started.md b/docs/server/getting-started.md index 0ff1c9d6..305a5613 100644 --- a/docs/server/getting-started.md +++ b/docs/server/getting-started.md @@ -34,15 +34,27 @@ npm start ### Skeletons -`create` scaffolds a server-rendered project by default. For a TypeScript JSON -API with no views and no bundler: +`create` scaffolds a server-rendered project by default. Three other skeletons +target the API-first stack: ```sh -npx @igojs/server create myapi --skel=api +npx @igojs/server create myapp --skel=api # TypeScript JSON API +npx @igojs/server create myapp --skel=front # React SPA on that API +npx @igojs/server create myapp --skel=fullstack # both, in one repository ``` -It ships a working domain — model, DTO, controller, routes, migration and -integration tests. See [JSON APIs](./api). +| | What it holds | +|---|---| +| `tailwind` | Server-rendered views, dust templates, webpack. The default. | +| `api` | TypeScript API — model, DTO, controller, routes, migration, integration tests. No views, no bundler. | +| `front` | Vite, React, React Router, TanStack Query, Vitest + MSW. Consumes an existing igo API. | +| `fullstack` | `api/` and `front/` as pnpm workspaces, plus Playwright and an nginx example. | + +All three ship a working `books` domain to copy from, oxlint and oxfmt, git +hooks enforcing Conventional Commits, and a CI workflow. See [JSON APIs](./api). + +Use `front` when the API already exists — a front-end rewrite of a running +project — and `fullstack` to start both at once. ## Minimal app diff --git a/packages/server/skel/fullstack/_.github/workflows/ci.yml b/packages/server/skel/fullstack/_.github/workflows/ci.yml index c2059546..23aaad29 100644 --- a/packages/server/skel/fullstack/_.github/workflows/ci.yml +++ b/packages/server/skel/fullstack/_.github/workflows/ci.yml @@ -103,6 +103,8 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile + # auto_migrate only runs in production, and dev.test() only migrates for + # the test suite: a fresh CI database has no tables until this step - run: pnpm migrate env: MYSQL_DATABASE: ${{ env.MYSQL_DATABASE }} From 8a615e11474e3f3922cef441adf892446f12c2eb Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Mon, 7 Sep 2026 16:38:52 +0200 Subject: [PATCH 26/80] =?UTF-8?q?feat(server):=20docker-compose=20pour=20l?= =?UTF-8?q?es=20d=C3=A9pendances=20locales?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MySQL et Valkey en conteneurs, l'app tourne en natif — le modèle qu'utilise déjà api-ceremonie chez funecap. Ça évite d'installer les deux à la main sur chaque poste, et c'est identique d'un projet igo à l'autre. Le flag utf8mb4 y est aussi : sans lui, MySQL démarre sur son charset par défaut et casse au premier accent. Pas de Dockerfile : ladom et certigo déploient par Ansible sur du bare metal, funecap pousse des images sur ECR. Le squelette ne peut pas trancher, et un Dockerfile faux est pire que pas de Dockerfile. Vérifié : docker compose up -d démarre les deux conteneurs sains, le serveur est bien en utf8mb4, et les 7 tests du squelette passent contre eux. Co-Authored-By: Claude Opus 5 (1M context) --- packages/server/skel/api/CLAUDE.md | 4 +- packages/server/skel/api/README.md | 4 ++ packages/server/skel/api/docker-compose.yml | 38 +++++++++++++++++++ packages/server/skel/fullstack/README.md | 6 +-- packages/server/skel/fullstack/api/CLAUDE.md | 5 ++- .../server/skel/fullstack/docker-compose.yml | 38 +++++++++++++++++++ 6 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 packages/server/skel/api/docker-compose.yml create mode 100644 packages/server/skel/fullstack/docker-compose.yml diff --git a/packages/server/skel/api/CLAUDE.md b/packages/server/skel/api/CLAUDE.md index 0debd5cf..58d42630 100644 --- a/packages/server/skel/api/CLAUDE.md +++ b/packages/server/skel/api/CLAUDE.md @@ -13,8 +13,8 @@ pnpm typecheck # tsc --noEmit pnpm build # -> dist/ ``` -Les tests ont besoin de MySQL et Redis en local. La base de test est recréée et -migrée à chaque exécution. +Les tests ont besoin de MySQL et Valkey — `docker compose up -d` les lance. La +base de test est recréée et migrée à chaque exécution. ## Structure diff --git a/packages/server/skel/api/README.md b/packages/server/skel/api/README.md index 02454034..347ce80c 100644 --- a/packages/server/skel/api/README.md +++ b/packages/server/skel/api/README.md @@ -4,8 +4,12 @@ API JSON TypeScript sur [igo](https://github.com/igocreate/igo). ## Démarrer +MySQL et Valkey doivent tourner — `docker compose up -d` les lance, ou utiliser +ceux déjà installés. + ```bash pnpm install +docker compose up -d pnpm start # tsx watch, rechargement à chaud pnpm test # mocha via tsx, base de test recréée à chaque run pnpm lint # oxlint diff --git a/packages/server/skel/api/docker-compose.yml b/packages/server/skel/api/docker-compose.yml new file mode 100644 index 00000000..0ff17138 --- /dev/null +++ b/packages/server/skel/api/docker-compose.yml @@ -0,0 +1,38 @@ +# Local dependencies. The app itself runs natively — `pnpm start`. +# +# docker compose up -d +# +services: + mysql: + image: mysql:8 + restart: unless-stopped + environment: + MYSQL_ALLOW_EMPTY_PASSWORD: 'yes' + MYSQL_DATABASE: '{project.name}' + ports: + - '3306:3306' + # igo connects in utf8mb4: a server left on its default charset breaks on + # the first accented character. + command: >- + --character-set-server=utf8mb4 + --collation-server=utf8mb4_unicode_ci + --innodb-default-row-format=dynamic + volumes: + - mysql_data:/var/lib/mysql + healthcheck: + test: ['CMD', 'mysqladmin', 'ping', '-h', 'localhost'] + interval: 10s + retries: 5 + + valkey: + image: valkey/valkey:8 + restart: unless-stopped + ports: + - '6379:6379' + healthcheck: + test: ['CMD', 'valkey-cli', 'ping'] + interval: 10s + retries: 5 + +volumes: + mysql_data: diff --git a/packages/server/skel/fullstack/README.md b/packages/server/skel/fullstack/README.md index 6b14c02d..8fc97f48 100644 --- a/packages/server/skel/fullstack/README.md +++ b/packages/server/skel/fullstack/README.md @@ -7,15 +7,15 @@ dépôt. TypeScript, Node 24, pnpm. ```bash pnpm install -pnpm migrate # crée les tables -pnpm dev # api sur :3000, front sur :5173 +docker compose up -d # MySQL + Valkey +pnpm migrate # crée les tables +pnpm dev # api sur :3000, front sur :5173 ``` Ouvrir http://localhost:5173. Le front proxifie `/api` vers le back : le navigateur ne voit qu'une seule origine, donc **le cookie de session passe sans CORS**. -MySQL et Redis doivent tourner en local. ## Commandes diff --git a/packages/server/skel/fullstack/api/CLAUDE.md b/packages/server/skel/fullstack/api/CLAUDE.md index 0ed9f42f..58d42630 100644 --- a/packages/server/skel/fullstack/api/CLAUDE.md +++ b/packages/server/skel/fullstack/api/CLAUDE.md @@ -8,12 +8,13 @@ API JSON sur [igo](https://github.com/igocreate/igo). TypeScript, Node 24, pnpm. pnpm start # tsx watch pnpm test # mocha — vraie base, isolée par transaction pnpm lint # oxlint +pnpm format # oxfmt pnpm typecheck # tsc --noEmit pnpm build # -> dist/ ``` -Les tests ont besoin de MySQL et Redis en local. La base de test est recréée et -migrée à chaque exécution. +Les tests ont besoin de MySQL et Valkey — `docker compose up -d` les lance. La +base de test est recréée et migrée à chaque exécution. ## Structure diff --git a/packages/server/skel/fullstack/docker-compose.yml b/packages/server/skel/fullstack/docker-compose.yml new file mode 100644 index 00000000..0ff17138 --- /dev/null +++ b/packages/server/skel/fullstack/docker-compose.yml @@ -0,0 +1,38 @@ +# Local dependencies. The app itself runs natively — `pnpm start`. +# +# docker compose up -d +# +services: + mysql: + image: mysql:8 + restart: unless-stopped + environment: + MYSQL_ALLOW_EMPTY_PASSWORD: 'yes' + MYSQL_DATABASE: '{project.name}' + ports: + - '3306:3306' + # igo connects in utf8mb4: a server left on its default charset breaks on + # the first accented character. + command: >- + --character-set-server=utf8mb4 + --collation-server=utf8mb4_unicode_ci + --innodb-default-row-format=dynamic + volumes: + - mysql_data:/var/lib/mysql + healthcheck: + test: ['CMD', 'mysqladmin', 'ping', '-h', 'localhost'] + interval: 10s + retries: 5 + + valkey: + image: valkey/valkey:8 + restart: unless-stopped + ports: + - '6379:6379' + healthcheck: + test: ['CMD', 'valkey-cli', 'ping'] + interval: 10s + retries: 5 + +volumes: + mysql_data: From d27f34e8360b2c3b2f8446759aba8346dfb72268 Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Mon, 7 Sep 2026 17:12:06 +0200 Subject: [PATCH 27/80] =?UTF-8?q?docs(skel):=20typer=20les=20erreurs=20m?= =?UTF-8?q?=C3=A9tier=20du=20contr=C3=B4leur=20d'exemple?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le CLAUDE.md et la doc prescrivent un `type` par cas métier, mais les 404 du contrôleur sortaient un `about:blank` : le squelette contredisait la convention qu'il est censé enseigner. Co-Authored-By: Claude Opus 5 (1M context) --- .../api/app/features/books/books.controller.ts | 14 +++++++------- .../api/app/features/books/books.controller.ts | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/server/skel/api/app/features/books/books.controller.ts b/packages/server/skel/api/app/features/books/books.controller.ts index b58a2a2b..fbb533f6 100644 --- a/packages/server/skel/api/app/features/books/books.controller.ts +++ b/packages/server/skel/api/app/features/books/books.controller.ts @@ -4,6 +4,10 @@ import type { ApiHandler } from '@igojs/server'; import Book from './Book'; import * as dto from './books.dto'; +// The `type` is what a client branches on — the status alone cannot tell two +// business situations apart. It is a URI, and the slug belongs to the project. +const BOOK_NOT_FOUND = '/problems/book-not-found'; + // The schemas below give req.body and req.query their types: no shape is // declared twice, and a field that is not in the schema is a compile error. export const index: ApiHandler<{ query: typeof dto.ListBooks }> = async (req, res) => { @@ -22,38 +26,34 @@ export const index: ApiHandler<{ query: typeof dto.ListBooks }> = async (req, re }; index.query = dto.ListBooks; -// export const show: ApiHandler = async (req, res) => { const book = await Book.find(req.params.id); if (!book) { - return void sendProblem(res, 404, { detail: 'Book not found' }); + return void sendProblem(res, 404, { type: BOOK_NOT_FOUND, detail: 'Book not found' }); } res.json(dto.serialize(book)); }; -// export const create: ApiHandler<{ body: typeof dto.CreateBook }> = async (req, res) => { const book = await Book.create(req.body); res.status(201).json(dto.serialize(book)); }; create.body = dto.CreateBook; -// export const update: ApiHandler<{ body: typeof dto.UpdateBook }> = async (req, res) => { const book = await Book.find(req.params.id); if (!book) { - return void sendProblem(res, 404, { detail: 'Book not found' }); + return void sendProblem(res, 404, { type: BOOK_NOT_FOUND, detail: 'Book not found' }); } await book.update(req.body); res.json(dto.serialize(book)); }; update.body = dto.UpdateBook; -// export const destroy: ApiHandler = async (req, res) => { const book = await Book.find(req.params.id); if (!book) { - return void sendProblem(res, 404, { detail: 'Book not found' }); + return void sendProblem(res, 404, { type: BOOK_NOT_FOUND, detail: 'Book not found' }); } await book.delete(); res.status(204).end(); diff --git a/packages/server/skel/fullstack/api/app/features/books/books.controller.ts b/packages/server/skel/fullstack/api/app/features/books/books.controller.ts index b58a2a2b..fbb533f6 100644 --- a/packages/server/skel/fullstack/api/app/features/books/books.controller.ts +++ b/packages/server/skel/fullstack/api/app/features/books/books.controller.ts @@ -4,6 +4,10 @@ import type { ApiHandler } from '@igojs/server'; import Book from './Book'; import * as dto from './books.dto'; +// The `type` is what a client branches on — the status alone cannot tell two +// business situations apart. It is a URI, and the slug belongs to the project. +const BOOK_NOT_FOUND = '/problems/book-not-found'; + // The schemas below give req.body and req.query their types: no shape is // declared twice, and a field that is not in the schema is a compile error. export const index: ApiHandler<{ query: typeof dto.ListBooks }> = async (req, res) => { @@ -22,38 +26,34 @@ export const index: ApiHandler<{ query: typeof dto.ListBooks }> = async (req, re }; index.query = dto.ListBooks; -// export const show: ApiHandler = async (req, res) => { const book = await Book.find(req.params.id); if (!book) { - return void sendProblem(res, 404, { detail: 'Book not found' }); + return void sendProblem(res, 404, { type: BOOK_NOT_FOUND, detail: 'Book not found' }); } res.json(dto.serialize(book)); }; -// export const create: ApiHandler<{ body: typeof dto.CreateBook }> = async (req, res) => { const book = await Book.create(req.body); res.status(201).json(dto.serialize(book)); }; create.body = dto.CreateBook; -// export const update: ApiHandler<{ body: typeof dto.UpdateBook }> = async (req, res) => { const book = await Book.find(req.params.id); if (!book) { - return void sendProblem(res, 404, { detail: 'Book not found' }); + return void sendProblem(res, 404, { type: BOOK_NOT_FOUND, detail: 'Book not found' }); } await book.update(req.body); res.json(dto.serialize(book)); }; update.body = dto.UpdateBook; -// export const destroy: ApiHandler = async (req, res) => { const book = await Book.find(req.params.id); if (!book) { - return void sendProblem(res, 404, { detail: 'Book not found' }); + return void sendProblem(res, 404, { type: BOOK_NOT_FOUND, detail: 'Book not found' }); } await book.delete(); res.status(204).end(); From b679addf67991ff4a8b2c31667273e6e5c8846c6 Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Mon, 7 Sep 2026 17:23:18 +0200 Subject: [PATCH 28/80] fix(cli): accepter les seeds TypeScript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `igo db seed` filtrait sur `.js` : dans un projet TS, le dossier seeds/ était traité comme vide, sans erreur. Un module TS exporte via `default`, d'où le repli. Co-Authored-By: Claude Opus 5 (1M context) --- packages/server/cli/db.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/server/cli/db.js b/packages/server/cli/db.js index af1725d9..f9e65c6e 100644 --- a/packages/server/cli/db.js +++ b/packages/server/cli/db.js @@ -128,7 +128,7 @@ const verbs = { } const files = fs.readdirSync(seedsDir) - .filter(f => f.match(/^\d+.*\.js$/)) + .filter(f => f.match(/^\d+.*\.(js|ts)$/)) .sort(); if (!files.length) { @@ -139,7 +139,7 @@ const verbs = { await cache.init(); for (const file of files) { const seed = require(path.join(seedsDir, file)); - await seed(); + await (seed.default || seed)(); console.log(` ${green('✔')} ${file}`); } }, From 1dddeb49e691d9a2856ca4228f46b936cce7b238 Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Tue, 8 Sep 2026 09:15:49 +0200 Subject: [PATCH 29/80] feat(skel): ajouter migrate et seed aux squelettes API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le squelette api n'avait aucun moyen documenté de créer ses tables, et seul fullstack exposait migrate — à sa racine, pas dans api/. Le seed importe les modèles TypeScript, d'où le loader tsx. Co-Authored-By: Claude Opus 5 (1M context) --- packages/server/skel/api/CLAUDE.md | 3 +++ packages/server/skel/api/README.md | 3 +++ packages/server/skel/api/package.json | 2 ++ packages/server/skel/api/seeds/001-books.ts | 9 +++++++++ packages/server/skel/api/tsconfig.json | 2 +- packages/server/skel/fullstack/api/package.json | 2 ++ packages/server/skel/fullstack/api/seeds/001-books.ts | 9 +++++++++ packages/server/skel/fullstack/api/tsconfig.json | 2 +- packages/server/test/CreateTest.js | 11 +++++++++++ 9 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 packages/server/skel/api/seeds/001-books.ts create mode 100644 packages/server/skel/fullstack/api/seeds/001-books.ts diff --git a/packages/server/skel/api/CLAUDE.md b/packages/server/skel/api/CLAUDE.md index 58d42630..b02c9e74 100644 --- a/packages/server/skel/api/CLAUDE.md +++ b/packages/server/skel/api/CLAUDE.md @@ -6,6 +6,8 @@ API JSON sur [igo](https://github.com/igocreate/igo). TypeScript, Node 24, pnpm. ```bash pnpm start # tsx watch +pnpm migrate # migrations SQL +pnpm seed # données de dev pnpm test # mocha — vraie base, isolée par transaction pnpm lint # oxlint pnpm format # oxfmt @@ -30,6 +32,7 @@ app/ config.ts surcharge de la config igo routes.ts montage sql/ migrations, une par fichier daté +seeds/ données de dev, jouées à la demande test/ miroir de app/ ``` diff --git a/packages/server/skel/api/README.md b/packages/server/skel/api/README.md index 347ce80c..0e59550d 100644 --- a/packages/server/skel/api/README.md +++ b/packages/server/skel/api/README.md @@ -11,6 +11,8 @@ ceux déjà installés. pnpm install docker compose up -d pnpm start # tsx watch, rechargement à chaud +pnpm migrate # joue les migrations SQL +pnpm seed # remplit la base avec des données de dev pnpm test # mocha via tsx, base de test recréée à chaque run pnpm lint # oxlint pnpm format # oxfmt @@ -33,6 +35,7 @@ app/ config.ts routes.ts ← montage des routes sql/ ← migrations +seeds/ ← données de dev, jouées à la demande ``` Une feature regroupe tout son domaine, modèle compris. Ce qui devient diff --git a/packages/server/skel/api/package.json b/packages/server/skel/api/package.json index 46cb985a..df35dc63 100644 --- a/packages/server/skel/api/package.json +++ b/packages/server/skel/api/package.json @@ -9,6 +9,8 @@ "build": "tsc && cp -R sql locales dist/", "start": "tsx watch app.ts", "serve": "cd dist && node app.js", + "migrate": "igo db migrate", + "seed": "NODE_OPTIONS=\"--import tsx\" igo db seed", "lint": "oxlint", "test": "mocha", "typecheck": "tsc --noEmit", diff --git a/packages/server/skel/api/seeds/001-books.ts b/packages/server/skel/api/seeds/001-books.ts new file mode 100644 index 00000000..17b990ec --- /dev/null +++ b/packages/server/skel/api/seeds/001-books.ts @@ -0,0 +1,9 @@ +import Book from '../app/features/books/Book'; + +// Seeds give a fresh checkout something to look at. Data the application needs +// to run belongs in a migration — this runs only outside production, and only +// when someone asks for it. +export default async () => { + await Book.create({ title: 'Dune', author: 'Frank Herbert', pages: 412 }); + await Book.create({ title: 'Neuromancer', author: 'William Gibson', pages: 271 }); +}; diff --git a/packages/server/skel/api/tsconfig.json b/packages/server/skel/api/tsconfig.json index 4ebd94ff..804a90b5 100644 --- a/packages/server/skel/api/tsconfig.json +++ b/packages/server/skel/api/tsconfig.json @@ -12,6 +12,6 @@ "skipLibCheck": true, "types": ["node", "mocha"] }, - "include": ["app.ts", "app/**/*.ts", "test/**/*.ts"], + "include": ["app.ts", "app/**/*.ts", "seeds/**/*.ts", "test/**/*.ts"], "exclude": ["node_modules", "dist"] } diff --git a/packages/server/skel/fullstack/api/package.json b/packages/server/skel/fullstack/api/package.json index b8190b16..49d55a79 100644 --- a/packages/server/skel/fullstack/api/package.json +++ b/packages/server/skel/fullstack/api/package.json @@ -9,6 +9,8 @@ "build": "tsc && cp -R sql locales dist/", "start": "tsx watch app.ts", "serve": "cd dist && node app.js", + "migrate": "igo db migrate", + "seed": "NODE_OPTIONS=\"--import tsx\" igo db seed", "lint": "oxlint", "test": "mocha", "typecheck": "tsc --noEmit", diff --git a/packages/server/skel/fullstack/api/seeds/001-books.ts b/packages/server/skel/fullstack/api/seeds/001-books.ts new file mode 100644 index 00000000..17b990ec --- /dev/null +++ b/packages/server/skel/fullstack/api/seeds/001-books.ts @@ -0,0 +1,9 @@ +import Book from '../app/features/books/Book'; + +// Seeds give a fresh checkout something to look at. Data the application needs +// to run belongs in a migration — this runs only outside production, and only +// when someone asks for it. +export default async () => { + await Book.create({ title: 'Dune', author: 'Frank Herbert', pages: 412 }); + await Book.create({ title: 'Neuromancer', author: 'William Gibson', pages: 271 }); +}; diff --git a/packages/server/skel/fullstack/api/tsconfig.json b/packages/server/skel/fullstack/api/tsconfig.json index 4ebd94ff..804a90b5 100644 --- a/packages/server/skel/fullstack/api/tsconfig.json +++ b/packages/server/skel/fullstack/api/tsconfig.json @@ -12,6 +12,6 @@ "skipLibCheck": true, "types": ["node", "mocha"] }, - "include": ["app.ts", "app/**/*.ts", "test/**/*.ts"], + "include": ["app.ts", "app/**/*.ts", "seeds/**/*.ts", "test/**/*.ts"], "exclude": ["node_modules", "dist"] } diff --git a/packages/server/test/CreateTest.js b/packages/server/test/CreateTest.js index fa20982c..d0ff19fa 100644 --- a/packages/server/test/CreateTest.js +++ b/packages/server/test/CreateTest.js @@ -54,4 +54,15 @@ describe('cli/create', function() { assert(controller.includes('create.body = dto.CreateBook'), 'schema is attached to the handler'); }); + + it('should ship migrations and seeds in the api skeletons', async () => { + await create({ _: ['create', 'myapi'], skel: 'api' }); + + const pkg = JSON.parse(fs.readFileSync(path.join(tmp, 'myapi', 'package.json'), 'utf8')); + assert(pkg.scripts.migrate, 'migrate script'); + // the seeds import the TS models, so the CLI needs the tsx loader + assert(pkg.scripts.seed.includes('tsx'), `seed runs under tsx: ${pkg.scripts.seed}`); + + assert(fs.existsSync(path.join(tmp, 'myapi', 'seeds', '001-books.ts'))); + }); }); From cb54ea1bbf7ebc060fc4126fabe5a9f83a3324ac Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Tue, 8 Sep 2026 09:16:11 +0200 Subject: [PATCH 30/80] =?UTF-8?q?test(skel):=20v=C3=A9rifier=20que=20le=20?= =?UTF-8?q?contr=C3=B4leur=20d'exemple=20type=20ses=20erreurs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- packages/server/test/CreateTest.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/server/test/CreateTest.js b/packages/server/test/CreateTest.js index d0ff19fa..440aa02e 100644 --- a/packages/server/test/CreateTest.js +++ b/packages/server/test/CreateTest.js @@ -53,6 +53,8 @@ describe('cli/create', function() { path.join(tmp, 'myapi', 'app', 'features', 'books', 'books.controller.ts'), 'utf8'); assert(controller.includes('create.body = dto.CreateBook'), 'schema is attached to the handler'); + assert(controller.includes('\'/problems/book-not-found\''), + 'business errors carry their own problem type'); }); it('should ship migrations and seeds in the api skeletons', async () => { From cb48f4a97584e10e921a65677b51412d7200398e Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Tue, 8 Sep 2026 09:16:23 +0200 Subject: [PATCH 31/80] chore(skel): retirer l'exemple de conf nginx du squelette fullstack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une conf de déploiement n'est pas de l'applicatif : elle dépend de l'infra du projet et se périme sans que personne ne s'en aperçoive. Co-Authored-By: Claude Opus 5 (1M context) --- .../skel/fullstack/deploy/nginx.conf.example | 44 ------------------- 1 file changed, 44 deletions(-) delete mode 100644 packages/server/skel/fullstack/deploy/nginx.conf.example diff --git a/packages/server/skel/fullstack/deploy/nginx.conf.example b/packages/server/skel/fullstack/deploy/nginx.conf.example deleted file mode 100644 index 32418bd9..00000000 --- a/packages/server/skel/fullstack/deploy/nginx.conf.example +++ /dev/null @@ -1,44 +0,0 @@ -# Serving {project.name}: nginx serves the built front, Node serves the API. -# -# Copy, adjust the paths and the server_name, and drop into sites-available. - -upstream app { - server 127.0.0.1:3000; - keepalive 16; -} - -server { - listen 80; - server_name example.com; - - root /var/www/{project.name}/front/dist; - - # The API is the Node process. Everything else is a static file. - location /api/ { - proxy_pass http://app; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - # igo reuses an inbound request id, which ties nginx and app logs together - proxy_set_header X-Request-Id $request_id; - } - - # Hashed filenames: safe to cache forever. - location /assets/ { - expires max; - add_header Cache-Control "public, immutable"; - } - - # index.html must NOT be cached: it is the file that names the hashed assets. - # Cached, a returning user keeps pointing at assets that no longer exist. - location = /index.html { - add_header Cache-Control "no-cache"; - } - - # Client-side routing: an unknown path is a route, not a missing file. - location / { - try_files $uri $uri/ /index.html; - } -} From d423c706ab7d60bcff2563558868dda1f0552323 Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Tue, 8 Sep 2026 09:16:36 +0200 Subject: [PATCH 32/80] feat(skel): faire de e2e un paquet pnpm, avec page objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit e2e n'était qu'un dossier : ni linté ni typechecké, puisque `pnpm -r` ne visitait que api et front. Il porte maintenant son package.json, son tsconfig et sa config Playwright. Son script s'appelle test:e2e et non test, pour que `pnpm test` reste rapide et n'entraîne pas les E2E. Les tests passent par des page objects — locators et actions seulement, les assertions restent dans le fichier de test. Co-Authored-By: Claude Opus 5 (1M context) --- .../skel/fullstack/_.github/workflows/ci.yml | 4 +- packages/server/skel/fullstack/e2e/CLAUDE.md | 77 +++++++++++++++++++ .../server/skel/fullstack/e2e/books.spec.ts | 28 ++++--- .../server/skel/fullstack/e2e/package.json | 16 ++++ .../skel/fullstack/e2e/pages/books.page.ts | 41 ++++++++++ .../fullstack/{ => e2e}/playwright.config.ts | 6 +- .../server/skel/fullstack/e2e/tsconfig.json | 12 +++ packages/server/skel/fullstack/package.json | 4 +- .../server/skel/fullstack/pnpm-workspace.yaml | 1 + 9 files changed, 170 insertions(+), 19 deletions(-) create mode 100644 packages/server/skel/fullstack/e2e/CLAUDE.md create mode 100644 packages/server/skel/fullstack/e2e/package.json create mode 100644 packages/server/skel/fullstack/e2e/pages/books.page.ts rename packages/server/skel/fullstack/{ => e2e}/playwright.config.ts (87%) create mode 100644 packages/server/skel/fullstack/e2e/tsconfig.json diff --git a/packages/server/skel/fullstack/_.github/workflows/ci.yml b/packages/server/skel/fullstack/_.github/workflows/ci.yml index 23aaad29..0188be84 100644 --- a/packages/server/skel/fullstack/_.github/workflows/ci.yml +++ b/packages/server/skel/fullstack/_.github/workflows/ci.yml @@ -113,7 +113,7 @@ jobs: - run: pnpm build - name: Install Playwright browsers - run: pnpm exec playwright install --with-deps chromium + run: pnpm --filter ./e2e exec playwright install --with-deps chromium # playwright.config.ts starts the API and the front, and waits for both - run: pnpm test:e2e @@ -124,5 +124,5 @@ jobs: if: ${{ !cancelled() }} with: name: playwright-report - path: playwright-report/ + path: e2e/playwright-report/ retention-days: 14 diff --git a/packages/server/skel/fullstack/e2e/CLAUDE.md b/packages/server/skel/fullstack/e2e/CLAUDE.md new file mode 100644 index 00000000..425e0bae --- /dev/null +++ b/packages/server/skel/fullstack/e2e/CLAUDE.md @@ -0,0 +1,77 @@ +# {project.name} — e2e + +Parcours Playwright contre le build, API et base réelles. + +## Commandes + +```bash +pnpm test:e2e # depuis la racine +pnpm --filter ./e2e exec playwright test --ui # mode interactif +pnpm --filter ./e2e exec playwright test --debug # pas à pas +``` + +`playwright.config.ts` démarre l'API et le front lui-même, et les arrête après. +Rien à lancer à la main. En CI c'est le build qui est testé — ce qui est +déployé ; en local, `tsx watch` évite de rebuilder à chaque exécution. + +## Ce qui a sa place ici + +Un E2E vérifie le **câblage** : navigateur, build du front, proxy, API, base. +Il est lent et il casse pour des raisons qui n'ont rien à voir avec ce qu'il +teste, donc il reste rare. + +Tout ce qui peut être couvert plus bas doit l'être plus bas : + +| Question | Où | +|---|---| +| ce contrôleur renvoie-t-il le bon JSON ? | `api/test/` | +| ce composant affiche-t-il l'erreur ? | `front/src/**/*.test.tsx` | +| l'ensemble tient-il debout ? | ici | + +Un chemin critique — le parcours qui fait perdre de l'argent s'il casse — +mérite un E2E. Une variante d'affichage, non. + +## Page Objects + +**Un POM expose des locators et les actions qui y mènent. Il ne porte aucune +assertion** : ce qui est correct appartient au test, et le même locator est +attendu présent dans un test, absent dans un autre. + +```ts +// pages/books.page.ts +export class BooksPage { + readonly heading: Locator; + constructor(private readonly page: Page) { + this.heading = page.getByRole('heading', { name: 'Books' }); + } + async goto() { await this.page.goto('/'); } +} + +// books.spec.ts +await expect(books.heading).toBeVisible(); +``` + +Un POM par page ou par écran, dans `pages/`. + +## Sélecteurs + +Dans l'ordre de préférence : `getByRole`, `getByLabel`, `getByText`. Ce sont +ceux qu'un utilisateur — et un lecteur d'écran — perçoit ; ils cassent quand le +comportement change, pas quand une classe CSS bouge. + +`data-testid` en dernier recours, quand rien d'accessible n'identifie l'élément. +Jamais de sélecteur CSS ou XPath structurel. + +## Attentes + +Pas de `waitForTimeout`. Les assertions `expect(locator)` réessaient toutes +seules : `await expect(x).toBeVisible()` attend déjà. + +## Données + +La base est partagée entre les tests, et ils tournent en parallèle. Un test qui +crée une donnée lui donne un nom qui lui appartient (`Dune ${Date.now()}`) +plutôt que de compter sur un état de départ. + +Un projet qui a besoin d'un jeu de données dédié le pose lui-même — il n'y a +pas de seed E2E ici. diff --git a/packages/server/skel/fullstack/e2e/books.spec.ts b/packages/server/skel/fullstack/e2e/books.spec.ts index 60f63475..d30d41e7 100644 --- a/packages/server/skel/fullstack/e2e/books.spec.ts +++ b/packages/server/skel/fullstack/e2e/books.spec.ts @@ -1,33 +1,37 @@ import { expect, test } from '@playwright/test'; +import { BooksPage } from './pages/books.page'; + // E2E covers the wiring end to end — browser, front build, proxy, API, database. // Everything below that is already covered faster by the front and back tests, // so this file stays short on purpose. test.describe('books', () => { test('should list the books served by the API', async ({ page }) => { - await page.goto('/'); + const books = new BooksPage(page); + await books.goto(); - await expect(page.getByRole('heading', { name: 'Books' })).toBeVisible(); - await expect(page.getByText(/loading/i)).toBeHidden(); + await expect(books.heading).toBeVisible(); + await expect(books.loading).toBeHidden(); + await expect(books.total).toBeVisible(); }); test('should add a book and show it in the list', async ({ page }) => { - await page.goto('/'); + const books = new BooksPage(page); + await books.goto(); + // the database is shared with the other tests, so the title has to be ours const title = `Dune ${Date.now()}`; - await page.getByLabel('title').fill(title); - await page.getByLabel('author').fill('Frank Herbert'); - await page.getByLabel('pages').fill('412'); - await page.getByRole('button', { name: /add book/i }).click(); + await books.addBook({ title, author: 'Frank Herbert', pages: '412' }); - await expect(page.getByText(title)).toBeVisible(); + await expect(books.bookNamed(title)).toBeVisible(); }); test('should show the validation errors the server returns', async ({ page }) => { - await page.goto('/'); + const books = new BooksPage(page); + await books.goto(); - await page.getByRole('button', { name: /add book/i }).click(); + await books.submit.click(); - await expect(page.getByRole('alert').first()).toBeVisible(); + await expect(books.errors.first()).toBeVisible(); }); }); diff --git a/packages/server/skel/fullstack/e2e/package.json b/packages/server/skel/fullstack/e2e/package.json new file mode 100644 index 00000000..8716de26 --- /dev/null +++ b/packages/server/skel/fullstack/e2e/package.json @@ -0,0 +1,16 @@ +{ + "name": "{project.name}-e2e", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "test:e2e": "playwright test", + "lint": "oxlint", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@playwright/test": "^1.63.0", + "@types/node": "^24.0.0", + "typescript": "^7.0.0" + } +} diff --git a/packages/server/skel/fullstack/e2e/pages/books.page.ts b/packages/server/skel/fullstack/e2e/pages/books.page.ts new file mode 100644 index 00000000..23617b0d --- /dev/null +++ b/packages/server/skel/fullstack/e2e/pages/books.page.ts @@ -0,0 +1,41 @@ +import type { Locator, Page } from '@playwright/test'; + +// A page object exposes locators and the actions that reach them. It holds no +// assertion: what counts as correct belongs to the test, so the same locator +// can be expected present in one test and absent in another. +export class BooksPage { + readonly heading: Locator; + readonly loading: Locator; + readonly total: Locator; + readonly title: Locator; + readonly author: Locator; + readonly pages: Locator; + readonly submit: Locator; + readonly errors: Locator; + + constructor(private readonly page: Page) { + this.heading = page.getByRole('heading', { name: 'Books' }); + this.loading = page.getByText(/loading/i); + this.total = page.getByText(/in total/); + this.title = page.getByLabel('title'); + this.author = page.getByLabel('author'); + this.pages = page.getByLabel('pages'); + this.submit = page.getByRole('button', { name: /add book/i }); + this.errors = page.getByRole('alert'); + } + + async goto() { + await this.page.goto('/'); + } + + bookNamed(title: string): Locator { + return this.page.getByText(title); + } + + async addBook({ title, author, pages }: { title: string; author: string; pages: string }) { + await this.title.fill(title); + await this.author.fill(author); + await this.pages.fill(pages); + await this.submit.click(); + } +} diff --git a/packages/server/skel/fullstack/playwright.config.ts b/packages/server/skel/fullstack/e2e/playwright.config.ts similarity index 87% rename from packages/server/skel/fullstack/playwright.config.ts rename to packages/server/skel/fullstack/e2e/playwright.config.ts index 7f5d85d8..f3156c23 100644 --- a/packages/server/skel/fullstack/playwright.config.ts +++ b/packages/server/skel/fullstack/e2e/playwright.config.ts @@ -5,7 +5,7 @@ const BASE_URL = process.env.E2E_BASE_URL ?? `http://127.0.0.1:${PORT}`; const API_URL = process.env.API_URL ?? 'http://127.0.0.1:3000'; export default defineConfig({ - testDir: './e2e', + testDir: '.', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, @@ -28,7 +28,7 @@ export default defineConfig({ { // in CI the build is what ships, so that is what gets tested; // locally, tsx watch avoids a rebuild on every run - command: process.env.CI ? 'pnpm --filter ./api serve' : 'pnpm --filter ./api start', + command: process.env.CI ? 'pnpm --filter ../api serve' : 'pnpm --filter ../api start', // polled to know the API is up: any route it answers 2xx on will do url: `${API_URL}/api/books`, reuseExistingServer: !process.env.CI, @@ -37,7 +37,7 @@ export default defineConfig({ { // --host binds 127.0.0.1 too: vite listens on localhost (IPv6) by // default, which the url below would never reach. - command: `pnpm --filter ./front exec vite preview --port ${PORT} --strictPort --host 127.0.0.1`, + command: `pnpm --filter ../front exec vite preview --port ${PORT} --strictPort --host 127.0.0.1`, url: BASE_URL, reuseExistingServer: !process.env.CI, timeout: 60_000, diff --git a/packages/server/skel/fullstack/e2e/tsconfig.json b/packages/server/skel/fullstack/e2e/tsconfig.json new file mode 100644 index 00000000..37963f28 --- /dev/null +++ b/packages/server/skel/fullstack/e2e/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "preserve", + "moduleDetection": "force", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/packages/server/skel/fullstack/package.json b/packages/server/skel/fullstack/package.json index b8a5cff1..493433fd 100644 --- a/packages/server/skel/fullstack/package.json +++ b/packages/server/skel/fullstack/package.json @@ -9,8 +9,9 @@ "lint": "pnpm -r lint", "typecheck": "pnpm -r typecheck", "test": "pnpm -r test", - "test:e2e": "playwright test", + "test:e2e": "pnpm --filter ./e2e test:e2e", "migrate": "pnpm --filter ./api exec igo db migrate", + "seed": "pnpm --filter ./api seed", "prepare": "husky", "format": "oxfmt", "format:check": "oxfmt --check" @@ -18,7 +19,6 @@ "devDependencies": { "@commitlint/cli": "^21.2.0", "@commitlint/config-conventional": "^21.2.0", - "@playwright/test": "^1.63.0", "concurrently": "^10.0.0", "husky": "^9.1.7", "lint-staged": "^17.5.0", diff --git a/packages/server/skel/fullstack/pnpm-workspace.yaml b/packages/server/skel/fullstack/pnpm-workspace.yaml index b41c055d..d77460d0 100644 --- a/packages/server/skel/fullstack/pnpm-workspace.yaml +++ b/packages/server/skel/fullstack/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - api - front + - e2e # pnpm blocks postinstall scripts unless a package is listed here. These # compile or download a native binary, which they need to run at all. From a10d5c275308093d7ee8ba859e96406bcfc6f200 Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Tue, 8 Sep 2026 09:16:46 +0200 Subject: [PATCH 33/80] =?UTF-8?q?docs(skel):=20r=C3=A9partir=20les=20conve?= =?UTF-8?q?ntions=20entre=20la=20racine=20et=20les=20paquets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fullstack/front/CLAUDE.md manquait, alors que trois endroits y renvoyaient. Les règles de commit dupliquées dans api/CLAUDE.md retournent à la racine, où vivent les hooks. Claude charge la racine et le paquet courant ensemble : l'enfant précise, il ne répète pas. Co-Authored-By: Claude Opus 5 (1M context) --- packages/server/skel/front/CLAUDE.md | 2 +- packages/server/skel/fullstack/CLAUDE.md | 20 +++-- packages/server/skel/fullstack/README.md | 42 ++++----- packages/server/skel/fullstack/api/CLAUDE.md | 20 ++--- .../server/skel/fullstack/front/CLAUDE.md | 86 +++++++++++++++++++ 5 files changed, 124 insertions(+), 46 deletions(-) create mode 100644 packages/server/skel/fullstack/front/CLAUDE.md diff --git a/packages/server/skel/front/CLAUDE.md b/packages/server/skel/front/CLAUDE.md index 12e9fb7f..eda17422 100644 --- a/packages/server/skel/front/CLAUDE.md +++ b/packages/server/skel/front/CLAUDE.md @@ -23,7 +23,7 @@ src/ main.tsx point d'entrée, providers routes.tsx arbre de routes, lazy par feature components/ - ui/ composants copiés (shadcn) — purs + ui/ composants copiés (shadcn) — purs, à créer layout/ coquille de page features// pages/ composants de route — PEUVENT fetch diff --git a/packages/server/skel/fullstack/CLAUDE.md b/packages/server/skel/fullstack/CLAUDE.md index 3b6fb22f..e8ae089c 100644 --- a/packages/server/skel/fullstack/CLAUDE.md +++ b/packages/server/skel/fullstack/CLAUDE.md @@ -2,20 +2,21 @@ API JSON igo + SPA React dans un seul dépôt. TypeScript, Node 24, pnpm. -**Les conventions de code sont dans `api/CLAUDE.md` et `front/CLAUDE.md`.** Ce -fichier ne couvre que ce qui concerne les deux. +Trois paquets pnpm : `api`, `front` et `e2e`. Ce fichier ne couvre que ce qui +les concerne tous ; chacun a son propre `CLAUDE.md`, qui précise sans répéter. ## Commandes ```bash pnpm dev # api :3000 + front :5173 pnpm build # api/dist + front/dist -pnpm lint # oxlint sur les deux -pnpm format # oxfmt sur les deux -pnpm typecheck -pnpm test # tests back et front +pnpm lint # oxlint sur les trois paquets +pnpm format # oxfmt sur tout le dépôt +pnpm typecheck # tsc sur les trois paquets +pnpm test # api et front — rapide, pas les E2E pnpm test:e2e # Playwright contre le build pnpm migrate +pnpm seed ``` Une commande ciblée passe par un filtre : `pnpm --filter ./api test`. @@ -42,8 +43,8 @@ dérivent, ce sont les tests de feature qui le montrent. Les deux côtés se répondent : ``` -api/app/api// routes, controller, dto -front/src/features// api.ts, types.ts, pages, sections, components +api/app/features// routes, controller, dto, modèle +front/src/features// api.ts, types.ts, pages, sections, components ``` Le back sert d'abord — le front consomme un contrat qui existe. @@ -57,7 +58,8 @@ Le back sert d'abord — le front consomme un contrat qui existe. | E2E | `e2e/` | chemins critiques seulement | Les E2E tournent contre le **build**, pas le serveur de développement. Ils sont -lents : tout ce qui peut être couvert plus bas doit l'être plus bas. +lents : tout ce qui peut être couvert plus bas doit l'être plus bas. `pnpm test` +ne les lance pas — `pnpm test:e2e` est une commande à part. ## Commits diff --git a/packages/server/skel/fullstack/README.md b/packages/server/skel/fullstack/README.md index 8fc97f48..c796eda4 100644 --- a/packages/server/skel/fullstack/README.md +++ b/packages/server/skel/fullstack/README.md @@ -9,6 +9,7 @@ dépôt. TypeScript, Node 24, pnpm. pnpm install docker compose up -d # MySQL + Valkey pnpm migrate # crée les tables +pnpm seed # données de dev pnpm dev # api sur :3000, front sur :5173 ``` @@ -20,14 +21,15 @@ CORS**. ## Commandes ```bash -pnpm dev # les deux en parallèle +pnpm dev # api et front en parallèle pnpm build # api/dist et front/dist -pnpm lint # oxlint sur les deux -pnpm format # oxfmt sur les deux -pnpm typecheck # tsc sur les deux -pnpm test # tests back et front +pnpm lint # oxlint sur les trois paquets +pnpm format # oxfmt sur tout le dépôt +pnpm typecheck # tsc sur les trois paquets +pnpm test # api et front — rapide, pas les E2E pnpm test:e2e # Playwright contre le build pnpm migrate # migrations SQL +pnpm seed # données de dev ``` ## Structure @@ -35,17 +37,23 @@ pnpm migrate # migrations SQL ``` api/ API igo — voir api/CLAUDE.md front/ SPA React — voir front/CLAUDE.md -e2e/ parcours Playwright +e2e/ parcours Playwright — voir e2e/CLAUDE.md ``` -Deux paquets pnpm dans un dépôt : **un commit porte un front et un back +Trois paquets pnpm dans un dépôt : **un commit porte un front et un back cohérents**, et le déploiement livre un seul artefact. +`e2e` est un paquet comme les deux autres — il est linté et typechecké avec +eux, et porte sa propre configuration Playwright. Il n'est pas rattaché au +front : ses tests démarrent l'API *et* le front, et traversent les deux. + +Une commande ciblée passe par un filtre : `pnpm --filter ./api test`. + ## Les tests | Niveau | Où | Ce qu'il couvre | | ------------------ | ------------------------- | ----------------------------------- | -| Intégration back | `api/test/` | route → contrôleur → DTO → base | +| Intégration back | `api/test/` | route → contrôleur → DTO → base | | Composant, feature | `front/src/**/*.test.tsx` | rendu, API simulée par MSW | | E2E | `e2e/` | le câblage complet, navigateur réel | @@ -53,24 +61,10 @@ Les E2E tournent contre le **build** du front, pas le serveur de développement c'est ce qui est déployé. Ils restent peu nombreux : tout ce qui peut être couvert plus bas doit l'être. -## Déploiement - -Un seul artefact. Le build produit `api/dist` et `front/dist`. - -**nginx sert les statiques**, pas igo — `front/dist` va dans le répertoire servi -par nginx, et `/api` est passé au process Node. Deux réglages à ne pas -découvrir en production : - -- une `location = /index.html` **sans `expires max`** : sinon l'utilisateur - garde un fichier qui référence des assets disparus ; -- un `try_files` qui retombe sur `index.html`, sans quoi le routage client - renvoie des 404 sur rechargement. - -Voir `deploy/nginx.conf.example`. - ## Conventions [Conventional Commits](https://www.conventionalcommits.org), vérifiés par un hook. Le pre-commit passe oxlint sur les fichiers indexés. -Les conventions de code sont dans `api/CLAUDE.md` et `front/CLAUDE.md`. +Les conventions de code sont dans `api/CLAUDE.md`, `front/CLAUDE.md` et +`e2e/CLAUDE.md`. diff --git a/packages/server/skel/fullstack/api/CLAUDE.md b/packages/server/skel/fullstack/api/CLAUDE.md index 58d42630..68dc91f9 100644 --- a/packages/server/skel/fullstack/api/CLAUDE.md +++ b/packages/server/skel/fullstack/api/CLAUDE.md @@ -1,11 +1,13 @@ -# {project.name} +# {project.name} — api -API JSON sur [igo](https://github.com/igocreate/igo). TypeScript, Node 24, pnpm. +API JSON sur [igo](https://github.com/igocreate/igo). TypeScript, Node 24. ## Commandes ```bash pnpm start # tsx watch +pnpm migrate # migrations SQL +pnpm seed # données de dev pnpm test # mocha — vraie base, isolée par transaction pnpm lint # oxlint pnpm format # oxfmt @@ -13,8 +15,9 @@ pnpm typecheck # tsc --noEmit pnpm build # -> dist/ ``` -Les tests ont besoin de MySQL et Valkey — `docker compose up -d` les lance. La -base de test est recréée et migrée à chaque exécution. +Depuis la racine : `pnpm --filter ./api test`. MySQL et Valkey doivent tourner +(`docker compose up -d`) ; la base de test est recréée et migrée à chaque +exécution. ## Structure @@ -30,6 +33,7 @@ app/ config.ts surcharge de la config igo routes.ts montage sql/ migrations, une par fichier daté +seeds/ données de dev, jouées à la demande test/ miroir de app/ ``` @@ -79,14 +83,6 @@ route est protégée. Les tests passent par `dev.agent` contre la vraie base. Les mocks ne servent que pour les dépendances externes — API tierces, SMTP. -## Commits - -[Conventional Commits](https://www.conventionalcommits.org), vérifié par un hook : -`feat:`, `fix:`, `chore:`, `refactor:`, `docs:`, `test:`, `ci:`. Le scope entre -parenthèses quand il aide — `fix(books): …`. - -Le hook de pre-commit passe oxlint sur les fichiers indexés. - ## Documentation - [Routes et API JSON](https://igocreate.github.io/igo/server/api) diff --git a/packages/server/skel/fullstack/front/CLAUDE.md b/packages/server/skel/fullstack/front/CLAUDE.md new file mode 100644 index 00000000..ac1dae48 --- /dev/null +++ b/packages/server/skel/fullstack/front/CLAUDE.md @@ -0,0 +1,86 @@ +# {project.name} — front + +SPA React consommant l'API JSON d'igo. Vite, TypeScript, Node 24. + +## Commandes + +```bash +pnpm dev # http://localhost:5173, proxy /api vers le back +pnpm test # vitest + testing library + msw +pnpm lint # oxlint +pnpm format # oxfmt +pnpm typecheck # tsc --noEmit +pnpm build # -> dist/ +``` + +`pnpm dev` à la racine lance le back et le front ensemble — c'est la façon +normale de travailler. Le proxy `/api` vise `http://127.0.0.1:3000`. + +## Structure + +``` +src/ + main.tsx point d'entrée, providers + routes.tsx arbre de routes, lazy par feature + components/ + ui/ composants copiés (shadcn) — purs, à créer + layout/ coquille de page + features// + pages/ composants de route — PEUVENT fetch + sections/ blocs autonomes — PEUVENT fetch + components/ affichage — PURS, props only + api.ts queries et mutations TanStack Query + types.ts types de la feature + lib/ + api-client.ts wrapper fetch typé, erreurs RFC 9457 + test/ handlers MSW, helper de rendu +``` + +## Conventions + +**Seuls `pages/` et `sections/` appellent `useQuery` ou `useMutation`.** Tout le +reste reçoit ses données par props. Un bloc retirable sans casser ses voisins +possède ses données — c'est une section ; un bloc réutilisé ailleurs est un +composant pur. + +Ça se vérifie : + +```bash +grep -r "useQuery\|useMutation" src/components/ # doit être vide +grep -r "useQuery\|useMutation" src/features/*/components/ # doit être vide +``` + +**Les URL d'API sont relatives** (`/api/…`). Jamais de base URL absolue : c'est +ce qui permet au même build de tourner sur tous les environnements. + +**L'état serveur appartient à TanStack Query**, pas à un `useState` synchronisé +par `useEffect`. L'état purement client passe par React context tant qu'il reste +léger. + +**Les états loading et error sont explicites** dans les pages et sections. Pas +de composant qui suppose que les données sont là. + +**Le serveur est l'autorité sur la validation.** La validation côté client est +un confort ; les erreurs du serveur s'affichent telles quelles, par champ, via +`ApiError.fieldError(champ)`. + +**Les types de la feature reflètent le DTO du back.** Ils sont écrits à la main : +si les deux dérivent, ce sont les tests de feature qui le montrent. + +## Tests + +MSW intercepte au niveau réseau, donc le vrai `apiClient` tourne dans les tests. + +| Où | Niveau | Ce qu'on simule | +|---|---|---| +| `components/` | rendu avec props | rien | +| `sections/`, `pages/` | rendu avec providers | le réseau (MSW) | + +Un test de feature couvre le cas nominal, l'erreur serveur, et la validation. + +## Système de design + +Tailwind est installé, sans bibliothèque de composants. +[shadcn/ui](https://ui.shadcn.com) est la recommandation — ses composants se +copient dans `src/components/ui/` et deviennent du code du projet. C'est un +choix, pas une obligation. From 36beba07a011b76d99b8ab594a4e117f26f11f9e Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Tue, 8 Sep 2026 09:16:52 +0200 Subject: [PATCH 34/80] chore: ignorer dump.rdb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Valkey le dépose à la racine dès qu'il tourne depuis le dépôt. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e25a6b0c..658d7bc1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules coverage *.log +dump.rdb docs/.vitepress/cache docs/.vitepress/dist From 295974bcb0e8928122ec460e72816dbb1456235c Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Tue, 8 Sep 2026 09:20:37 +0200 Subject: [PATCH 35/80] =?UTF-8?q?docs(skel):=20mettre=20le=20ticket=20en?= =?UTF-8?q?=20t=C3=AAte=20du=20sujet=20de=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `git log --oneline` n'affiche que la première ligne : un ticket placé en pied de commit ne se voit pas quand on parcourt l'historique. Une consigne, pas une règle — commitlint reste inchangé, et un commit sans ticket passe. L'énumération des types dans api/CLAUDE.md disparaît au passage : elle paraphrasait la spec liée juste au-dessus. Co-Authored-By: Claude Opus 5 (1M context) --- packages/server/skel/api/CLAUDE.md | 9 +++++---- packages/server/skel/front/CLAUDE.md | 4 ++++ packages/server/skel/fullstack/CLAUDE.md | 4 ++++ packages/server/skel/fullstack/README.md | 3 +++ 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/server/skel/api/CLAUDE.md b/packages/server/skel/api/CLAUDE.md index b02c9e74..c3cfdfa8 100644 --- a/packages/server/skel/api/CLAUDE.md +++ b/packages/server/skel/api/CLAUDE.md @@ -84,12 +84,13 @@ pour les dépendances externes — API tierces, SMTP. ## Commits -[Conventional Commits](https://www.conventionalcommits.org), vérifié par un hook : -`feat:`, `fix:`, `chore:`, `refactor:`, `docs:`, `test:`, `ci:`. Le scope entre -parenthèses quand il aide — `fix(books): …`. - +[Conventional Commits](https://www.conventionalcommits.org), vérifié par un hook. Le hook de pre-commit passe oxlint sur les fichiers indexés. +Quand le travail est rattaché à un ticket, son identifiant ouvre le sujet : +`feat(books): [PROJ-123] ajouter la pagination`. L'historique se lit alors sans +ouvrir les commits. Le hook ne l'exige pas : sans ticket, on s'en passe. + ## Documentation - [Routes et API JSON](https://igocreate.github.io/igo/server/api) diff --git a/packages/server/skel/front/CLAUDE.md b/packages/server/skel/front/CLAUDE.md index eda17422..8c317ce9 100644 --- a/packages/server/skel/front/CLAUDE.md +++ b/packages/server/skel/front/CLAUDE.md @@ -83,6 +83,10 @@ Un test de feature couvre le cas nominal, l'erreur serveur, et la validation. [Conventional Commits](https://www.conventionalcommits.org), vérifié par un hook. Le hook de pre-commit passe oxlint sur les fichiers indexés. +Quand le travail est rattaché à un ticket, son identifiant ouvre le sujet : +`feat(books): [PROJ-123] paginer la liste`. L'historique se lit alors sans +ouvrir les commits. Le hook ne l'exige pas : sans ticket, on s'en passe. + ## Système de design Tailwind est installé, sans bibliothèque de composants. diff --git a/packages/server/skel/fullstack/CLAUDE.md b/packages/server/skel/fullstack/CLAUDE.md index e8ae089c..285a2b8b 100644 --- a/packages/server/skel/fullstack/CLAUDE.md +++ b/packages/server/skel/fullstack/CLAUDE.md @@ -66,5 +66,9 @@ ne les lance pas — `pnpm test:e2e` est une commande à part. [Conventional Commits](https://www.conventionalcommits.org), vérifiés par un hook. Le pre-commit passe oxlint sur les fichiers indexés. +Quand le travail est rattaché à un ticket, son identifiant ouvre le sujet : +`feat(books): [PROJ-123] ajouter la pagination`. L'historique se lit alors sans +ouvrir les commits. Le hook ne l'exige pas : sans ticket, on s'en passe. + Un commit qui touche les deux côtés est normal — c'est l'intérêt du dépôt unique : front et back restent cohérents par construction. diff --git a/packages/server/skel/fullstack/README.md b/packages/server/skel/fullstack/README.md index c796eda4..996bdb79 100644 --- a/packages/server/skel/fullstack/README.md +++ b/packages/server/skel/fullstack/README.md @@ -66,5 +66,8 @@ couvert plus bas doit l'être. [Conventional Commits](https://www.conventionalcommits.org), vérifiés par un hook. Le pre-commit passe oxlint sur les fichiers indexés. +Le ticket, quand il y en a un, ouvre le sujet : +`feat(books): [PROJ-123] ajouter la pagination`. + Les conventions de code sont dans `api/CLAUDE.md`, `front/CLAUDE.md` et `e2e/CLAUDE.md`. From 140012aed1bea043fc0d77b32a4620f4d4c8728a Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Tue, 8 Sep 2026 09:43:16 +0200 Subject: [PATCH 36/80] docs(server): corriger les exemples de configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le loader appelle `require(fichier).init(config)` : un `module.exports =` nu ne s'applique jamais, et le TypeError est avalé par le try/catch du loader — l'app démarre avec la configuration ignorée. Retire aussi la mention de `code`/`sqlState` sur les erreurs SQL, que le logger ne sérialise pas. Co-Authored-By: Claude Opus 5 (1M context) --- docs/guide/production.md | 2 +- docs/server/errors.md | 12 ++++++++---- docs/server/getting-started.md | 4 ++-- docs/server/logging.md | 9 +++++---- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/docs/guide/production.md b/docs/guide/production.md index 1fab76b3..f26afaef 100644 --- a/docs/guide/production.md +++ b/docs/guide/production.md @@ -23,7 +23,7 @@ An environment-specific config file `app/config-production.js` is loaded on top ```js // app/config-production.js -module.exports = (config) => { +module.exports.init = (config) => { config.cache.redis.db = 1; }; ``` diff --git a/docs/server/errors.md b/docs/server/errors.md index 8ba73511..7e1be1a9 100644 --- a/docs/server/errors.md +++ b/docs/server/errors.md @@ -26,7 +26,9 @@ Node gives no guarantee about the state of a process that reached this point, so ```js // app/config.js -config.exitOnUncaughtException = false; +module.exports.init = (config) => { + config.exitOnUncaughtException = false; +}; ``` The server then stays up **only** when the exception happened during a request that was already answered. An exception raised outside any request still exits, since nothing can vouch for the process state. @@ -58,9 +60,11 @@ Configure one or more recipients for error notification emails: ```js // app/config.js -config.mailcrashto = 'admin@example.com'; -// or, for several recipients: -config.mailcrashto = ['admin@example.com', 'ops@example.com']; +module.exports.init = (config) => { + config.mailcrashto = 'admin@example.com'; + // or, for several recipients: + config.mailcrashto = ['admin@example.com', 'ops@example.com']; +}; ``` The email includes: error message, stack trace, request context (method, URL, user-agent, body, session). diff --git a/docs/server/getting-started.md b/docs/server/getting-started.md index 305a5613..71db8414 100644 --- a/docs/server/getting-started.md +++ b/docs/server/getting-started.md @@ -72,11 +72,11 @@ Define routes in `app/routes.js`, controllers in `app/controllers/`, templates i ## Configuration -Configuration is loaded from several files, in order — see [Development › Configuration](../guide/development#configuration) for the full list. The minimum is an `app/config.js` that exports a function taking the config object: +Configuration is loaded from several files, in order — see [Development › Configuration](../guide/development#configuration) for the full list. The minimum is an `app/config.js` that exports an `init` function taking the config object: ```js // app/config.js -module.exports = (config) => { +module.exports.init = (config) => { config.httpport = process.env.PORT || 3000; config.mysql = { database: process.env.MYSQL_DATABASE }; config.redis = { socket: { host: process.env.REDIS_HOST || '127.0.0.1' } }; diff --git a/docs/server/logging.md b/docs/server/logging.md index 33d57cb9..bc6d5cea 100644 --- a/docs/server/logging.md +++ b/docs/server/logging.md @@ -28,7 +28,9 @@ match. ```js // app/config.js -config.logformat = 'json'; // or 'human' +module.exports.init = (config) => { + config.logformat = 'json'; // or 'human' +}; ``` `LOG_FORMAT` and `LOG_LEVEL` override it from the environment, which is handy @@ -38,8 +40,7 @@ to reproduce production output locally: LOG_FORMAT=json npm start ``` -Errors keep their stack, and SQL errors keep their `code` and `sqlState`, as -separate fields. +Errors keep their stack. ### Standing fields @@ -104,7 +105,7 @@ collector at the process output. To add a destination, winston transports work as usual: ```js -// app/config.js — logger is a plain winston logger +// anywhere at startup — logger is a plain winston logger const { logger } = require('@igojs/server'); logger.add(new winston.transports.File({ filename: 'logs/app.log' })); ``` From 840d502599e44b4c22172497244b72348bd37fae Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Tue, 8 Sep 2026 09:43:24 +0200 Subject: [PATCH 37/80] docs: documenter le socle API-first dans le CLAUDE.md racine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute la couche API JSON, les types TypeScript, les squelettes et les ADR — internes, non publiés — à la structure du dépôt. Corrige au passage `.eslintrc.json`, remplacé par `eslint.config.js`. Le README du squelette front annonçait `pnpm dev` pour lancer le back, qui n'expose que `pnpm start`. L'en-tête d'errorhandler décrivait le comportement de `src/api/problem.js` avec un renvoi croisé. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 9 ++++++++- packages/server/skel/front/README.md | 2 +- packages/server/src/connect/errorhandler.js | 3 +-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 119f328f..5b4dfc6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,7 @@ Igo is a Node.js full-stack web framework built on Express, providing ORM, templ │ ├── server/ # @igojs/server - Express framework core │ └── component/ # @igojs/component - Reactive components with SSR ├── docs/ # VitePress documentation (deployed to GitHub Pages) +│ └── adr/ # Architecture decision records (internal, not published) ├── package.json # Root workspace configuration └── CHANGELOG.md # Version history ``` @@ -51,8 +52,12 @@ Igo is a Node.js full-stack web framework built on Express, providing ORM, templ - i18next internationalization - Redis caching, email (nodemailer + MJML) - CLI for scaffolding and database commands +- JSON APIs: schema validation, RFC 9457 errors, structured logging - **Entry:** `packages/server/src/index.js` - **CLI:** `packages/server/cli/igo.js` +- **JSON API layer:** `packages/server/src/api/` +- **TypeScript types:** `packages/server/index.d.ts` +- **Project skeletons:** `packages/server/skel/` — `api`, `front` and `fullstack` scaffold TypeScript projects with their own tooling (pnpm, oxlint, oxfmt); the others are igo apps ### @igojs/component (Reactive Components) - Single-file `.dust` components (` - - diff --git a/packages/server/skel/front/package.json b/packages/server/skel/front/package.json deleted file mode 100644 index c9bd3a86..00000000 --- a/packages/server/skel/front/package.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "name": "{project.name}-front", - "version": "0.0.1", - "private": true, - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", - "preview": "vite preview", - "lint": "oxlint", - "test": "vitest run", - "test:watch": "vitest", - "typecheck": "tsc --noEmit", - "prepare": "husky", - "format": "oxfmt", - "format:check": "oxfmt --check" - }, - "dependencies": { - "@tanstack/react-query": "^5.102.0", - "react": "^19.2.0", - "react-dom": "^19.2.0", - "react-router": "^8.3.0" - }, - "devDependencies": { - "@commitlint/cli": "^21.2.0", - "@commitlint/config-conventional": "^21.2.0", - "@tailwindcss/vite": "^4.3.0", - "@testing-library/jest-dom": "^6.9.0", - "@testing-library/react": "^16.3.0", - "@testing-library/user-event": "^14.6.0", - "@types/node": "^24.13.0", - "@types/react": "^19.2.0", - "@types/react-dom": "^19.2.0", - "@vitejs/plugin-react": "^6.1.0", - "husky": "^9.1.7", - "jsdom": "^30.0.0", - "lint-staged": "^17.5.0", - "msw": "^2.12.0", - "oxfmt": "^0.66.0", - "oxlint": "^1.81.0", - "tailwindcss": "^4.3.0", - "typescript": "^7.0.0", - "vite": "^8.2.0", - "vitest": "^5.0.0" - }, - "engines": { - "node": ">=24" - } -} diff --git a/packages/server/skel/front/pnpm-workspace.yaml b/packages/server/skel/front/pnpm-workspace.yaml deleted file mode 100644 index c5308f17..00000000 --- a/packages/server/skel/front/pnpm-workspace.yaml +++ /dev/null @@ -1,5 +0,0 @@ -# msw's postinstall only prints a banner; the browser service worker is -# generated on demand by `msw init`. pnpm blocks postinstall scripts unless a -# package is listed here. -allowBuilds: - msw: true diff --git a/packages/server/skel/front/src/components/layout/app-layout.tsx b/packages/server/skel/front/src/components/layout/app-layout.tsx deleted file mode 100644 index c9164f15..00000000 --- a/packages/server/skel/front/src/components/layout/app-layout.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { Outlet } from 'react-router'; - -export function AppLayout() { - return ( -
      - -
      - ); -} diff --git a/packages/server/skel/front/src/features/books/api.ts b/packages/server/skel/front/src/features/books/api.ts deleted file mode 100644 index b14c8b1f..00000000 --- a/packages/server/skel/front/src/features/books/api.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; - -import { apiClient } from '@/lib/api-client'; - -import type { Book, BooksPage, CreateBook } from './types'; - -const keys = { - all: ['books'] as const, - list: (page: number) => ['books', { page }] as const, -}; - -export function useBooks(page = 1) { - return useQuery({ - queryKey: keys.list(page), - queryFn: () => apiClient.get(`/api/books?page=${page}`), - }); -} - -export function useCreateBook() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (book: CreateBook) => apiClient.post('/api/books', book), - onSuccess: () => queryClient.invalidateQueries({ queryKey: keys.all }), - }); -} diff --git a/packages/server/skel/front/src/features/books/components/books-list.test.tsx b/packages/server/skel/front/src/features/books/components/books-list.test.tsx deleted file mode 100644 index 46b48553..00000000 --- a/packages/server/skel/front/src/features/books/components/books-list.test.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; - -import { aBook } from '@/test/handlers'; - -import { BooksList } from './books-list'; - -// A pure component needs no providers: props in, markup out. -describe('BooksList', () => { - it('should list every book', () => { - render(); - - expect(screen.getByText('Dune')).toBeInTheDocument(); - expect(screen.getByText('Neuromancer')).toBeInTheDocument(); - }); - - it('should say so when there is nothing to show', () => { - render(); - - expect(screen.getByText(/no book yet/i)).toBeInTheDocument(); - }); -}); diff --git a/packages/server/skel/front/src/features/books/components/books-list.tsx b/packages/server/skel/front/src/features/books/components/books-list.tsx deleted file mode 100644 index 877d9ac1..00000000 --- a/packages/server/skel/front/src/features/books/components/books-list.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { Book } from '../types'; - -// Pure: everything arrives through props. No useQuery here — see the data -// injection rule in the front conventions. -export function BooksList({ books }: { books: Book[] }) { - if (books.length === 0) { - return

      No book yet.

      ; - } - - return ( -
        - {books.map((book) => ( -
      • -
        - {book.title} - {book.author} -
        - {book.pages} pages -
      • - ))} -
      - ); -} diff --git a/packages/server/skel/front/src/features/books/pages/books-page.test.tsx b/packages/server/skel/front/src/features/books/pages/books-page.test.tsx deleted file mode 100644 index 26bd32a6..00000000 --- a/packages/server/skel/front/src/features/books/pages/books-page.test.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { http, HttpResponse } from 'msw'; -import { describe, expect, it } from 'vitest'; - -import { renderWithProviders } from '@/test/render'; -import { server } from '@/test/msw-server'; - -import { BooksPage } from './books-page'; - -describe('BooksPage', () => { - it('should show the books once loaded', async () => { - renderWithProviders(); - - expect(screen.getByText(/loading/i)).toBeInTheDocument(); - expect(await screen.findByText('Dune')).toBeInTheDocument(); - }); - - it('should report a server error instead of loading forever', async () => { - server.use( - http.get('/api/books', () => - HttpResponse.json( - { type: 'about:blank', title: 'Internal Server Error', status: 500 }, - { status: 500 }, - ), - ), - ); - - renderWithProviders(); - - expect(await screen.findByRole('alert')).toHaveTextContent(/internal server error/i); - }); - - it('should show validation errors under the fields the server named', async () => { - server.use( - http.post('/api/books', () => - HttpResponse.json( - { - type: 'urn:igo:validation-failed', - title: 'Validation failed', - status: 400, - errors: [{ path: 'title', code: 'too_small', message: 'Too small' }], - }, - { status: 400 }, - ), - ), - ); - - renderWithProviders(); - await screen.findByText('Dune'); - - await userEvent.click(screen.getByRole('button', { name: /add book/i })); - - expect(await screen.findByText('Too small')).toBeInTheDocument(); - }); - - it('should add a book and refresh the list', async () => { - renderWithProviders(); - await screen.findByText('Dune'); - - await userEvent.type(screen.getByLabelText(/title/i), 'Neuromancer'); - await userEvent.type(screen.getByLabelText(/author/i), 'Gibson'); - await userEvent.type(screen.getByLabelText(/pages/i), '271'); - await userEvent.click(screen.getByRole('button', { name: /add book/i })); - - await waitFor(() => expect(screen.getByLabelText(/title/i)).toHaveValue('')); - }); -}); diff --git a/packages/server/skel/front/src/features/books/pages/books-page.tsx b/packages/server/skel/front/src/features/books/pages/books-page.tsx deleted file mode 100644 index e42d29c8..00000000 --- a/packages/server/skel/front/src/features/books/pages/books-page.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { useBooks } from '../api'; -import { BooksList } from '../components/books-list'; -import { AddBookSection } from '../sections/add-book-section'; - -// A page assembles. Loading and error states are handled explicitly rather -// than left to a spinner that never resolves. -export function BooksPage() { - const { data, isPending, isError, error } = useBooks(); - - return ( - <> -

      Books

      - - - - {isPending &&

      Loading…

      } - {isError && ( -

      - {error.message} -

      - )} - {data && ( - <> - -

      {data.page.total} in total

      - - )} - - ); -} - -export const Component = BooksPage; diff --git a/packages/server/skel/front/src/features/books/sections/add-book-section.tsx b/packages/server/skel/front/src/features/books/sections/add-book-section.tsx deleted file mode 100644 index 2bc7bf8f..00000000 --- a/packages/server/skel/front/src/features/books/sections/add-book-section.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useState } from 'react'; - -import { ApiError } from '@/lib/api-client'; - -import { useCreateBook } from '../api'; - -const EMPTY = { title: '', author: '', pages: '' }; - -// A section owns its mutation. The server is the authority on validity: its -// per-field errors are displayed as they come, without being re-derived here. -export function AddBookSection() { - const [form, setForm] = useState(EMPTY); - const createBook = useCreateBook(); - - const error = createBook.error instanceof ApiError ? createBook.error : null; - - const submit = (event: React.FormEvent) => { - event.preventDefault(); - createBook.mutate( - { title: form.title, author: form.author, pages: Number(form.pages) }, - { onSuccess: () => setForm(EMPTY) }, - ); - }; - - return ( - - {(['title', 'author', 'pages'] as const).map((field) => ( -
      - - setForm({ ...form, [field]: e.target.value })} - className="mt-1 w-full rounded border border-slate-300 px-3 py-2" - /> - {error?.fieldError(field) && ( -

      - {error.fieldError(field)} -

      - )} -
      - ))} - - - - ); -} diff --git a/packages/server/skel/front/src/features/books/types.ts b/packages/server/skel/front/src/features/books/types.ts deleted file mode 100644 index 3f6281f5..00000000 --- a/packages/server/skel/front/src/features/books/types.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Mirrors the DTO the back serializes. Kept by hand: the back is JavaScript, -// so there is no contract to generate from — a mismatch shows up in the -// feature tests, which run against the real payload shape. -export interface Book { - id: number; - title: string; - author: string; - pages: number; - published: boolean; - createdAt: string; -} - -export interface BooksPage { - books: Book[]; - page: { page: number; perPage: number; pages: number; total: number }; -} - -export interface CreateBook { - title: string; - author: string; - pages: number; - published?: boolean; -} diff --git a/packages/server/skel/front/src/index.css b/packages/server/skel/front/src/index.css deleted file mode 100644 index d4b50785..00000000 --- a/packages/server/skel/front/src/index.css +++ /dev/null @@ -1 +0,0 @@ -@import 'tailwindcss'; diff --git a/packages/server/skel/front/src/lib/api-client.ts b/packages/server/skel/front/src/lib/api-client.ts deleted file mode 100644 index 5289bfea..00000000 --- a/packages/server/skel/front/src/lib/api-client.ts +++ /dev/null @@ -1,51 +0,0 @@ -// RFC 9457 problem document, as returned by igo on every API error. -export interface Problem { - type: string; - title: string; - status: number; - detail?: string; - errors?: { path: string; code?: string; message: string }[]; -} - -export class ApiError extends Error { - readonly problem: Problem; - - constructor(problem: Problem) { - super(problem.detail || problem.title); - this.name = 'ApiError'; - this.problem = problem; - } - - /** Message for one field, to sit under the input that caused it. */ - fieldError(path: string): string | undefined { - return this.problem.errors?.find((e) => e.path === path)?.message; - } -} - -// Relative URLs on purpose: the same build then runs against every -// environment, behind the dev proxy or behind nginx. -const request = async (method: string, path: string, body?: unknown): Promise => { - const response = await fetch(path, { - method, - headers: body ? { 'Content-Type': 'application/json' } : undefined, - body: body ? JSON.stringify(body) : undefined, - }); - - if (!response.ok) { - const problem = await response.json().catch(() => ({ - type: 'about:blank', - title: response.statusText, - status: response.status, - })); - throw new ApiError(problem as Problem); - } - - return response.status === 204 ? (undefined as T) : response.json(); -}; - -export const apiClient = { - get: (path: string) => request('GET', path), - post: (path: string, body: unknown) => request('POST', path, body), - put: (path: string, body: unknown) => request('PUT', path, body), - delete: (path: string) => request('DELETE', path), -}; diff --git a/packages/server/skel/front/src/lib/query-client.ts b/packages/server/skel/front/src/lib/query-client.ts deleted file mode 100644 index 815485d0..00000000 --- a/packages/server/skel/front/src/lib/query-client.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { QueryClient } from '@tanstack/react-query'; - -import { ApiError } from './api-client'; - -export const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 30_000, - // a 404 or a validation error will not fix itself on retry - retry: (failureCount, error) => - !(error instanceof ApiError && error.problem.status < 500) && failureCount < 2, - }, - }, -}); diff --git a/packages/server/skel/front/src/main.tsx b/packages/server/skel/front/src/main.tsx deleted file mode 100644 index 9a89edca..00000000 --- a/packages/server/skel/front/src/main.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; -import { QueryClientProvider } from '@tanstack/react-query'; -import { RouterProvider } from 'react-router'; - -import { queryClient } from '@/lib/query-client'; -import { router } from '@/routes'; - -import './index.css'; - -createRoot(document.getElementById('root')!).render( - - - - - , -); diff --git a/packages/server/skel/front/src/routes.tsx b/packages/server/skel/front/src/routes.tsx deleted file mode 100644 index d04a59bc..00000000 --- a/packages/server/skel/front/src/routes.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { createBrowserRouter } from 'react-router'; - -import { AppLayout } from '@/components/layout/app-layout'; - -export const router = createBrowserRouter([ - { - element: , - children: [ - // lazy per feature: a route is only downloaded when it is visited - { index: true, lazy: () => import('@/features/books/pages/books-page') }, - ], - }, -]); diff --git a/packages/server/skel/front/src/test/handlers.ts b/packages/server/skel/front/src/test/handlers.ts deleted file mode 100644 index b9d25c23..00000000 --- a/packages/server/skel/front/src/test/handlers.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { http, HttpResponse } from 'msw'; - -import type { Book } from '@/features/books/types'; - -export const aBook = (overrides: Partial = {}): Book => ({ - id: 1, - title: 'Dune', - author: 'Frank Herbert', - pages: 412, - published: true, - createdAt: '2026-01-01T00:00:00.000Z', - ...overrides, -}); - -// Default handlers describe the happy path; a test overrides the one case it -// is about with server.use(). -export const handlers = [ - http.get('/api/books', () => - HttpResponse.json({ - books: [aBook()], - page: { page: 1, perPage: 25, pages: 1, total: 1 }, - }), - ), - - http.post('/api/books', async ({ request }) => { - const body = (await request.json()) as Partial; - return HttpResponse.json(aBook({ id: 2, ...body }), { status: 201 }); - }), -]; diff --git a/packages/server/skel/front/src/test/msw-server.ts b/packages/server/skel/front/src/test/msw-server.ts deleted file mode 100644 index 5ac9204f..00000000 --- a/packages/server/skel/front/src/test/msw-server.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { setupServer } from 'msw/node'; - -import { handlers } from './handlers'; - -export const server = setupServer(...handlers); diff --git a/packages/server/skel/front/src/test/render.tsx b/packages/server/skel/front/src/test/render.tsx deleted file mode 100644 index 430113a9..00000000 --- a/packages/server/skel/front/src/test/render.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { render } from '@testing-library/react'; -import type { ReactElement } from 'react'; - -// A fresh client per test: a cache shared between tests makes them pass or -// fail depending on their order. Retries off so an error surfaces at once. -export function renderWithProviders(ui: ReactElement) { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); - - return render({ui}); -} diff --git a/packages/server/skel/front/src/test/setup.ts b/packages/server/skel/front/src/test/setup.ts deleted file mode 100644 index 91f1d1e7..00000000 --- a/packages/server/skel/front/src/test/setup.ts +++ /dev/null @@ -1,14 +0,0 @@ -import '@testing-library/jest-dom/vitest'; -import { cleanup } from '@testing-library/react'; -import { afterAll, afterEach, beforeAll } from 'vitest'; - -import { server } from './msw-server'; - -// MSW intercepts at the network level, so the production apiClient runs -// untouched: swapping the HTTP wrapper does not break these tests. -beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); -afterEach(() => { - server.resetHandlers(); - cleanup(); -}); -afterAll(() => server.close()); diff --git a/packages/server/skel/front/src/vite-env.d.ts b/packages/server/skel/front/src/vite-env.d.ts deleted file mode 100644 index 11f02fe2..00000000 --- a/packages/server/skel/front/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/packages/server/skel/front/tsconfig.json b/packages/server/skel/front/tsconfig.json deleted file mode 100644 index 46739c4f..00000000 --- a/packages/server/skel/front/tsconfig.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2023", - "lib": ["ES2023", "DOM", "DOM.Iterable"], - "module": "ESNext", - "moduleResolution": "bundler", - "jsx": "react-jsx", - "strict": true, - "noEmit": true, - "skipLibCheck": true, - "resolveJsonModule": true, - "isolatedModules": true, - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "types": ["vitest/globals", "@testing-library/jest-dom"], - "paths": { - "@/*": ["./src/*"] - }, - "moduleDetection": "force", - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "erasableSyntaxOnly": true - }, - "include": ["src", "vite.config.ts", "vitest.config.ts"] -} diff --git a/packages/server/skel/front/vite.config.ts b/packages/server/skel/front/vite.config.ts deleted file mode 100644 index e8c102a0..00000000 --- a/packages/server/skel/front/vite.config.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { fileURLToPath, URL } from 'node:url'; - -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; -import tailwindcss from '@tailwindcss/vite'; - -const API_PROXY = { - target: process.env.API_URL || 'http://127.0.0.1:3000', - changeOrigin: false, -}; - -export default defineConfig({ - plugins: [react(), tailwindcss()], - - // tsconfig paths are for the type checker only: the bundler needs its own - resolve: { - alias: { - '@': fileURLToPath(new URL('./src', import.meta.url)), - }, - }, - - // The browser sees a single origin, so the igo session cookie is sent like - // any same-origin cookie — no CORS, no credentials handling. In production - // nginx plays this role. - server: { - port: 5173, - proxy: { '/api': API_PROXY }, - }, - - // `vite preview` does not inherit server.proxy: without this, a build served - // for E2E tests would have no API behind it. - preview: { - proxy: { '/api': API_PROXY }, - }, -}); diff --git a/packages/server/skel/front/vitest.config.ts b/packages/server/skel/front/vitest.config.ts deleted file mode 100644 index ada938c3..00000000 --- a/packages/server/skel/front/vitest.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { defineConfig, mergeConfig } from 'vitest/config'; - -import viteConfig from './vite.config.ts'; - -// Vitest 5 no longer accepts a `test` key in vite's defineConfig: the test -// setup lives in its own file and reuses the app config. -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'jsdom', - globals: true, - setupFiles: ['./src/test/setup.ts'], - include: ['src/**/*.{test,spec}.{ts,tsx}'], - }, - }), -); diff --git a/packages/server/test/CreateTest.js b/packages/server/test/CreateTest.js index 440aa02e..e88ef54d 100644 --- a/packages/server/test/CreateTest.js +++ b/packages/server/test/CreateTest.js @@ -7,7 +7,7 @@ const path = require('path'); const create = require('@igojs/server/cli/create'); -const SKELETONS = ['tailwind', 'api', 'front', 'fullstack']; +const SKELETONS = ['tailwind', 'fullstack']; describe('cli/create', function() { this.timeout(20000); @@ -43,28 +43,29 @@ describe('cli/create', function() { assert(fs.existsSync(path.join(tmp, 'myapp', 'views')), 'tailwind skeleton has views'); }); - it('should carry the api conventions into the api skeletons', async () => { - await create({ _: ['create', 'myapi'], skel: 'api' }); + it('should carry the api conventions into the api skeleton', async () => { + await create({ _: ['create', 'myapi'], skel: 'fullstack' }); - const routes = fs.readFileSync(path.join(tmp, 'myapi', 'app', 'routes.ts'), 'utf8'); + const routes = fs.readFileSync(path.join(tmp, 'myapi', 'api', 'app', 'routes.ts'), 'utf8'); assert(routes.includes('app.api('), 'routes mount through app.api()'); const controller = fs.readFileSync( - path.join(tmp, 'myapi', 'app', 'features', 'books', 'books.controller.ts'), 'utf8'); + path.join(tmp, 'myapi', 'api', 'app', 'features', 'books', 'books.controller.ts'), 'utf8'); assert(controller.includes('create.body = dto.CreateBook'), 'schema is attached to the handler'); assert(controller.includes('\'/problems/book-not-found\''), 'business errors carry their own problem type'); }); - it('should ship migrations and seeds in the api skeletons', async () => { - await create({ _: ['create', 'myapi'], skel: 'api' }); + it('should ship migrations and seeds in the api skeleton', async () => { + await create({ _: ['create', 'myapi'], skel: 'fullstack' }); - const pkg = JSON.parse(fs.readFileSync(path.join(tmp, 'myapi', 'package.json'), 'utf8')); + const pkg = JSON.parse( + fs.readFileSync(path.join(tmp, 'myapi', 'api', 'package.json'), 'utf8')); assert(pkg.scripts.migrate, 'migrate script'); // the seeds import the TS models, so the CLI needs the tsx loader assert(pkg.scripts.seed.includes('tsx'), `seed runs under tsx: ${pkg.scripts.seed}`); - assert(fs.existsSync(path.join(tmp, 'myapi', 'seeds', '001-books.ts'))); + assert(fs.existsSync(path.join(tmp, 'myapi', 'api', 'seeds', '001-books.ts'))); }); }); From 10a7f3f35775e6aeeaf1d048ac5409a582829f97 Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Thu, 10 Sep 2026 10:22:26 +0200 Subject: [PATCH 41/80] =?UTF-8?q?feat(server):=20exposer=20un=20point=20d?= =?UTF-8?q?=20entr=C3=A9e=20pour=20charger=20le=20.env?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un fichier chargé par `node --import` tourne avant l application, ce qui est obligatoire pour OpenTelemetry : il instrumente en remplaçant les modules au moment du require, donc il doit précéder express et mysql2. Mais à cet instant igo n a pas lu le .env — c est sa configuration qui appelle dotenv — et toute variable qu un tel fichier lit vaut undefined. Le SDK ne démarre alors pas : aucune erreur, aucun avertissement, aucune donnée. Mesuré : OTEL_EXPORTER_OTLP_ENDPOINT undefined au moment du --import. Importer igo pour contourner ne marche pas — 1936 modules chargés, dont express et winston, soit précisément ce que l instrumentation devait précéder. D où ce point d entrée, qui n en charge que 3. Co-Authored-By: Claude Opus 5 --- packages/server/env.js | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 packages/server/env.js diff --git a/packages/server/env.js b/packages/server/env.js new file mode 100644 index 00000000..6c64b41c --- /dev/null +++ b/packages/server/env.js @@ -0,0 +1,25 @@ + +// Charge le .env sans rien initialiser d'autre. +// +// Un fichier chargé par `node --import` tourne avant l'application, ce qui est +// obligatoire pour OpenTelemetry : il instrumente en remplaçant les modules au +// moment du require, donc il doit précéder le chargement d'express ou de +// mysql2. Mais à cet instant igo n'a pas encore lu le .env — c'est sa +// configuration qui appelle dotenv — et toute variable qu'un tel fichier lit +// vaut undefined. +// +// Importer igo pour contourner ça ne marche pas : ça charge express et winston, +// soit précisément ce que l'instrumentation devait précéder. +// +// D'où ce point d'entrée : `import '@igojs/server/env'` en tête d'un fichier +// --import. +// +// dotenv ne remplace jamais une variable déjà définie, et deux appels sont sans +// effet supplémentaire : l'import est sans risque même si l'application charge +// ensuite la configuration d'igo normalement. + +// Même règle que src/config.js : en production les variables viennent de +// l'environnement, pas d'un fichier. +if (process.env.NODE_ENV !== 'production') { + require('dotenv').config({ quiet: true }); +} From 1abee3b373b89d8d6ddd2ea7583bc87fc8f9b9e6 Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Thu, 10 Sep 2026 10:22:45 +0200 Subject: [PATCH 42/80] feat(server): exporter redact() et la rendre configurable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La fonction vivait dans errorhandler.js, sans être exportée : un projet qui voulait journaliser un corps de requête devait réécrire la sienne, avec une occasion d oublier un champ à chaque fois. Son motif ne connaissait que l anglais, alors que la convention de langue fait du français le nom attendu des champs du domaine. Vérifié : motDePasse, mot_de_passe et jeton partaient en clair — le champ le plus sensible qui existe, sous le nom qu il porte le plus souvent. Le défaut reste volontairement court : ce qui authentifie un appelant, et rien d autre. Un IBAN ou un dossier médical appartiennent au domaine, qu igo ne peut pas deviner — config.sensitiveKeys les ajoute. La correspondance est ancrée plutôt qu en sous-chaîne : userPassword et jetonDeSession sont attrapés, tokenExpiry et tokenizer non. Un défaut qui masque un champ utile gêne tous les projets ; un défaut qui en laisse passer un se corrige dans celui qui le connaît. La nouvelle version neutralise aussi les références circulaires, sur lesquelles la précédente aurait débordé la pile. Co-Authored-By: Claude Opus 5 --- packages/server/index.d.ts | 24 +++++- packages/server/index.js | 2 + packages/server/src/config.js | 18 ++++- packages/server/src/connect/errorhandler.js | 29 +++---- packages/server/src/redact.js | 63 +++++++++++++++ packages/server/test/RedactTest.js | 90 +++++++++++++++++++++ 6 files changed, 203 insertions(+), 23 deletions(-) create mode 100644 packages/server/src/redact.js create mode 100644 packages/server/test/RedactTest.js diff --git a/packages/server/index.d.ts b/packages/server/index.d.ts index c5a67ada..91793937 100644 --- a/packages/server/index.d.ts +++ b/packages/server/index.d.ts @@ -51,8 +51,18 @@ export interface Config { loglevel: string; /** 'json' for log collectors, 'human' for a terminal. */ logformat: 'json' | 'human'; - /** false silences the one-line-per-request log. */ - logrequests: boolean; + /** + * true logs every request, false none. A number is a status floor: 400 keeps + * the errors and drops the successes. + */ + logrequests: boolean | number; + /** + * Keys whose value redact() replaces. null keeps igo's default pattern, + * which covers the usual English and French names. A pattern set here + * replaces the default rather than adding to it — extend + * redact.DEFAULT_SENSITIVE_KEYS to keep both. + */ + sensitiveKeys: RegExp | null; [key: string]: unknown; } @@ -64,6 +74,16 @@ export declare const app: Express & { export declare const config: Config; export declare function problem(status: number, options?: ProblemOptions): ProblemDocument; + +/** + * Returns a copy of `value` with every sensitive field replaced by + * '[redacted]', following config.sensitiveKeys. Use it before logging a + * request body, its query or its headers. + */ +export declare function redact(value: T): T; +export declare namespace redact { + const DEFAULT_SENSITIVE_KEYS: RegExp; +} export declare function sendProblem(res: import('express').Response, status: number, options?: ProblemOptions): import('express').Response; export interface TestResponse { diff --git a/packages/server/index.js b/packages/server/index.js index b39532c9..93f6ea2a 100644 --- a/packages/server/index.js +++ b/packages/server/index.js @@ -4,6 +4,7 @@ const cache = require('./src/cache'); const logger = require('./src/logger'); const problem = require('./src/api/problem'); +const redact = require('./src/redact'); const server = { app: require('./src/app'), @@ -16,6 +17,7 @@ const server = { mailer: require('./src/mailer'), Form: require('./src/forms/Form'), problem: problem.problem, + redact, sendProblem: problem.send, }; diff --git a/packages/server/src/config.js b/packages/server/src/config.js index 566ef8c3..7116b38f 100644 --- a/packages/server/src/config.js +++ b/packages/server/src/config.js @@ -153,9 +153,25 @@ module.exports.init = function() { config.loglevel = process.env.LOG_LEVEL || 'info'; // 'json' for log collectors, 'human' for a terminal config.logformat = process.env.LOG_FORMAT || (config.env === 'production' ? 'json' : 'human'); - // set to false to silence the one-line-per-request log + // true logs every request, false none. A number is a status floor: 400 keeps + // the errors and drops the successes, which is what keeps a log bill down + // once latency and error rate come from metrics. config.logrequests = config.env !== 'test'; + // Keys whose value redact() replaces, in crash emails and in the request line + // of a failed request. Left null, the default covers what authenticates a + // caller — password, token, secret, cookie, authorization, in English and in + // French — and stops there. + // + // The fields a domain considers sensitive are the project's to declare: an + // IBAN, a medical record, a case number. A pattern set here *replaces* the + // default rather than adding to it, so extend it: + // + // const { redact } = require('@igojs/server'); + // config.sensitiveKeys = new RegExp( + // `${redact.DEFAULT_SENSITIVE_KEYS.source}|iban|dossier.?medical`, 'i'); + config.sensitiveKeys = null; + // if (config.env === 'dev') { config.cache_warnings = true; diff --git a/packages/server/src/connect/errorhandler.js b/packages/server/src/connect/errorhandler.js index 372825f9..029fd5fc 100644 --- a/packages/server/src/connect/errorhandler.js +++ b/packages/server/src/connect/errorhandler.js @@ -42,6 +42,7 @@ const path = require('path'); const os = require('os'); const config = require('../config'); +const redact = require('../redact'); const logger = require('../logger'); const mailer = require('../mailer'); const problem = require('../api/problem'); @@ -116,19 +117,6 @@ const checkThrottle = (errorKey) => { const HTML_ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' }; const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c => HTML_ESCAPES[c]); -// credentials must not leak in crash emails -const SENSITIVE_KEYS = /cookie|authorization|password|token|secret/i; -const redact = (obj) => { - if (!obj || typeof obj !== 'object') { - return obj; - } - const copy = Array.isArray(obj) ? [] : {}; - for (const key in obj) { - copy[key] = SENSITIVE_KEYS.test(key) ? '[redacted]' : redact(obj[key]); - } - return copy; -}; - const getURL = (req) => { const protocol = req.protocol || 'http'; const host = req.headers['x-forwarded-host'] || (req.get ? req.get('host') : req.headers.host) || 'localhost'; @@ -216,15 +204,16 @@ const handle = (err, req, res) => { // Check if response already sent if (res.headersSent) { // Response already sent, can only log - logger.error(`${req.method} ${getURL(req)} : ${err} (response already sent)`); - logger.error(err.stack); + logger.error(`${req.method} ${getURL(req)} : ${err} (response already sent)`, + { stack: err.stack }); sendCrashEmail(`Crash (response sent): ${err}`, formatMessage(req, err), String(err)); return; } - // Log error - logger.error(`${req.method} ${getURL(req)} : ${err}`); - logger.error(err.stack); + // The stack rides along as a field rather than on a line of its own: two + // consecutive calls produce two log entries for one error, which a collector + // then has to stitch back together. + logger.error(`${req.method} ${getURL(req)} : ${err}`, { stack: err.stack }); // Send email notification sendCrashEmail(`Crash: ${err}`, formatMessage(req, err), String(err)); @@ -268,8 +257,8 @@ process.on('uncaughtException', (err) => { if (handled) { handle(err, context.req, context.res); } else { - logger.error('Uncaught exception outside of request context:', err); - logger.error(err.stack); + logger.error(`Uncaught exception outside of request context: ${err}`, + { stack: err.stack }); sendCrashEmail(`Uncaught exception: ${err}`, `
      ${escapeHtml(err.stack)}
      `, String(err)); } diff --git a/packages/server/src/redact.js b/packages/server/src/redact.js new file mode 100644 index 00000000..bbdf3db0 --- /dev/null +++ b/packages/server/src/redact.js @@ -0,0 +1,63 @@ + +const config = require('./config'); + +// Keys whose value must never reach a log, a crash email or an error report. +// +// The list stays short on purpose: it covers what authenticates a caller, and +// nothing else. Those words mean the same thing in every domain — a `token` is +// a credential whether the project sells shoes or manages patients — so igo can +// claim to know them. +// +// Everything else belongs to the project. A bank knows its account fields, a +// clinic its medical ones, and igo can only guess — a longer default list would +// still miss the one field this domain calls sensitive, while masking others a +// diagnosis needed. +// +// const { redact } = require('@igojs/server'); +// config.sensitiveKeys = new RegExp( +// `${redact.DEFAULT_SENSITIVE_KEYS.source}|iban|numero.?secu`, 'i'); +// +// The match is anchored, not a bare substring: a name *ending* in `password` +// or `token` in English, *starting* with `motDePasse` or `jeton` in French — +// the qualifier sits before the word in one language and after it in the +// other. `userPassword` and `jetonDeSession` are caught; `tokenExpiry`, +// `cookieJar` and `tokenizer` are not. +// +// Anchoring means the default misses names like `tokenApi` or `jwtSecret_v2`. +// That is the point of the trade: a default that masks a field a diagnosis +// needed is a nuisance to every project, while a default that misses one is +// the project's to fix — it knows its own field names, and extends the pattern +// through config.sensitiveKeys. +const ENGLISH = 'password|passwd|token|secret|cookie|authorization'; +const FRENCH = 'mot.?de.?passe|jeton|cle.?secrete'; + +// A trailing `s`, `_confirmation` or `_hash` still names the same thing. +const DEFAULT_SENSITIVE_KEYS = new RegExp( + `(${ENGLISH})(s|_?confirmation|_?confirm|_?hash)?$|(${FRENCH})([A-Z_-]|$)`, + 'i' +); + +const pattern = () => config.sensitiveKeys || DEFAULT_SENSITIVE_KEYS; + +// Returns a copy with every sensitive value replaced. Circular references are +// tracked: a request body can hold one, and a crash report must not recurse +// until the stack gives out. +const redact = (value, seen = new WeakSet()) => { + if (!value || typeof value !== 'object') { + return value; + } + if (seen.has(value)) { + return '[circular]'; + } + seen.add(value); + + const keys = pattern(); + const copy = Array.isArray(value) ? [] : {}; + for (const key in value) { + copy[key] = keys.test(key) ? '[redacted]' : redact(value[key], seen); + } + return copy; +}; + +module.exports = redact; +module.exports.DEFAULT_SENSITIVE_KEYS = DEFAULT_SENSITIVE_KEYS; diff --git a/packages/server/test/RedactTest.js b/packages/server/test/RedactTest.js new file mode 100644 index 00000000..36aa174d --- /dev/null +++ b/packages/server/test/RedactTest.js @@ -0,0 +1,90 @@ +require('./init'); + +const assert = require('assert'); + +const { config, redact } = require('@igojs/server'); + +describe('redact', function() { + + afterEach(function() { + config.sensitiveKeys = null; + }); + + it('should redact the usual English field names', () => { + const out = redact({ password: 'x', token: 'y', authorization: 'z', cookie: 'c' }); + assert.deepStrictEqual(out, { + password: '[redacted]', + token: '[redacted]', + authorization: '[redacted]', + cookie: '[redacted]', + }); + }); + + // The language convention makes French field names the expected case, so a + // pattern that only knows `password` would leak the most common one of all. + it('should redact the French field names', () => { + const out = redact({ motDePasse: 'x', mot_de_passe: 'y', jeton: 'z' }); + assert.deepStrictEqual(out, { + motDePasse: '[redacted]', + mot_de_passe: '[redacted]', + jeton: '[redacted]', + }); + }); + + // Personal, but also what identifies the request that failed. Left in on + // purpose: a log holding an email is a retention question, not a leaked + // credential. + it('should leave harmless fields untouched', () => { + const out = redact({ email: 'a@b.c', nom: 'Alice', pages: 412, phone: '0600' }); + assert.deepStrictEqual(out, { email: 'a@b.c', nom: 'Alice', pages: 412, phone: '0600' }); + }); + + it('should reach into nested objects and arrays', () => { + const out = redact({ user: { motDePasse: 'x' }, list: [{ token: 't' }] }); + assert.deepStrictEqual(out, { user: { motDePasse: '[redacted]' }, list: [{ token: '[redacted]' }] }); + }); + + // A request body can hold a circular reference, and a crash report must not + // recurse until the stack gives out. + it('should survive a circular reference', () => { + const loop = { nom: 'a' }; + loop.self = loop; + assert.deepStrictEqual(redact(loop), { nom: 'a', self: '[circular]' }); + }); + + it('should leave primitives as they are', () => { + assert.strictEqual(redact('hello'), 'hello'); + assert.strictEqual(redact(42), 42); + assert.strictEqual(redact(null), null); + assert.strictEqual(redact(undefined), undefined); + }); + + // The default covers what authenticates a caller, and stops there: a domain + // field is the project's to declare, since igo cannot guess it. + it('should leave domain fields to the project', () => { + const out = redact({ iban: 'FR76', creditCard: '4111', ssn: '185' }); + assert.deepStrictEqual(out, { iban: 'FR76', creditCard: '4111', ssn: '185' }); + }); + + it('should catch a credential whatever it is prefixed with', () => { + const out = redact({ userPassword: 'x', accessToken: 'x', clientSecret: 'x' }); + for (const [key, value] of Object.entries(out)) { + assert.strictEqual(value, '[redacted]', `${key} left in the clear`); + } + }); + + it('should let a project set its own pattern', () => { + config.sensitiveKeys = /dossierMedical/i; + assert.deepStrictEqual( + redact({ dossierMedical: 'x', nom: 'Alice' }), + { dossierMedical: '[redacted]', nom: 'Alice' }); + }); + + it('should let a project extend the defaults', () => { + config.sensitiveKeys = new RegExp(`${redact.DEFAULT_SENSITIVE_KEYS.source}|iban`, 'i'); + assert.deepStrictEqual( + redact({ iban: 'FR76', motDePasse: 'y', nom: 'Alice' }), + { iban: '[redacted]', motDePasse: '[redacted]', nom: 'Alice' }); + }); + +}); From b797634d87000c88625858fd91cbe2624633d8bc Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Thu, 10 Sep 2026 10:23:40 +0200 Subject: [PATCH 43/80] =?UTF-8?q?feat(server):=20un=20identifiant=20unique?= =?UTF-8?q?=20et=20des=20logs=20de=20requ=C3=AAte=20filtrables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux changements sur le même fichier, indissociables. Un seul identifiant : igo générait un UUID sous request_id, tandis qu OpenTelemetry posait trace_id sur la même ligne — deux identités pour une requête, et le doute sur celle qu il faut chercher quand un utilisateur donne son code. Le trace-id est désormais lu, non fabriqué : la trace active, puis un traceparent entrant validé, puis 32 hexadécimaux en dernier recours. La réponse porte traceresponse, défini par W3C Trace Context, à la place de X-Request-Id. L en-tête entrant est le cas qu on oublie : un service instrumenté appelant un service igo qui ne l est pas envoie un traceparent qu aucun SDK ne lira. Sans sa lecture, igo casserait une chaîne de corrélation déjà établie. Un seuil de statut : config.logrequests acceptait un booléen, tout ou rien. Il accepte un plancher, et 400 ne garde que les erreurs — le poste dominant d une facture de logs, alors que la latence et le débit viennent des métriques. Les requêtes en échec portent maintenant leur corps, leur query, leurs paramètres de route et la réponse renvoyée, passés par redact(). Un 400 sans son corps se diagnostique en devinant, et un 500 se reproduit rarement à la demande. Co-Authored-By: Claude Opus 5 --- packages/server/src/connect/requestlogger.js | 183 +++++++++++++++++-- packages/server/src/logger.js | 16 +- packages/server/test/RequestIdTest.js | 96 ++++++++++ packages/server/test/RequestLoggerTest.js | 146 +++++++++++++++ 4 files changed, 416 insertions(+), 25 deletions(-) create mode 100644 packages/server/test/RequestIdTest.js create mode 100644 packages/server/test/RequestLoggerTest.js diff --git a/packages/server/src/connect/requestlogger.js b/packages/server/src/connect/requestlogger.js index 480d07c4..eb44681f 100644 --- a/packages/server/src/connect/requestlogger.js +++ b/packages/server/src/connect/requestlogger.js @@ -1,24 +1,130 @@ const { AsyncLocalStorage } = require('async_hooks'); -const { randomUUID } = require('crypto'); +const { randomBytes } = require('crypto'); const config = require('../config'); const logger = require('../logger'); +const redact = require('../redact'); const storage = new AsyncLocalStorage(); -// A reverse proxy or an upstream service may already have issued one: reusing -// it is what lets a single request be followed across services. -const INBOUND_HEADERS = ['x-request-id', 'x-correlation-id']; +// OpenTelemetry is not a dependency: an application that does not instrument +// itself must still boot. Loaded optionally, so the trace id is read when a +// SDK is registered and ignored otherwise. +let otel = null; +try { + otel = require('@opentelemetry/api'); +} catch { + // no instrumentation in this application +} -const incomingId = (req) => { - for (const header of INBOUND_HEADERS) { - const value = req.headers?.[header]; - if (typeof value === 'string' && value.length && value.length <= 200) { - return value; - } +// The trace id of the active span, when one exists. +// +// OpenTelemetry defines no generic request id — for it, trace_id *is* the +// identity of a request, and it already reaches the logs and the traces. So it +// is read rather than a second one being minted, which would leave two +// independent ids for the same request: the one the client is shown, and the +// one the trace carries. +// +// The test is the presence of a span, never the presence of a traceparent +// header: a request from an uninstrumented client carries no header, yet OTel +// has already created a trace for it. +const activeSpanContext = () => { + const context = otel?.trace.getSpan(otel.context.active())?.spanContext(); + // an all-zero id is what the API returns for an invalid context + return context && !/^0+$/.test(context.traceId) ? context : null; +}; + +const activeTraceId = () => activeSpanContext()?.traceId ?? null; + +// Only known when a SDK is registered: without a span there is no server side +// for a client to attach to, so no traceresponse is sent. +const activeSpanId = () => activeSpanContext()?.spanId ?? null; + +// The version is deliberately not pinned to `00`: Trace Context Level 2 exists, +// and the spec asks implementations to stay lenient about an unknown version +// whose remainder is well formed. Rejecting `01-…` would lose correlation the +// day a caller moves up. +const TRACEPARENT = /^[0-9a-f]{2}-([0-9a-f]{32})-[0-9a-f]{16}-[0-9a-f]{2}$/; + +// The trace id carried by an inbound traceparent, for the case nothing else +// covers: an instrumented service calling an igo service that is not, whose +// header no SDK will read. Without this, igo would mint a fresh id and break a +// chain of correlation already established. +// +// The value comes from the client, so it is validated: it can be malformed, +// duplicated (an array, then), or carry an attempt at injecting into the logs. +const traceIdFromHeader = (value) => { + if (typeof value !== 'string') { + return null; } - return null; + const match = TRACEPARENT.exec(value); + if (!match) { + return null; + } + // all zeroes is what the spec calls an invalid id + return /^0+$/.test(match[1]) ? null : match[1]; +}; + +// What a request line does not say: what the call failed with. A 400 without +// its body or its parameters is diagnosed by guesswork, and a 500 rarely +// reproduces on demand — so the body, the query and the path parameters ride +// along, but only when the response is an error. On a successful request they +// would multiply the volume without teaching anything. +// +// Values go through redact(): its default pattern covers `motDePasse` as well +// as `password`, and a project whose domain has its own sensitive fields +// extends config.sensitiveKeys. +const MAX_LENGTH = 2000; + +// A body can be large — an import, an attachment in base64. Truncated: a +// diagnosis needs the shape, not the whole content. +const truncate = (value) => { + const text = JSON.stringify(value); + if (!text || text.length <= MAX_LENGTH) { + return value; + } + return `${text.slice(0, MAX_LENGTH)}… (${text.length} chars)`; +}; + +const isEmpty = (value) => + !value || (typeof value === 'object' && Object.keys(value).length === 0); + +const failureContext = (req, res) => { + const context = {}; + if (!isEmpty(req.body)) { + context.body = truncate(redact(req.body)); + } + if (!isEmpty(req.query)) { + context.query = truncate(redact(req.query)); + } + if (!isEmpty(req.params)) { + context.params = redact(req.params); + } + // What the client was actually answered — a problem document, or whatever a + // controller chose to send. Captured below, since a response body cannot be + // read back off `res`. + if (res._loggedBody !== undefined) { + context.response = truncate(redact(res._loggedBody)); + } + return context; +}; + +// res.json is the one place every JSON answer goes through, problem documents +// included: wrapping it is what lets an error line carry the response the +// client received. Only the body is kept, and only while the request is in +// flight. +const captureResponseBody = (res) => { + if (typeof res.json !== 'function') { + return; + } + const json = res.json.bind(res); + res.json = (body) => { + if (res.statusCode >= 400) { + res._loggedBody = body; + } + return json(body); + }; }; const levelFor = (status) => { @@ -28,21 +134,59 @@ const levelFor = (status) => { return status >= 400 ? 'warn' : 'info'; }; -logger.provideRequestId(() => storage.getStore()?.requestId); +// config.logrequests takes a boolean, or a status floor: 400 keeps the errors +// and drops the successes. +// +// The floor exists because one line per request is the largest single item in a +// log bill, while the successful ones teach little that metrics do not already +// carry — latency per route and error rate are derived from spans. The errors, +// on the other hand, are worth every byte. +const shouldLog = (status) => { + const setting = config.logrequests; + if (setting === false) { + return false; + } + if (typeof setting === 'number') { + return status >= setting; + } + return true; +}; + +logger.provideRequestId(() => storage.getStore()?.traceId); // One line per request, carrying the id every log of that request is stamped // with. Mounted by igo before the routes. module.exports = (req, res, next) => { - const requestId = incomingId(req) || randomUUID(); - const start = process.hrtime.bigint(); + // The order matters. The OpenTelemetry context comes first: when a SDK is + // loaded it has already reconciled an inbound header if there was one, and + // reversing the two could retain an id diverging from the trace actually + // recorded. The generated value has the shape of a trace id, so the day + // instrumentation arrives it is replaced by a real one with no code change. + const traceId = activeTraceId() + || traceIdFromHeader(req.headers?.traceparent) + || randomBytes(16).toString('hex'); + const start = process.hrtime.bigint(); + + req.traceId = traceId; + + // traceresponse is what W3C Trace Context Level 2 defines for the way back, + // and it carries the server span id as well — which is what lets a browser + // attach its span to the server's. No X-Request-Id: one identity, under the + // name the specification gives it. + const spanId = activeSpanId(); + if (spanId) { + res.setHeader('traceresponse', `00-${traceId}-${spanId}-01`); + } - req.id = requestId; - res.setHeader('X-Request-Id', requestId); + captureResponseBody(res); - storage.run({ requestId }, () => { + storage.run({ traceId }, () => { // mock responses in tests are plain objects, with no events to listen to - if (config.logrequests !== false && typeof res.on === 'function') { + if (typeof res.on === 'function') { res.on('finish', () => { + if (!shouldLog(res.statusCode)) { + return; + } const duration = Number(process.hrtime.bigint() - start) / 1e6; logger.log(levelFor(res.statusCode), 'request', { method: req.method, @@ -50,6 +194,7 @@ module.exports = (req, res, next) => { path: (req.originalUrl || req.url || '').split('?')[0], status: res.statusCode, duration_ms: Math.round(duration * 10) / 10, + ...(res.statusCode >= 400 ? failureContext(req, res) : {}), }); }); } @@ -57,4 +202,4 @@ module.exports = (req, res, next) => { }); }; -module.exports.requestId = () => storage.getStore()?.requestId; +module.exports.traceId = () => storage.getStore()?.traceId; diff --git a/packages/server/src/logger.js b/packages/server/src/logger.js index 2c576111..2d0f0d0e 100644 --- a/packages/server/src/logger.js +++ b/packages/server/src/logger.js @@ -9,8 +9,8 @@ const humanFormat = () => winston.format.combine( winston.format.timestamp(), winston.format.splat(), winston.format.printf(info => { - const { timestamp, level, message, request_id, ...rest } = info; - const id = request_id ? ` [${request_id.slice(0, 8)}]` : ''; + const { timestamp, level, message, trace_id, ...rest } = info; + const id = trace_id ? ` [${String(trace_id).slice(0, 8)}]` : ''; const fields = Object.keys(rest).length ? ` ${JSON.stringify(rest)}` : ''; return `${timestamp} ${level}:${id} ${message}${fields}`; }) @@ -34,12 +34,16 @@ const logger = winston.createLogger({ ] }); -// Stamps every log emitted during a request with its id, so the lines of one -// request can be pulled together — and matched with what the client reports. +// Stamps every log emitted during a request with the id of that request, so +// its lines can be pulled together — and matched with what the client reports. +// +// The name is trace_id, the one OpenTelemetry uses: when instrumentation is on +// it has already stamped it, and when it is off igo fills the same field. One +// name for one value, whether the application is instrumented or not. const withRequestId = winston.format((info) => { const requestId = module.exports.currentRequestId(); - if (requestId && !info.request_id) { - info.request_id = requestId; + if (requestId && !info.trace_id) { + info.trace_id = requestId; } return info; }); diff --git a/packages/server/test/RequestIdTest.js b/packages/server/test/RequestIdTest.js new file mode 100644 index 00000000..b0e1a93d --- /dev/null +++ b/packages/server/test/RequestIdTest.js @@ -0,0 +1,96 @@ +require('./init'); + +const assert = require('assert'); + +const middleware = require('../src/connect/requestlogger'); + +// Drives the middleware and returns what it settled on. +const run = (headers = {}) => { + const sent = {}; + const req = { method: 'GET', originalUrl: '/', headers }; + const res = { + statusCode: 200, + setHeader: (name, value) => { sent[name] = value; }, + on: () => {}, + }; + middleware(req, res, () => {}); + return { traceId: req.traceId, sent }; +}; + +const TRACE_ID = /^[0-9a-f]{32}$/; + +describe('request identity', function() { + + // Without a registered SDK there is no active span, so igo produces a value + // of its own — with the shape of a trace id, so that the day instrumentation + // arrives it is replaced by a real one with no code change. + it('should generate a trace-id-shaped value when nothing provides one', () => { + assert.match(run().traceId, TRACE_ID); + }); + + // The case that gets forgotten: an instrumented service calling an igo + // service that is not. Its traceparent must not be discarded. + it('should adopt the trace id of an inbound traceparent', () => { + const { traceId } = run({ + traceparent: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + }); + assert.strictEqual(traceId, '4bf92f3577b34da6a3ce929d0e0e4736'); + }); + + it('should adopt it even when the caller did not sample', () => { + const { traceId } = run({ + traceparent: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00', + }); + assert.strictEqual(traceId, '4bf92f3577b34da6a3ce929d0e0e4736'); + }); + + // Trace Context Level 2 exists: refusing an unknown version would lose + // correlation the day a caller moves up. + it('should accept an unknown version whose remainder is well formed', () => { + const { traceId } = run({ + traceparent: '01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + }); + assert.strictEqual(traceId, '4bf92f3577b34da6a3ce929d0e0e4736'); + }); + + // The header comes from the client, so it is validated: it can be malformed, + // duplicated, or carry an attempt at injecting into the logs. + it('should reject a malformed traceparent and fall back', () => { + const refuses = [ + '00-00000000000000000000000000000000-00f067aa0ba902b7-01', // all zeroes + '00-4bf92f3577b34da6-00f067aa0ba902b7-01', // too short + '00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01', // uppercase + '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7', // truncated + 'garbage', + '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01\n{"level":"info"}', + ]; + for (const traceparent of refuses) { + const { traceId } = run({ traceparent }); + assert.match(traceId, TRACE_ID, `rejected: ${traceparent}`); + assert.notStrictEqual(traceId, '4bf92f3577b34da6a3ce929d0e0e4736'); + } + }); + + // A duplicated header reaches express as an array. + it('should reject a duplicated header', () => { + const { traceId } = run({ + traceparent: [ + '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-00f067aa0ba902b7-01', + ], + }); + assert.match(traceId, TRACE_ID); + assert.notStrictEqual(traceId, '4bf92f3577b34da6a3ce929d0e0e4736'); + }); + + // X-Request-Id is gone: one identity, under the name the spec gives it. And + // traceresponse needs a server span, which only a registered SDK provides. + it('should send no X-Request-Id', () => { + assert.strictEqual(run().sent['X-Request-Id'], undefined); + }); + + it('should send no traceresponse without instrumentation', () => { + assert.strictEqual(run().sent.traceresponse, undefined); + }); + +}); diff --git a/packages/server/test/RequestLoggerTest.js b/packages/server/test/RequestLoggerTest.js new file mode 100644 index 00000000..8f3338a5 --- /dev/null +++ b/packages/server/test/RequestLoggerTest.js @@ -0,0 +1,146 @@ +require('./init'); + +const assert = require('assert'); + +const { config, logger } = require('@igojs/server'); + +const middleware = require('../src/connect/requestlogger'); + +// Drives the middleware with a fake response, and returns what got logged. +const run = (status, extra = {}) => { + const lines = []; + const log = logger.log; + logger.log = (level, message, meta) => lines.push({ level, message, meta }); + + let finish; + const req = { method: 'GET', originalUrl: '/api/books', headers: {}, ...extra }; + const res = { + statusCode: status, + setHeader: () => {}, + on: (_e, cb) => { finish = cb; }, + json: (body) => { res.sent = body; return res; }, + }; + + try { + middleware(req, res, () => {}); + finish(); + } finally { + logger.log = log; + } + return lines; +}; + +describe('request logger', function() { + + afterEach(function() { + config.logrequests = config.env !== 'test'; + }); + + it('should log every request when true', () => { + config.logrequests = true; + assert.strictEqual(run(200).length, 1); + assert.strictEqual(run(500).length, 1); + }); + + it('should log nothing when false', () => { + config.logrequests = false; + assert.strictEqual(run(200).length, 0); + assert.strictEqual(run(500).length, 0); + }); + + // The point of the floor: successes are the volume, errors are the signal. + it('should keep only the errors above a status floor', () => { + config.logrequests = 400; + assert.strictEqual(run(200).length, 0); + assert.strictEqual(run(304).length, 0); + assert.strictEqual(run(400).length, 1); + assert.strictEqual(run(500).length, 1); + }); + + it('should pick the level from the status', () => { + config.logrequests = true; + assert.strictEqual(run(200)[0].level, 'info'); + assert.strictEqual(run(404)[0].level, 'warn'); + assert.strictEqual(run(500)[0].level, 'error'); + }); + + // A 400 without its body is diagnosed by guesswork, and a 500 rarely + // reproduces on demand. + it('should carry the request context when the response is an error', () => { + config.logrequests = true; + const { meta } = run(500, { body: { title: 'Dune' }, query: { page: '2' } })[0]; + assert.deepStrictEqual(meta.body, { title: 'Dune' }); + assert.deepStrictEqual(meta.query, { page: '2' }); + }); + + it('should carry no context on a successful response', () => { + config.logrequests = true; + const { meta } = run(200, { body: { title: 'Dune' } })[0]; + assert.strictEqual(meta.body, undefined); + }); + + // The point of going through redact(): a failed sign-in must not drop a + // password into the logs, where it would be kept and searchable. + it('should redact sensitive fields of the body', () => { + config.logrequests = true; + const { meta } = run(401, { + body: { email: 'a@b.c', motDePasse: 'sup3rS3cret', password: 'other' }, + })[0]; + assert.strictEqual(meta.body.motDePasse, '[redacted]'); + assert.strictEqual(meta.body.password, '[redacted]'); + assert.strictEqual(meta.body.email, 'a@b.c'); + }); + + it('should truncate an oversized body', () => { + config.logrequests = true; + const { meta } = run(400, { body: { blob: 'x'.repeat(5000) } })[0]; + assert.strictEqual(typeof meta.body, 'string'); + assert.match(meta.body, /chars\)$/); + }); + + // The response is what the client was actually answered: without it, a + // problem document has to be inferred from the status alone. + it('should carry the response body of an error', () => { + config.logrequests = true; + const lines = []; + const log = logger.log; + logger.log = (level, message, meta) => lines.push({ level, message, meta }); + + let finish; + const req = { method: 'POST', originalUrl: '/api/books', headers: {} }; + const res = { + statusCode: 200, + setHeader: () => {}, + on: (_e, cb) => { finish = cb; }, + json: (body) => { res.sent = body; return res; }, + }; + + try { + middleware(req, res, () => {}); + res.statusCode = 422; + res.json({ type: 'urn:igo:validation-failed', status: 422 }); + finish(); + } finally { + logger.log = log; + } + + assert.deepStrictEqual(lines[0].meta.response, + { type: 'urn:igo:validation-failed', status: 422 }); + }); + + it('should carry no response body on success', () => { + config.logrequests = true; + const { meta } = run(200)[0]; + assert.strictEqual(meta.response, undefined); + }); + + it('should carry method, path, status and duration', () => { + config.logrequests = true; + const { meta } = run(200)[0]; + assert.strictEqual(meta.method, 'GET'); + assert.strictEqual(meta.path, '/api/books'); + assert.strictEqual(meta.status, 200); + assert.strictEqual(typeof meta.duration_ms, 'number'); + }); + +}); From 3ca0ee729cef48aac0ffb3700b49351f5aa8f301 Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Thu, 10 Sep 2026 10:23:54 +0200 Subject: [PATCH 44/80] =?UTF-8?q?fix(server):=20rendre=20ApiHandler=20assi?= =?UTF-8?q?gnable=20derri=C3=A8re=20un=20middleware?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une route protégée dont le handler déclare un schéma query ne compilait pas : Express contraint son type de query à ParsedQs et prend les handlers d une route dans un paramètre rest, donc un seul type doit satisfaire tous les handlers de l appel. Un middleware ordinaire le fixe à ParsedQs, et plus aucune surcharge ne correspond — l erreur rapportée étant celle de la dernière essayée, elle parle de chunkedEncoding et ne dit rien de la cause. Le contournement — monter la garde sur le routeur — impose que toutes ses routes soient protégées. Un routeur mixte, liste réservée à côté de lectures publiques, n avait alors aucune issue. La sortie du schéma est donc intersectée avec ParsedQs. Le coût : lire un champ absent du schéma ne déclenche plus d erreur à l accès, son type restant large assez pour qu un usage typé le rattrape. Trois autres formes ont été essayées — réécrire query par-dessus un Request conforme, et deux variantes d index signature — toutes échouent : satisfaire une contrainte ouverte impose d hériter de son ouverture. Co-Authored-By: Claude Opus 5 --- packages/server/src/api/handler.d.ts | 19 ++++++++++++++++++- packages/server/test/types/valid.ts | 13 +++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/server/src/api/handler.d.ts b/packages/server/src/api/handler.d.ts index bc9965c7..254a3862 100644 --- a/packages/server/src/api/handler.d.ts +++ b/packages/server/src/api/handler.d.ts @@ -1,8 +1,25 @@ import type { Request, Response, NextFunction } from 'express'; +import type { ParsedQs } from 'qs'; import type { StandardSchemaV1 } from '@standard-schema/spec'; type Infer = S extends StandardSchemaV1 ? StandardSchemaV1.InferOutput : never; +/** + * Express constrains its query type to ParsedQs, and takes the handlers of one + * route in a rest parameter — so a single query type has to satisfy every + * handler of the call. A plain middleware pins it to ParsedQs, and a handler + * whose query was only the schema output then matched no overload: the error + * TypeScript reported was the last one it tried, about ErrorRequestHandler and + * chunkedEncoding, which said nothing of the cause. + * + * Hence the intersection below, which keeps a guard and a paginated handler on + * the same route. It has a cost: ParsedQs carries an index signature, so + * reading a field absent from the schema is no longer a compile error — only + * its type is. Rewriting `query` on top of a conforming Request would keep that + * check, but breaks the structural match Express needs, so no overload matches + * again. + */ + /** * An API handler whose request is shaped by the schemas attached to it. * @@ -26,7 +43,7 @@ export interface ApiHandler< Schemas['params'] extends StandardSchemaV1 ? Infer : Record, unknown, Schemas['body'] extends StandardSchemaV1 ? Infer : unknown, - Schemas['query'] extends StandardSchemaV1 ? Infer : Record + Schemas['query'] extends StandardSchemaV1 ? Infer & ParsedQs : ParsedQs >, res: Response, next: NextFunction diff --git a/packages/server/test/types/valid.ts b/packages/server/test/types/valid.ts index bd854f9a..96203936 100644 --- a/packages/server/test/types/valid.ts +++ b/packages/server/test/types/valid.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { Router, type RequestHandler } from 'express'; import type { ApiHandler } from '../../index'; const CreateBook = z.object({ @@ -24,3 +25,15 @@ export const index: ApiHandler<{ query: typeof ListBooks }> = (req, res) => { res.json({ page, status }); }; index.query = ListBooks; + +// A guard in front of a paginated handler: Express takes the handlers of one +// route in a rest parameter, so the middleware used to pin the query type to +// ParsedQs and no overload matched. The ADR asks every API controller to cover +// its refused-access case, so this is the ordinary shape, not an edge case. +const guard: RequestHandler = (_req, _res, next) => next(); + +const router = Router(); +router.get('/books', index); +router.get('/books/guarded', guard, index); +router.get('/books/twice', guard, guard, index); +router.post('/books', guard, create); From 62df221a0b78e25296d05a8bcc10f2d30cdd0233 Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Thu, 10 Sep 2026 10:24:03 +0200 Subject: [PATCH 45/80] =?UTF-8?q?feat(server):=20=C3=A9mettre=20finish=20s?= =?UTF-8?q?ur=20la=20r=C3=A9ponse=20simul=C3=A9e=20des=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mockResponse() était un objet nu, sans res.on() ni événement. Express émet finish quand une réponse est écrite, et les middlewares y accrochent leur travail d après-coup : un log de requête, une métrique, une piste d audit. Un simulacre qui ne l émet jamais rend tout cela intestable. Découvert en écrivant un test sur la journalisation des requêtes en erreur : le middleware ne pouvait rien capturer, alors qu il fonctionnait à l exécution. L événement est émis une seule fois, comme le vrai : un écouteur ajouté après l écriture de la réponse ne le déclenche pas une seconde fois. Co-Authored-By: Claude Opus 5 --- packages/server/src/dev/test/agent.js | 36 +++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/server/src/dev/test/agent.js b/packages/server/src/dev/test/agent.js index 6515b046..6f3ea77f 100644 --- a/packages/server/src/dev/test/agent.js +++ b/packages/server/src/dev/test/agent.js @@ -73,6 +73,39 @@ const mockResponse = () => { }, 10000); }); + // Express emits 'finish' once a response is written, and middlewares hang + // their after-the-fact work on it — a request log, a metric, an audit trail. + // A mock that never emits it makes all of that untestable. + const listeners = {}; + + res.on = (event, callback) => { + (listeners[event] = listeners[event] || []).push(callback); + return res; + }; + + res.removeListener = (event, callback) => { + listeners[event] = (listeners[event] || []).filter(cb => cb !== callback); + return res; + }; + + res.emit = (event, ...args) => { + for (const callback of listeners[event] || []) { + callback(...args); + } + return (listeners[event] || []).length > 0; + }; + + // Emitted once, like the real thing: a listener added after the response is + // written must not fire it a second time. + let finished = false; + const finish = () => { + if (finished) { + return; + } + finished = true; + res.emit('finish'); + }; + res.getHeader = (name) => { return res.headers[name]; }; @@ -88,6 +121,7 @@ const mockResponse = () => { } res.statusCode = statusCode; res.redirectUrl = redirectUrl; + finish(); resolveResponse(res); }; @@ -101,6 +135,7 @@ const mockResponse = () => { res.send = (data) => { res.body = data; + finish(); resolveResponse(res); }; @@ -108,6 +143,7 @@ const mockResponse = () => { if (chunk) { res.body += chunk; } + finish(); resolveResponse(res); }; From 501a8a7ab9714ffde62d7218c0e4bffdf5d71e7c Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Thu, 10 Sep 2026 10:24:13 +0200 Subject: [PATCH 46/80] =?UTF-8?q?feat(server):=20cr=C3=A9er=20les=20.env?= =?UTF-8?q?=20et=20le=20d=C3=A9p=C3=B4t=20git=20=C3=A0=20la=20g=C3=A9n?= =?UTF-8?q?=C3=A9ration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un squelette livre des .env.example, mais le projet a besoin d un .env pour démarrer : son absence coûtait une première erreur incompréhensible. Ils sont désormais copiés, sans jamais écraser un fichier existant. Le dépôt git est initialisé pour deux raisons : le script prepare de husky échoue sans lui — visible à chaque installation par un .git can't be found — et un projet sans historique n a aucun filet pour ses premières modifications. L échec n est pas fatal : git peut être absent, ou le dossier déjà dans un dépôt. Le CLI reste silencieux en environnement de test : un reporter qui analyse la sortie standard ne supporte pas une ligne de trop. Co-Authored-By: Claude Opus 5 --- packages/server/cli/create.js | 60 ++++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/packages/server/cli/create.js b/packages/server/cli/create.js index 351a5d9d..0a121dcf 100644 --- a/packages/server/cli/create.js +++ b/packages/server/cli/create.js @@ -1,11 +1,14 @@ +const { execFile } = require('child_process'); const fs = require('fs/promises'); const path = require('path'); +const { promisify } = require('util'); const _ = require('lodash'); const fse = require('fs-extra'); +const config = require('../src/config'); const utils = require('../src/utils'); // rename files starting with _. to . in the project directory @@ -58,6 +61,44 @@ const replaceInDirectory = async (dir, replacements) => { } }; +// A skeleton ships .env.example files; the project needs a .env to boot. A +// missing one costs a confusing first error, so copy them — never overwriting +// an existing .env. +const seedEnvFiles = async (dir) => { + const copied = []; + + const walk = async (current) => { + const entries = await fs.readdir(current, { withFileTypes: true }); + for (const entry of entries) { + const full = path.join(current, entry.name); + if (entry.isDirectory() && entry.name !== 'node_modules') { + await walk(full); + } else if (entry.name === '.env.example') { + const target = path.join(current, '.env'); + if (!await fse.pathExists(target)) { + await fse.copy(full, target); + copied.push(path.relative(dir, target)); + } + } + } + }; + + await walk(dir); + return copied; +}; + +// The husky prepare script runs on install and fails without a repository, and +// a project with no history has no safety net for its first changes. Failure is +// not fatal: git may be absent, or the directory already inside a repository. +const initRepository = async (dir) => { + try { + await promisify(execFile)('git', ['init', '--quiet'], { cwd: dir }); + return true; + } catch { + return false; + } +}; + // igo create const SKELETONS = ['tailwind', 'fullstack']; @@ -92,5 +133,22 @@ module.exports = async function (argv) { '{RANDOM_3}': utils.randomString(40) }; - return await replaceInDirectory(directory, replacements); + await replaceInDirectory(directory, replacements); + + const envFiles = await seedEnvFiles(directory); + const repository = await initRepository(directory); + + // Tests call this function directly, and a reporter parsing stdout cannot + // afford a stray line. + if (config.env === 'test') { + return; + } + + console.log(`Created ${args[1]} from the ${model} skeleton.`); + if (envFiles.length) { + console.log(` .env written: ${envFiles.join(', ')}`); + } + if (!repository) { + console.log(' no git repository created — run `git init` yourself'); + } }; From 866e033695adb2e6ce986978b343a46b9026972f Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Thu, 10 Sep 2026 10:24:48 +0200 Subject: [PATCH 47/80] =?UTF-8?q?fix(skel):=20rendre=20les=20ports=20confi?= =?UTF-8?q?gurables=20et=20raccourcir=20les=20d=C3=A9lais=20E2E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les ports de MySQL et Valkey étaient en dur : un poste qui fait déjà tourner un de ces services devait éditer deux fichiers. Ils se déplacent depuis un .env à la racine, qui n existait pas. Le délai d attente des serveurs E2E passe de 120 et 60 secondes à 10. L API démarre en 2 secondes mesurées, donc toute cause d échec au démarrage se payait deux minutes de silence, suivies d un message qui ne dit pas ce qui a échoué. Vérifié sur ce squelette : la sonde visait une route de la feature d exemple, et le diagnostic a pris 10 secondes au lieu de deux minutes. Co-Authored-By: Claude Opus 5 --- packages/server/skel/fullstack/_.env.example | 9 +++++++++ .../server/skel/fullstack/docker-compose.yml | 20 ++++++++++++++----- .../skel/fullstack/e2e/playwright.config.ts | 20 ++++++++++--------- 3 files changed, 35 insertions(+), 14 deletions(-) create mode 100644 packages/server/skel/fullstack/_.env.example diff --git a/packages/server/skel/fullstack/_.env.example b/packages/server/skel/fullstack/_.env.example new file mode 100644 index 00000000..0a5e4aeb --- /dev/null +++ b/packages/server/skel/fullstack/_.env.example @@ -0,0 +1,9 @@ +# Copier vers .env — ne jamais committer .env +# +# Ce fichier ne sert qu'à docker compose. La configuration de l'application est +# dans api/.env et front/.env. + +# Ports publiés par docker compose. À déplacer si le poste fait déjà tourner un +# MySQL ou un Valkey — et à reporter dans api/.env, que l'application lit. +# MYSQL_PORT=3306 +# REDIS_PORT=6379 diff --git a/packages/server/skel/fullstack/docker-compose.yml b/packages/server/skel/fullstack/docker-compose.yml index 0ff17138..a149b030 100644 --- a/packages/server/skel/fullstack/docker-compose.yml +++ b/packages/server/skel/fullstack/docker-compose.yml @@ -1,7 +1,17 @@ -# Local dependencies. The app itself runs natively — `pnpm start`. +# Les dépendances locales. L'application elle-même tourne nativement — +# `pnpm start`. # # docker compose up -d # +# Les ports publiés valent les ports habituels par défaut, et se déplacent +# depuis le .env de la racine — utile sur un poste qui fait déjà tourner un +# MySQL ou un Valkey : +# +# MYSQL_PORT=3308 +# REDIS_PORT=6382 +# +# Ce qui est choisi ici doit correspondre à api/.env, que l'application lit. +# services: mysql: image: mysql:8 @@ -10,9 +20,9 @@ services: MYSQL_ALLOW_EMPTY_PASSWORD: 'yes' MYSQL_DATABASE: '{project.name}' ports: - - '3306:3306' - # igo connects in utf8mb4: a server left on its default charset breaks on - # the first accented character. + - '${MYSQL_PORT:-3306}:3306' + # igo se connecte en utf8mb4 : un serveur laissé sur son jeu de caractères + # par défaut casse au premier caractère accentué. command: >- --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci @@ -28,7 +38,7 @@ services: image: valkey/valkey:8 restart: unless-stopped ports: - - '6379:6379' + - '${REDIS_PORT:-6379}:6379' healthcheck: test: ['CMD', 'valkey-cli', 'ping'] interval: 10s diff --git a/packages/server/skel/fullstack/e2e/playwright.config.ts b/packages/server/skel/fullstack/e2e/playwright.config.ts index f3156c23..6444dbcf 100644 --- a/packages/server/skel/fullstack/e2e/playwright.config.ts +++ b/packages/server/skel/fullstack/e2e/playwright.config.ts @@ -20,27 +20,29 @@ export default defineConfig({ projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], - // Two servers: the API, and the built front that proxies /api to it — the - // way nginx does in production. Playwright starts both and stops them after. + // Deux serveurs : l'API, et le front construit qui lui proxifie /api — comme + // nginx le fait en production. Playwright démarre les deux et les arrête + // ensuite. webServer: process.env.E2E_BASE_URL ? undefined : [ { - // in CI the build is what ships, so that is what gets tested; - // locally, tsx watch avoids a rebuild on every run + // en CI, c'est le build qui part en production, donc c'est lui qu'on + // teste ; en local, tsx watch évite une reconstruction à chaque essai command: process.env.CI ? 'pnpm --filter ../api serve' : 'pnpm --filter ../api start', - // polled to know the API is up: any route it answers 2xx on will do + // interrogée pour savoir si l'API répond : n'importe quelle route sur + // laquelle elle rend un 2xx convient url: `${API_URL}/api/books`, reuseExistingServer: !process.env.CI, - timeout: 120_000, + timeout: 10_000, }, { - // --host binds 127.0.0.1 too: vite listens on localhost (IPv6) by - // default, which the url below would never reach. + // --host écoute aussi sur 127.0.0.1 : vite écoute par défaut sur + // localhost (IPv6), que l'url ci-dessous n'atteindrait jamais. command: `pnpm --filter ../front exec vite preview --port ${PORT} --strictPort --host 127.0.0.1`, url: BASE_URL, reuseExistingServer: !process.env.CI, - timeout: 60_000, + timeout: 10_000, }, ], }); From 70c5c9345ec65950a2d9b78edc495f0987ebde16 Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Thu, 10 Sep 2026 10:25:07 +0200 Subject: [PATCH 48/80] =?UTF-8?q?feat(skel):=20livrer=20l=20observabilit?= =?UTF-8?q?=C3=A9=20du=20socle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le squelette ne contenait rien d OpenTelemetry ni de Faro : chaque projet refaisait le câblage, et l ADR estimait ce travail à une journée. Il est ici fait une fois. Rien n est envoyé par défaut : l absence d OTEL_EXPORTER_OTLP_ENDPOINT côté API et de VITE_FARO_URL côté front désactive tout. Un poste de développement ne consomme donc aucun quota, et les tests E2E n envoient rien. Le back parle OpenTelemetry, un protocole neutre — le code ne nomme aucun fournisseur. Le front utilise Faro, qui lui est spécifique : c est le seul endroit du socle qui suppose Grafana, faute d équivalent neutre pour les Web Vitals et les erreurs navigateur. instrumentation.ts est chargé par --import, donc avant igo, et sa première ligne importe le point d entrée qui charge le .env. Les détecteurs de ressource sont restreints à env : les défauts en ajoutent treize, dont process.pid, qui change à chaque redémarrage et crée un jeu de séries neuf à chaque fois. alloy/config.alloy.example est un point de départ, non actif : la destination et les exporteurs d infrastructure dépendent de la plateforme. Son en-tête documente les pièges de cardinalité mesurés, dont disable_collectors sur l exporteur MySQL — 999 séries ramenées à 475. Côté front, deux frontières d erreur qui ne se recouvrent pas : celle de main.tsx capte le rendu, l errorElement de routes.tsx capte ce que react-router intercepte lui-même. Retirer l une rend ses erreurs invisibles. Co-Authored-By: Claude Opus 5 --- .../skel/fullstack/alloy/config.alloy.example | 355 ++++++++++++++++++ .../server/skel/fullstack/api/_.env.example | 14 +- .../server/skel/fullstack/api/app/config.ts | 8 + .../skel/fullstack/api/instrumentation.ts | 102 +++++ .../server/skel/fullstack/api/package.json | 19 +- .../server/skel/fullstack/api/tsconfig.json | 2 +- .../server/skel/fullstack/front/_.env.example | 30 ++ .../server/skel/fullstack/front/package.json | 8 +- .../src/components/layout/error-boundary.tsx | 14 + .../src/components/layout/error-page.tsx | 20 + .../src/components/layout/route-error.tsx | 27 ++ .../fullstack/front/src/lib/api-client.ts | 25 +- .../fullstack/front/src/lib/report-error.ts | 13 + .../server/skel/fullstack/front/src/main.tsx | 11 +- .../skel/fullstack/front/src/observability.ts | 145 +++++++ .../skel/fullstack/front/src/routes.tsx | 14 +- .../skel/fullstack/front/vite.config.ts | 85 +++-- .../skel/fullstack/front/vitest.config.ts | 15 +- .../server/skel/fullstack/pnpm-workspace.yaml | 2 + 19 files changed, 854 insertions(+), 55 deletions(-) create mode 100644 packages/server/skel/fullstack/alloy/config.alloy.example create mode 100644 packages/server/skel/fullstack/api/instrumentation.ts create mode 100644 packages/server/skel/fullstack/front/src/components/layout/error-boundary.tsx create mode 100644 packages/server/skel/fullstack/front/src/components/layout/error-page.tsx create mode 100644 packages/server/skel/fullstack/front/src/components/layout/route-error.tsx create mode 100644 packages/server/skel/fullstack/front/src/lib/report-error.ts create mode 100644 packages/server/skel/fullstack/front/src/observability.ts diff --git a/packages/server/skel/fullstack/alloy/config.alloy.example b/packages/server/skel/fullstack/alloy/config.alloy.example new file mode 100644 index 00000000..4a9e4357 --- /dev/null +++ b/packages/server/skel/fullstack/alloy/config.alloy.example @@ -0,0 +1,355 @@ +// EXEMPLE — à copier en `config.alloy` et à adapter. +// +// Ce fichier n'est pas actif : la destination, les identifiants et les +// exporteurs d'infrastructure dépendent de la plateforme retenue. Il est fourni +// parce que la configuration d'un collecteur se paie sinon en une journée de +// tâtonnements, et parce que les arbitrages ci-dessous ont été mesurés plutôt +// que devinés. +// +// Ce qui est à revoir en le reprenant : +// +// - les blocs `otelcol.exporter.otlp` et `prometheus.remote_write` — la +// destination et les identifiants ; +// - `prometheus.exporter.mysql` et `.redis` — à retirer si le projet n'a pas +// ces services, à compléter s'il en a d'autres ; +// - le chemin des logs, qui suit la façon dont l'application est lancée. +// +// Deux pièges de cardinalité, mesurés : +// +// - `disable_collectors` sur l'exporteur MySQL. Les six collecteurs par +// défaut produisent 999 séries, dont 471 pour `global_variables` — la +// *configuration* du serveur, donc des constantes réenvoyées à chaque +// scrape. Noter que `enabled_collectors` est rejeté et `enable_collectors` +// accepté mais sans effet : seul `disable_collectors` agit. +// - `OTEL_NODE_RESOURCE_DETECTORS=env` côté application (cf. +// api/instrumentation.ts). Sans lui, treize attributs de ressource +// deviennent des étiquettes, dont `process.pid` — qui change à chaque +// redémarrage et crée donc un jeu de séries neuf à chaque fois. +// +// Deux détails qui surprennent à la première lecture d'un tableau de bord : +// +// - Express pose un `ETag` sur les réponses JSON. Un navigateur qui rappelle +// la même route reçoit donc un **304**, classé en `3xx` : une requête qui +// ne compte que les `2xx` rate l'essentiel du trafic réel. +// - `http_client_request_duration` n'a jamais `http_route` — elle mesure les +// appels *sortants*, où il n'existe aucune route. C'est +// `http_server_request_duration` qui porte la route. + +// Alloy reçoit la télémétrie de l'application en OTLP, puis la pousse vers +// Grafana Cloud. L'application n'a donc aucun identifiant Grafana : le secret +// vit ici, et un seul endroit est à faire tourner. +// +// En production ce fichier est déployé par Ansible à côté de l'application ; +// en local il tourne dans un conteneur (cf. docker-compose.yml). + +logging { + level = "info" + format = "logfmt" +} + +// ── Réception ──────────────────────────────────────────────────────────────── + +otelcol.receiver.otlp "app" { + // 0.0.0.0 et non localhost : depuis un conteneur, l'application est un hôte + // distant. + http { endpoint = "0.0.0.0:4318" } + grpc { endpoint = "0.0.0.0:4317" } + + output { + metrics = [otelcol.processor.batch.defaut.input] + logs = [otelcol.processor.batch.defaut.input] + traces = [otelcol.processor.batch.defaut.input] + } +} + +// Le lot amortit les à-coups : sans lui, chaque requête HTTP de l'application +// provoquerait un appel réseau vers Grafana. +otelcol.processor.batch "defaut" { + output { + metrics = [otelcol.exporter.prometheus.vers_grafana.input] + // L'application n'envoie pas ses logs en OTLP : elle écrit un fichier que + // loki.source.file suit (voir plus bas). Ce flux reste donc vide. + logs = [] + traces = [otelcol.processor.tail_sampling.traces.input] + } +} + +// ── Métriques ──────────────────────────────────────────────────────────────── + +otelcol.exporter.prometheus "vers_grafana" { + // Sans cette option, les attributs de ressource — dont `service.name`, + // `service.version` et `deployment.environment.name` — ne deviennent pas des + // étiquettes Prometheus : les métriques arrivent sans savoir de quel service + // elles viennent. Elle est désactivée par défaut. + resource_to_telemetry_conversion = true + + forward_to = [prometheus.remote_write.grafana.receiver] +} + +prometheus.remote_write "grafana" { + endpoint { + url = sys.env("GRAFANA_METRICS_URL") + basic_auth { + username = sys.env("GRAFANA_METRICS_ID") + password = sys.env("GRAFANA_TOKEN") + } + } +} + +// ── Logs ───────────────────────────────────────────────────────────────────── + +loki.write "grafana" { + endpoint { + url = sys.env("GRAFANA_LOGS_URL") + basic_auth { + username = sys.env("GRAFANA_LOGS_ID") + password = sys.env("GRAFANA_TOKEN") + } + } +} + +// ── Traces ─────────────────────────────────────────────────────────────────── + +// Tempo parle OTLP nativement : pas de conversion, contrairement aux métriques +// et aux logs. +// ── Échantillonnage des traces ─────────────────────────────────────────────── +// +// Le filtre ci-dessous allège chaque trace ; celui-ci réduit leur *nombre*. Les +// deux axes sont indépendants, et c'est le second qui pèse : une trace par +// requête réussie n'apprend rien que les métriques ne disent déjà, alors qu'une +// trace lente ou en erreur est précisément ce qu'on vient chercher. +// +// « tail » parce que la décision est prise après avoir vu la trace entière, +// contrairement à un tirage à la racine qui ignore encore comment la requête va +// se terminer. Le prix est un tampon : les spans sont retenus `decision_wait` +// avant d'être jugés, donc de la mémoire et un délai avant l'arrivée dans +// Tempo. +// +// L'ordre des politiques n'importe pas — elles sont évaluées toutes ensemble, et +// une seule suffit à garder la trace. +otelcol.processor.tail_sampling "traces" { + // Au-delà de la latence maximale attendue d'une requête, sinon une trace + // lente serait jugée avant d'être terminée. + decision_wait = "10s" + + // Les erreurs serveur. Une exception a une pile et une cascade d'appels que + // la ligne de log seule ne montre pas — c'est le cas où la trace apporte + // vraiment quelque chose. + // + // L'instrumentation HTTP ne marque en ERROR que les 5xx : côté serveur, un + // 4xx est un comportement normal, la faute étant au client. + policy { + name = "spans_en_erreur" + type = "status_code" + + status_code { + status_codes = ["ERROR"] + } + } + + // Les 4xx ne sont pas retenus, et ce n'est pas une économie de volume — leur + // part est marginale. C'est qu'une trace n'y apprend rien : + // + // • un 401 ou un 404 est le quotidien d'une application — session expirée, + // lien mort. Leur taux se surveille par métrique ; + // • un 403 ou un 422 est décidé *en mémoire*, avant toute I/O : la trace ne + // contient qu'un span racine et la requête SQL qui a chargé les données. + // Ce qu'on voudrait savoir — avec quelles données le refus s'est produit — + // est déjà dans la ligne de log, qui porte le corps, la query, les + // paramètres de route et la réponse renvoyée (cf. config.logrequests). + // + // Une trace vaut quand le problème est distribué ou temporel. Un refus métier + // synchrone n'est ni l'un ni l'autre. + + // Pas de politique sur la latence non plus. Un seuil absolu suppose de + // connaître la latence normale de chaque route, ce qui n'est vrai qu'après + // l'avoir mesurée et change avec le volume de données : sur cette démo les + // routes vont de 4 à 14 ms, un facteur 3,5 entre elles. Trop haut, le seuil + // n'attrape rien ; trop bas, il retient tout le trafic de la route la plus + // lente. + // + // Le P95 par route vient des métriques HTTP standard, et l'échantillon + // ci-dessous donne des traces d'exemple pour aller voir où le temps passe. + + // Le nominal. Un échantillon suffit à garder un exemple de chaque parcours ; + // le débit et la latence viennent des métriques. + policy { + name = "echantillon_du_nominal" + type = "probabilistic" + + probabilistic { + sampling_percentage = 10 + } + } + + output { + traces = [otelcol.processor.filter.spans.input] + } +} + +// ── Pas de métriques dérivées des spans ───────────────────────────────────── +// +// Un connecteur `spanmetrics` produirait débit, taux d'erreur et latence depuis +// les spans. Il a été retiré : `instrumentation-http` fournit déjà +// `http.server.request.duration` avec les mêmes dimensions — `http.route`, +// `http.request.method`, `http.response.status_code` — et c'est une convention +// OpenTelemetry, donc un tableau de bord générique la reconnaît. +// +// Ce qu'on y perd, et qu'il faut savoir : +// +// • les seuils d'histogramme du standard commencent à 5 ms, alors que le P50 +// mesuré est à 3 ms : plus de la moitié des requêtes tombent dans le premier +// seuil, où le quantile est interpolé. Le P50 devient une estimation ; +// • le regroupement des statuts en classes (2xx/3xx/5xx, 4xx détaillés) n'est +// plus précalculé : il se fait en PromQL, par exemple +// `label_replace(..., "classe", "$1xx", "http_response_status_code", "(.)..")`. +// +// L'arbitrage retenu : une métrique standard qu'on ne maintient pas vaut mieux +// qu'une métrique sur mesure qui dérive. + +// ── Filtrage des spans ─────────────────────────────────────────────────────── +// +// Tous les spans de middleware et de routage sont jetés — y compris ceux des +// middlewares applicatifs. Mesuré sur un GET /api/animaux de 9,77 ms : +// `marquerRequete` 5,17 µs et `chargerCompte` 8,88 µs, soit **0,14 % de la +// requête**. Deux spans par requête pour mesurer l'invisible. +// +// Ce qui reste dit tout : le span racine (durée totale, statut, route), le +// `request handler` (le temps passé dans le contrôleur) et les `SELECT` (là où +// le temps part réellement — 7,42 et 5,32 ms dans cet exemple). +// +// Si un middleware ralentissait un jour, l'écart entre le span racine et le +// `request handler` le montrerait, et son éventuelle requête SQL apparaîtrait +// de toute façon : l'instrumentation de la base est indépendante. +otelcol.processor.filter "spans" { + error_mode = "ignore" + + traces { + span = [ + "IsMatch(name, \"^(middleware|router) - \")", + ] + } + + output { + traces = [otelcol.exporter.otlp.tempo.input] + } +} + +// Tempo n'accepte que l'OTLP en gRPC : `otelcol.exporter.otlp` et un endpoint +// réduit à `hôte:443`, sans chemin. La variante HTTP (`otelcol.exporter.otlphttp`) +// répond 404 sur tous les chemins, ce qui envoie chercher une URL qui n'existe +// pas. +otelcol.exporter.otlp "tempo" { + client { + endpoint = sys.env("GRAFANA_TRACES_URL") + auth = otelcol.auth.basic.tempo.handler + } +} + +otelcol.auth.basic "tempo" { + username = sys.env("GRAFANA_TRACES_ID") + password = sys.env("GRAFANA_TOKEN") +} + +// ── Services managés ───────────────────────────────────────────────────────── +// +// C'est l'argument n° 1 retenu par l'ADR contre Sentry : « les intégrations +// services managés font la différence. MySQL et Redis OVH sont monitorés via +// les endpoints standard, avec 80+ métriques et des dashboards pré-construits. +// Sentry ne voit que le code applicatif — la DB et le cache sont des boîtes +// noires. » +// +// En production, ces mêmes blocs visent les endpoints OVH au lieu des +// conteneurs locaux : seule l'adresse change. + +prometheus.exporter.mysql "base" { + // Compte de supervision aux droits minimaux, cf. mysql-init/01-exporter.sql + data_source_name = "exporter:exporter@(mysql:3306)/" + + // Les six collecteurs par défaut produisent 999 séries. Ceux-ci n'apportent + // rien au projet et pèsent la moitié du total : + // + // global_variables 471 séries — la *configuration* du serveur, + // donc des constantes réenvoyées à chaque + // scrape : 1,36 million d'échantillons par jour + // pour des valeurs qui ne bougent pas. + // info_schema.innodb_cmp 45 séries — compression InnoDB, non utilisée + // info_schema.innodb_cmpmem 20 séries — idem + // + // Mesuré : 999 séries avant, 475 après — 1,5 million d échantillons par jour + // + // Ce qu'on y perd : `max_connections`, qui servait de plafond au panneau des + // connexions. Le seuil est donc écrit en clair dans le tableau de bord — à + // revoir si la configuration de MySQL change. + disable_collectors = [ + "global_variables", + "info_schema.innodb_cmp", + "info_schema.innodb_cmpmem", + ] +} + +prometheus.exporter.redis "cache" { + // Valkey parle le protocole Redis : l'exporteur Redis fonctionne tel quel. + redis_addr = "valkey:6379" +} + +// Les cibles des exporteurs se scrutent comme n'importe quelle cible +// Prometheus, puis rejoignent le même flux de sortie que les métriques +// applicatives. +prometheus.scrape "services" { + targets = concat( + prometheus.exporter.mysql.base.targets, + prometheus.exporter.redis.cache.targets, + ) + forward_to = [prometheus.remote_write.grafana.receiver] + scrape_interval = "30s" +} + +// ── Logs de l'application ──────────────────────────────────────────────────── +// +// igo écrit ses logs JSON sur la sortie standard. Rien ne les collecte tout +// seul : Alloy doit les lire quelque part, et il ne peut pas lire le stdout +// d'un process qu'il ne lance pas — c'est une limite du système, le stdout +// n'étant accessible qu'au parent. +// +// En production l'application tourne sous pm2, qui écrit dans +// `~/.pm2/logs/-out.log` — c'est ce fichier qu'Alloy suit. En local, le +// script `pnpm start` redirige la sortie vers `api/logs/app.log`, monté dans le +// conteneur. +// +// Pousser les logs en OTLP depuis l'application serait l'autre voie. Elle est +// écartée : les logs seraient perdus si l'application meurt avant l'envoi, et +// c'est précisément au moment d'un crash qu'on en a besoin. Essayé sur cette +// démo, l'instrumentation winston n'a de surcroît rien émis. + +local.file_match "app" { + path_targets = [{ + __path__ = "/var/log/{project.name}/*.log", + service_name = "{project.name}-api", + }] +} + +loki.source.file "app" { + targets = local.file_match.app.targets + forward_to = [loki.process.json.receiver] +} + +loki.process "json" { + stage.json { + expressions = { + level = "level", + service = "service", + environment = "environment", + version = "version", + } + } + + stage.labels { + values = { + level = "", + environment = "", + } + } + + forward_to = [loki.write.grafana.receiver] +} diff --git a/packages/server/skel/fullstack/api/_.env.example b/packages/server/skel/fullstack/api/_.env.example index 6e5d3ba2..fa524a5d 100644 --- a/packages/server/skel/fullstack/api/_.env.example +++ b/packages/server/skel/fullstack/api/_.env.example @@ -11,7 +11,7 @@ MYSQL_HOST=127.0.0.1 MYSQL_PORT=3306 MYSQL_USERNAME=root MYSQL_PASSWORD= -MYSQL_DATABASE= +MYSQL_DATABASE={project.name} REDIS_HOST=127.0.0.1 REDIS_PORT=6379 @@ -24,3 +24,15 @@ REDIS_PORT=6379 # SMTP_USER= # SMTP_PASSWORD= # SMTP_FROM= + +# --- Observability (optional) --------------------------------------------- +# Nothing is sent while OTEL_EXPORTER_OTLP_ENDPOINT is unset: no quota spent on +# a development machine, and no local data mixed into production's. +# +# The application never writes to a platform directly. It speaks OTLP to a +# local collector — Grafana Alloy — which relays, filters and derives metrics. +# Point this at that collector, not at a vendor URL. +# OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 + +# Name the service appears under. Defaults to the package name. +# OTEL_SERVICE_NAME={project.name} diff --git a/packages/server/skel/fullstack/api/app/config.ts b/packages/server/skel/fullstack/api/app/config.ts index 0221d7db..c3e6ec87 100644 --- a/packages/server/skel/fullstack/api/app/config.ts +++ b/packages/server/skel/fullstack/api/app/config.ts @@ -3,4 +3,12 @@ import type { Config } from '@igojs/server'; export const init = (config: Config) => { config.cookieSecret = '{RANDOM_1}'; config.cookieSession.keys = ['{RANDOM_2}']; + + // Une ligne par requête est le premier poste d'un volume de logs, et les + // requêtes qui réussissent n'apprennent rien que les métriques ne portent + // déjà : latence par route et taux d'erreur se dérivent des spans. + // + // À décommenter quand l'observabilité est branchée — pas avant, sinon on perd + // les seules traces d'activité dont on dispose. + // config.logrequests = 400; }; diff --git a/packages/server/skel/fullstack/api/instrumentation.ts b/packages/server/skel/fullstack/api/instrumentation.ts new file mode 100644 index 00000000..9e9e2360 --- /dev/null +++ b/packages/server/skel/fullstack/api/instrumentation.ts @@ -0,0 +1,102 @@ +import '@igojs/server/env'; + +import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'; +import { resourceFromAttributes } from '@opentelemetry/resources'; +import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; +import { NodeSDK } from '@opentelemetry/sdk-node'; +import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'; + +// Chargé par --import, AVANT igo : OpenTelemetry instrumente en remplaçant les +// modules au moment du require, donc ce fichier doit passer en premier. Importé +// depuis app.ts, il n'instrumenterait ni express ni mysql2. +// +// Conséquence : le .env n'est pas encore lu, puisque c'est la configuration +// d'igo qui appelle dotenv. Sans le premier import ci-dessus, toute variable +// OTEL_* lue plus bas vaudrait undefined et le SDK ne démarrerait pas — +// silencieusement, sans erreur ni donnée. Ce point d'entrée charge le .env sans +// rien initialiser, là où importer @igojs/server tirerait express et winston, +// soit précisément ce que ce fichier devait précéder. + +// Les détecteurs par défaut ajoutent treize attributs de ressource — dont +// process.pid, process.command_args, process.executable.path, host.id — et un +// collecteur les convertit en étiquettes. +// +// Le coût n'est pas le nombre d'étiquettes mais la cardinalité : process.pid +// change à chaque redémarrage, donc chaque relance crée un jeu de séries neuf, +// facturé comme tel. Et un chemin d'exécutable dans une étiquette n'apprend +// rien qu'on veuille interroger. +// +// `env` seul est conservé : il lit OTEL_RESOURCE_ATTRIBUTES, ce qui laisse un +// déploiement ajouter ce qu'il juge utile. Le reste est déclaré ci-dessous, à +// la main. +process.env.OTEL_NODE_RESOURCE_DETECTORS ??= 'env'; + +const sdk = new NodeSDK({ + // OTel 2.x n'expose plus de classe Resource : les attributs passent par + // resourceFromAttributes. + resource: resourceFromAttributes({ + [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || '{project.name}-api', + [ATTR_SERVICE_VERSION]: process.env.APP_VERSION || '0.0.1', + 'deployment.environment.name': process.env.NODE_ENV || 'dev', + }), + traceExporter: new OTLPTraceExporter(), + metricReader: new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter(), + exportIntervalMillis: 15_000, + }), + // Pas d'exporteur de logs : igo écrit déjà du JSON structuré sur la sortie + // standard, que le collecteur lit. Les pousser aussi en OTLP les dupliquerait. + // L'instrumentation winston ci-dessous sert uniquement à estampiller ces + // lignes avec le trace_id. + instrumentations: [ + getNodeAutoInstrumentations({ + // Le système de fichiers produit un span par lecture : illisible, et le + // volume à lui seul épuise un quota. + '@opentelemetry/instrumentation-fs': { enabled: false }, + // Express reste instrumenté : c'est elle qui pose `http.route` — le motif + // de la route, pas l'URL brute — sur le span *et* sur la métrique du + // serveur. Sans elle, la latence par route n'est pas calculable. + // + // Elle coûte environ 56 spans de plomberie par requête sur 66. Ce volume + // appartient au collecteur, qui peut dériver les métriques des spans avant + // de les jeter. La désactiver ici obligerait à choisir entre volume et + // finesse ; traiter le problème là-bas ne coûte rien. + '@opentelemetry/instrumentation-express': { enabled: true }, + // Le routage interne d'Express n'apprend rien : 21 spans `router - …`. + '@opentelemetry/instrumentation-router': { enabled: false }, + '@opentelemetry/instrumentation-mysql2': { enabled: true }, + // igo écrit déjà ses logs en JSON avec service/version/environment : on + // veut l'estampille trace_id, pas une seconde copie des logs. + '@opentelemetry/instrumentation-winston': { + enabled: true, + disableLogSending: true, + }, + }), + ], +}); + +// Pas de destination, rien à envoyer : l'absence d'OTEL_EXPORTER_OTLP_ENDPOINT +// suffit à désactiver l'observabilité. C'est le défaut d'un poste de +// développement — ni quota consommé, ni erreurs locales mélangées à celles de la +// production — et la même règle que côté front, où l'absence de VITE_FARO_URL +// désactive Faro. +// +// L'adresse doit viser un collecteur local (Grafana Alloy), pas directement une +// plateforme : c'est ce qui permet de filtrer, de dériver des métriques et de +// changer de destination sans toucher à ce fichier. +const active = Boolean(process.env.OTEL_EXPORTER_OTLP_ENDPOINT); + +if (active) { + sdk.start(); +} + +const stop = async () => { + if (active) { + await sdk.shutdown(); + } +}; + +process.once('SIGTERM', stop); +process.once('SIGINT', stop); diff --git a/packages/server/skel/fullstack/api/package.json b/packages/server/skel/fullstack/api/package.json index 49d55a79..871ac5d6 100644 --- a/packages/server/skel/fullstack/api/package.json +++ b/packages/server/skel/fullstack/api/package.json @@ -7,29 +7,34 @@ "main": "dist/app.js", "scripts": { "build": "tsc && cp -R sql locales dist/", - "start": "tsx watch app.ts", - "serve": "cd dist && node app.js", + "start": "tsx watch --import ./instrumentation.ts app.ts", + "serve": "cd dist && node --import ./instrumentation.js app.js", "migrate": "igo db migrate", "seed": "NODE_OPTIONS=\"--import tsx\" igo db seed", "lint": "oxlint", "test": "mocha", "typecheck": "tsc --noEmit", - "prepare": "husky", "format": "oxfmt", "format:check": "oxfmt --check" }, "dependencies": { + "@igojs/db": "{igo.version}", "@igojs/igo": "{igo.version}", + "@igojs/server": "{igo.version}", + "@opentelemetry/api": "^1.9.1", "zod": "^4.5.4" }, "devDependencies": { - "@commitlint/cli": "^21.2.0", - "@commitlint/config-conventional": "^21.2.0", + "@opentelemetry/auto-instrumentations-node": "^0.80.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.222.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.222.0", + "@opentelemetry/resources": "^2.11.0", + "@opentelemetry/sdk-metrics": "^2.11.0", + "@opentelemetry/sdk-node": "^0.222.0", + "@opentelemetry/semantic-conventions": "^1.43.0", "@types/express": "^5.0.6", "@types/mocha": "^10.0.10", "@types/node": "^24.0.0", - "husky": "^9.1.7", - "lint-staged": "^17.5.0", "oxfmt": "^0.66.0", "oxlint": "^1.81.0", "tsx": "^4.20.0", diff --git a/packages/server/skel/fullstack/api/tsconfig.json b/packages/server/skel/fullstack/api/tsconfig.json index 804a90b5..f58c3818 100644 --- a/packages/server/skel/fullstack/api/tsconfig.json +++ b/packages/server/skel/fullstack/api/tsconfig.json @@ -12,6 +12,6 @@ "skipLibCheck": true, "types": ["node", "mocha"] }, - "include": ["app.ts", "app/**/*.ts", "seeds/**/*.ts", "test/**/*.ts"], + "include": ["app.ts", "instrumentation.ts", "app/**/*.ts", "seeds/**/*.ts", "test/**/*.ts"], "exclude": ["node_modules", "dist"] } diff --git a/packages/server/skel/fullstack/front/_.env.example b/packages/server/skel/fullstack/front/_.env.example index 47c15306..3be06924 100644 --- a/packages/server/skel/fullstack/front/_.env.example +++ b/packages/server/skel/fullstack/front/_.env.example @@ -1,3 +1,33 @@ # Copy to .env — never commit .env + # Where the dev proxy sends /api. Defaults to http://127.0.0.1:3000. # API_URL=http://127.0.0.1:3000 + +# --- Observability (optional) --------------------------------------------- +# Everything below is off by default. Faro stays disabled while VITE_FARO_URL +# is unset, which is what a development machine wants: no quota spent, and no +# local errors mixed into production's. +# +# The collector URL comes from Grafana Cloud → Frontend Observability → your +# app. It ships to the browser and is not a secret. +# VITE_FARO_URL=https://faro-collector-prod-.grafana.net/collect/ + +# Name shown in Grafana. Must match the app declared there AND the appName the +# source-map uploader sends, or stack traces never resolve. +# VITE_FARO_APP_NAME={project.name} + +# Faro reports this as the environment. Do not rely on Vite's MODE: it reads +# 'production' in every build, local ones included. +# VITE_ENVIRONMENT=dev + +# Share of successful traffic kept (default 0.1). Errors and Web Vitals are +# always kept, whatever this says. +# VITE_FARO_SAMPLE=0.1 + +# --- Source maps (build only, never exposed to the browser) --------------- +# Without these, a stack trace in Grafana points at minified code. All four are +# required together; the API key is a secret. +# FARO_API_KEY= +# FARO_APP_ID= +# FARO_STACK_ID= +# FARO_UPLOAD_ENDPOINT=https://faro-api-prod-.grafana.net/faro/api/v1 diff --git a/packages/server/skel/fullstack/front/package.json b/packages/server/skel/fullstack/front/package.json index c9bd3a86..8b540246 100644 --- a/packages/server/skel/fullstack/front/package.json +++ b/packages/server/skel/fullstack/front/package.json @@ -11,19 +11,19 @@ "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit", - "prepare": "husky", "format": "oxfmt", "format:check": "oxfmt --check" }, "dependencies": { + "@grafana/faro-react": "^2.11.0", + "@grafana/faro-web-tracing": "^2.11.0", "@tanstack/react-query": "^5.102.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router": "^8.3.0" }, "devDependencies": { - "@commitlint/cli": "^21.2.0", - "@commitlint/config-conventional": "^21.2.0", + "@grafana/faro-rollup-plugin": "^0.12.0", "@tailwindcss/vite": "^4.3.0", "@testing-library/jest-dom": "^6.9.0", "@testing-library/react": "^16.3.0", @@ -32,9 +32,7 @@ "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^6.1.0", - "husky": "^9.1.7", "jsdom": "^30.0.0", - "lint-staged": "^17.5.0", "msw": "^2.12.0", "oxfmt": "^0.66.0", "oxlint": "^1.81.0", diff --git a/packages/server/skel/fullstack/front/src/components/layout/error-boundary.tsx b/packages/server/skel/fullstack/front/src/components/layout/error-boundary.tsx new file mode 100644 index 00000000..3c1e4f41 --- /dev/null +++ b/packages/server/skel/fullstack/front/src/components/layout/error-boundary.tsx @@ -0,0 +1,14 @@ +import { FaroErrorBoundary } from '@grafana/faro-react'; +import type { ReactNode } from 'react'; + +import { ErrorPage } from './error-page'; + +// La frontière d'erreur racine : un composant qui lève affiche une page +// générique au lieu d'un écran blanc, et l'incident est signalé. +// +// Le fournisseur d'observabilité est enfermé ici, comme il l'est dans +// reportError() : le point d'entrée de l'application n'a pas à le nommer, et le +// remplacer ne touche que ce fichier. +export function ErrorBoundary({ children }: { children: ReactNode }) { + return }>{children}; +} diff --git a/packages/server/skel/fullstack/front/src/components/layout/error-page.tsx b/packages/server/skel/fullstack/front/src/components/layout/error-page.tsx new file mode 100644 index 00000000..f5b33c9b --- /dev/null +++ b/packages/server/skel/fullstack/front/src/components/layout/error-page.tsx @@ -0,0 +1,20 @@ +// Affichée par la frontière d'erreur racine quand un composant lève. Une page +// générique vaut mieux qu'un écran blanc, qui est précisément ce qui rend une +// panne du front invisible pour tout le monde sauf l'utilisateur. +export function ErrorPage() { + return ( +
      +

      Une erreur est survenue

      +

      + L'incident a été signalé. Vous pouvez recharger la page pour reprendre. +

      + +
      + ); +} diff --git a/packages/server/skel/fullstack/front/src/components/layout/route-error.tsx b/packages/server/skel/fullstack/front/src/components/layout/route-error.tsx new file mode 100644 index 00000000..df0e79d2 --- /dev/null +++ b/packages/server/skel/fullstack/front/src/components/layout/route-error.tsx @@ -0,0 +1,27 @@ +import { useEffect } from 'react'; +import { isRouteErrorResponse, useRouteError } from 'react-router'; + +import { reportError } from '@/lib/report-error'; + +import { ErrorPage } from './error-page'; + +// react-router intercepte lui-même ce qui échoue dans une route — chargement +// d'un module lazy, loader, action — et n'atteint donc jamais la frontière +// d'erreur racine. Sans errorElement, il affiche sa page de secours, et +// l'incident ne remonte nulle part. +// +// Un 404 n'est pas un incident : c'est un lien mort ou une URL saisie à la +// main, que le taux d'erreur des métriques porte déjà. On ne remonte que le +// reste. +export function RouteError() { + const error = useRouteError(); + const attendu = isRouteErrorResponse(error) && error.status === 404; + + useEffect(() => { + if (!attendu) { + reportError(error, { origine: 'route' }); + } + }, [error, attendu]); + + return ; +} diff --git a/packages/server/skel/fullstack/front/src/lib/api-client.ts b/packages/server/skel/fullstack/front/src/lib/api-client.ts index 5289bfea..f4b5a976 100644 --- a/packages/server/skel/fullstack/front/src/lib/api-client.ts +++ b/packages/server/skel/fullstack/front/src/lib/api-client.ts @@ -1,4 +1,4 @@ -// RFC 9457 problem document, as returned by igo on every API error. +// Document de problème RFC 9457, tel qu'igo le renvoie sur chaque erreur d'API. export interface Problem { type: string; title: string; @@ -10,10 +10,14 @@ export interface Problem { export class ApiError extends Error { readonly problem: Problem; - constructor(problem: Problem) { + /** L'identifiant de trace renvoyé par le serveur, s'il en a renvoyé un. */ + readonly traceId?: string; + + constructor(problem: Problem, traceId?: string) { super(problem.detail || problem.title); this.name = 'ApiError'; this.problem = problem; + this.traceId = traceId; } /** Message for one field, to sit under the input that caused it. */ @@ -22,8 +26,17 @@ export class ApiError extends Error { } } -// Relative URLs on purpose: the same build then runs against every -// environment, behind the dev proxy or behind nginx. +const TRACERESPONSE = /^[0-9a-f]{2}-([0-9a-f]{32})-[0-9a-f]{16}-[0-9a-f]{2}$/; + +// W3C Trace Context Level 2 définit `traceresponse` pour le retour. Faro ne +// l'exploite pas, on le lit donc à la main : seul le trace-id sert au support. +const traceIdDeLaReponse = (response: Response) => { + const entete = response.headers.get('traceresponse'); + return (entete && TRACERESPONSE.exec(entete)?.[1]) || undefined; +}; + +// URL relatives à dessein : le même build tourne alors sur tous les +// environnements, derrière le proxy de développement ou derrière nginx. const request = async (method: string, path: string, body?: unknown): Promise => { const response = await fetch(path, { method, @@ -37,7 +50,9 @@ const request = async (method: string, path: string, body?: unknown): Promise title: response.statusText, status: response.status, })); - throw new ApiError(problem as Problem); + // Le code montré à l'utilisateur donne au support de quoi retrouver la + // trace et les logs. + throw new ApiError(problem as Problem, traceIdDeLaReponse(response)); } return response.status === 204 ? (undefined as T) : response.json(); diff --git a/packages/server/skel/fullstack/front/src/lib/report-error.ts b/packages/server/skel/fullstack/front/src/lib/report-error.ts new file mode 100644 index 00000000..33aa4f1e --- /dev/null +++ b/packages/server/skel/fullstack/front/src/lib/report-error.ts @@ -0,0 +1,13 @@ +import { faro } from '@grafana/faro-react'; + +// Le point d'entrée unique pour signaler une erreur que le code a rattrapée. +// Faro capte déjà les exceptions non rattrapées et les rejets de promesse ; +// cette fonction sert aux cas que l'application gère elle-même et veut quand +// même voir remonter. +// +// L'appel est sans effet quand Faro n'est pas initialisé (pas de collecteur +// configuré) : l'application n'a pas à savoir si l'observabilité est branchée. +export const reportError = (error: unknown, context?: Record) => { + const asError = error instanceof Error ? error : new Error(String(error)); + faro.api?.pushError(asError, { context }); +}; diff --git a/packages/server/skel/fullstack/front/src/main.tsx b/packages/server/skel/fullstack/front/src/main.tsx index 9a89edca..1ede1a70 100644 --- a/packages/server/skel/fullstack/front/src/main.tsx +++ b/packages/server/skel/fullstack/front/src/main.tsx @@ -1,8 +1,11 @@ +import './observability'; + import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { QueryClientProvider } from '@tanstack/react-query'; import { RouterProvider } from 'react-router'; +import { ErrorBoundary } from '@/components/layout/error-boundary'; import { queryClient } from '@/lib/query-client'; import { router } from '@/routes'; @@ -10,8 +13,10 @@ import './index.css'; createRoot(document.getElementById('root')!).render( - - - + + + + + , ); diff --git a/packages/server/skel/fullstack/front/src/observability.ts b/packages/server/skel/fullstack/front/src/observability.ts new file mode 100644 index 00000000..d69d630f --- /dev/null +++ b/packages/server/skel/fullstack/front/src/observability.ts @@ -0,0 +1,145 @@ +import { + createReactRouterV7DataOptions, + getWebInstrumentations, + initializeFaro, + ReactIntegration, + TransportItemType, +} from '@grafana/faro-react'; +import type { TransportItem } from '@grafana/faro-react'; +import { TracingInstrumentation } from '@grafana/faro-web-tracing'; +import { matchRoutes } from 'react-router'; + +// Importé en premier dans main.tsx : Faro doit être en place avant React pour +// capter une erreur survenue au chargement. +// +// L'URL du collecteur part dans le navigateur — ce n'est pas un secret. Elle +// reste en variable d'environnement pour qu'un autre déploiement puisse viser +// ailleurs sans toucher au code. +// +// Son absence désactive Faro : c'est le défaut d'un poste de développement, qui +// ne doit ni consommer de quota ni mélanger ses erreurs à celles de la +// production. Pour l'activer le temps d'un build : +// VITE_FARO_URL=… pnpm build +const url = import.meta.env.VITE_FARO_URL; + +// Part du trafic en succès conservée. Une session sur dix suffit à mesurer des +// tendances de performance, et un incident touche rarement une seule session. +// Les erreurs, elles, échappent à ce tirage. +const ROUTINE_SHARE = Number(import.meta.env.VITE_FARO_SAMPLE ?? 0.1); + +// Tiré une fois par chargement : échantillonner événement par événement +// laisserait un parcours à moitié enregistré et rendrait les durées illisibles. +const inSample = Math.random() < ROUTINE_SHARE; + +// Un appel a échoué s'il a rendu un statut d'erreur — ou s'il n'a rendu aucun +// statut du tout. Un fetch qui échoue en réseau (DNS, expiration, CORS) n'a pas +// de `http.response.status_code` : le tester seul laisserait échantillonner +// l'événement le plus intéressant. +const failed = (attributes: Record | undefined) => { + const status = Number(attributes?.['http.response.status_code'] ?? 0); + if (status >= 400) { + return true; + } + // Un appel abouti porte toujours un statut ; son absence sur un événement de + // requête signale un échec avant la réponse. + const estUneRequete = attributes?.['http.method'] !== undefined + || attributes?.['http.request.method'] !== undefined + || attributes?.['http.url'] !== undefined; + return estUneRequete && status === 0; +}; + +// Le contexte navigateur pèse ~1,5 Ko par événement, répété à chaque appel. +// Tout garder sature le quota sans rien apprendre ; tout jeter perdrait la +// corrélation front/back. On garde donc l'anormal en entier, et un échantillon +// du reste. +const filter = (item: TransportItem): TransportItem | null => { + switch (item.type) { + // Jamais échantillonnés. Une erreur vue une seule fois est précisément + // celle qu'on cherche, et les Web Vitals n'ont de sens qu'agrégés sur tout + // le trafic. + case TransportItemType.EXCEPTION: + case TransportItemType.MEASUREMENT: + return item; + + // Les spans du navigateur portent la racine de la trace. Les échantillonner + // ici ne laisserait que la partie serveur, et la corrélation front/back + // cesserait de fonctionner. La décision à l'échelle de la trace appartient + // au bit `sampled` de l'en-tête traceparent que le SDK propage, pas à ce + // filtre. + case TransportItemType.TRACE: + return item; + + // Un log volontaire — pushLog() — est intentionnel par nature : personne + // n'en écrit un sans raison, et la console n'est pas capturée. Le jeter + // même partiellement reviendrait à ignorer une demande explicite. + case TransportItemType.LOG: + return item; + + // Les événements — appels fetch, navigation, performance — font le volume : + // 93 % des charges Faro mesurées, à ~1,5 Ko de contexte navigateur chacun. + // C'est le seul signal à échantillonner, sauf quand l'appel a échoué. + case TransportItemType.EVENT: { + const payload = item.payload as { attributes?: Record }; + return failed(payload.attributes) || inSample ? item : null; + } + + // Un type de signal que ce filtre ne connaît pas encore passe entier. Le + // jeter à 90 % le ferait disparaître sans que personne ne l'ait décidé — + // c'est exactement ainsi que les spans du navigateur ont été perdus une + // première fois, et la corrélation front/back avec eux. On décide + // d'échantillonner ; jamais l'inverse. + default: + return item; + } +}; + +if (url) { + initializeFaro({ + url, + app: { + // Doit correspondre exactement à l'application déclarée dans Grafana + // Frontend Observability, et à l'`appName` passé au téléversement des + // source maps (vite.config.ts) : c'est cette clé qui rattache une pile + // d'appels à ses source maps. + name: import.meta.env.VITE_FARO_APP_NAME || '{project.name}', + version: import.meta.env.VITE_APP_VERSION || '0.0.1', + // Pas import.meta.env.MODE : il vaut 'production' dans tout build Vite, y + // compris un `vite preview` sur un poste de développement. Les erreurs + // locales se mélangeraient alors à celles de la production. + environment: import.meta.env.VITE_ENVIRONMENT || 'dev', + }, + instrumentations: [ + ...getWebInstrumentations({ + // La console est ramassée indistinctement, bibliothèques tierces + // comprises. Ce qui mérite d'être remonté passe par reportError(). + captureConsole: false, + }), + new TracingInstrumentation(), + // Sans elle, une navigation est remontée sous son URL brute : /animaux/12 + // et /animaux/47 comptent alors comme deux pages distinctes, et + // l'agrégation par page devient illisible dès que les identifiants se + // multiplient. L'intégration résout le motif de la route, comme + // http.route le fait côté serveur. + // + // La variante « data router » n'a besoin que de matchRoutes ; c'est + // withFaroRouterInstrumentation, dans routes.tsx, qui l'abonne aux + // navigations. + new ReactIntegration({ + router: createReactRouterV7DataOptions({ matchRoutes }), + }), + ], + + beforeSend: filter, + + ignoreErrors: [ + // Bizarreries de mise en page, sans conséquence + /^ResizeObserver loop limit exceeded$/, + /^ResizeObserver loop completed with undelivered notifications$/, + // Scripts d'une autre origine, sans pile exploitable + /^Script error\.$/, + // Interférences d'extensions de navigateur + /chrome-extension:\/\//, + /moz-extension:\/\//, + ], + }); +} diff --git a/packages/server/skel/fullstack/front/src/routes.tsx b/packages/server/skel/fullstack/front/src/routes.tsx index d04a59bc..187742a0 100644 --- a/packages/server/skel/fullstack/front/src/routes.tsx +++ b/packages/server/skel/fullstack/front/src/routes.tsx @@ -1,13 +1,23 @@ +import { withFaroRouterInstrumentation } from '@grafana/faro-react'; import { createBrowserRouter } from 'react-router'; import { AppLayout } from '@/components/layout/app-layout'; +import { RouteError } from '@/components/layout/route-error'; -export const router = createBrowserRouter([ +const arbre = createBrowserRouter([ { element: , + // Couvre tout l'arbre : react-router remonte l'erreur jusqu'au premier + // errorElement rencontré. + errorElement: , children: [ - // lazy per feature: a route is only downloaded when it is visited + // lazy par feature : une route n'est téléchargée qu'à la visite { index: true, lazy: () => import('@/features/books/pages/books-page') }, ], }, ]); + +// Abonne Faro aux navigations : l'intégration déclarée dans observability.ts en +// dépend pour résoudre le motif de chaque route. Sans collecteur configuré, +// l'appel est sans effet. +export const router = withFaroRouterInstrumentation(arbre); diff --git a/packages/server/skel/fullstack/front/vite.config.ts b/packages/server/skel/fullstack/front/vite.config.ts index e8c102a0..4ef113dd 100644 --- a/packages/server/skel/fullstack/front/vite.config.ts +++ b/packages/server/skel/fullstack/front/vite.config.ts @@ -1,35 +1,68 @@ import { fileURLToPath, URL } from 'node:url'; -import { defineConfig } from 'vite'; +import { defineConfig, loadEnv } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; +import faroUploader from '@grafana/faro-rollup-plugin'; -const API_PROXY = { - target: process.env.API_URL || 'http://127.0.0.1:3000', - changeOrigin: false, -}; +export default defineConfig(({ mode }) => { + // vite.config.ts ne reçoit pas le .env dans process.env : loadEnv le lit + // explicitement. Le troisième argument vide lève le filtre sur le préfixe + // VITE_, sans quoi une clé qui ne sert qu'au build resterait invisible ici. + const env = { ...loadEnv(mode, process.cwd(), ''), ...process.env }; -export default defineConfig({ - plugins: [react(), tailwindcss()], + const API_PROXY = { + target: env.API_URL || 'http://127.0.0.1:3000', + changeOrigin: false, + }; - // tsconfig paths are for the type checker only: the bundler needs its own - resolve: { - alias: { - '@': fileURLToPath(new URL('./src', import.meta.url)), + // Les source maps ne sont téléversées que si la clé est fournie : un build + // sans observabilité configurée reste possible, et la CI d'une contribution + // externe n'a pas besoin du secret. Les quatre valeurs viennent de la page de + // réglages de l'application Grafana. + const faro = + env.FARO_API_KEY && env.FARO_APP_ID && env.FARO_STACK_ID && env.FARO_UPLOAD_ENDPOINT + ? faroUploader({ + appName: env.VITE_FARO_APP_NAME || '{project.name}', + endpoint: env.FARO_UPLOAD_ENDPOINT, + appId: env.FARO_APP_ID, + stackId: env.FARO_STACK_ID, + apiKey: env.FARO_API_KEY, + gzipContents: true, + }) + : null; + + return { + plugins: [react(), tailwindcss(), ...(faro ? [faro] : [])], + + // Les chemins du tsconfig ne servent qu'au vérificateur de types : le + // bundler a besoin des siens + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + + build: { + // Sans source maps, une pile d'appels dans Grafana désigne du code + // minifié. `hidden` les produit sans que le bundle y renvoie : le + // téléversement les donne à Grafana, le navigateur ne les télécharge + // jamais. + sourcemap: 'hidden', + }, + + // Le navigateur ne voit qu'une seule origine, donc le cookie de session + // d'igo passe comme n'importe quel cookie de même origine — pas de CORS, pas + // de gestion d'identifiants. En production, nginx joue ce rôle. + server: { + port: 5173, + proxy: { '/api': API_PROXY }, + }, + + // `vite preview` n'hérite pas de server.proxy : sans ceci, un build servi + // pour les tests E2E n'aurait aucune API derrière lui. + preview: { + proxy: { '/api': API_PROXY }, }, - }, - - // The browser sees a single origin, so the igo session cookie is sent like - // any same-origin cookie — no CORS, no credentials handling. In production - // nginx plays this role. - server: { - port: 5173, - proxy: { '/api': API_PROXY }, - }, - - // `vite preview` does not inherit server.proxy: without this, a build served - // for E2E tests would have no API behind it. - preview: { - proxy: { '/api': API_PROXY }, - }, + }; }); diff --git a/packages/server/skel/fullstack/front/vitest.config.ts b/packages/server/skel/fullstack/front/vitest.config.ts index 2d6c6eed..785577aa 100644 --- a/packages/server/skel/fullstack/front/vitest.config.ts +++ b/packages/server/skel/fullstack/front/vitest.config.ts @@ -2,17 +2,22 @@ import { defineConfig, mergeConfig } from 'vitest/config'; import viteConfig from './vite.config.ts'; -// Vitest 5 no longer accepts a `test` key in vite's defineConfig: the test -// setup lives in its own file and reuses the app config. +// Vitest 5 n'accepte plus de clé `test` dans le defineConfig de vite : la +// configuration des tests vit dans son propre fichier et réutilise celle de +// l'application. +// +// vite.config.ts est une fonction depuis qu'il lit le .env : on la résout ici en +// mode test, ce qui laisse au passage le téléversement des source maps +// désactivé pendant les tests. export default mergeConfig( - viteConfig, + viteConfig({ mode: 'test', command: 'serve' }), defineConfig({ test: { environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'], - // e2e/ belongs to Playwright: vitest would otherwise pick its specs up - // and fail on an import it cannot resolve. + // e2e/ appartient à Playwright : sans ceci, vitest ramasserait ses specs + // et échouerait sur un import qu'il ne sait pas résoudre. include: ['src/**/*.{test,spec}.{ts,tsx}'], }, }), diff --git a/packages/server/skel/fullstack/pnpm-workspace.yaml b/packages/server/skel/fullstack/pnpm-workspace.yaml index d77460d0..a4fb7f06 100644 --- a/packages/server/skel/fullstack/pnpm-workspace.yaml +++ b/packages/server/skel/fullstack/pnpm-workspace.yaml @@ -9,3 +9,5 @@ allowBuilds: esbuild: true '@parcel/watcher': true msw: true + # arrives with the OpenTelemetry OTLP exporter + protobufjs: true From 38209a21d11836767c6427df92d8950f3eca4f30 Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Thu, 10 Sep 2026 10:25:22 +0200 Subject: [PATCH 49/80] =?UTF-8?q?docs(skel):=20passer=20les=20commentaires?= =?UTF-8?q?=20du=20code=20en=20fran=C3=A7ais?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La convention de langue veut le français pour les commentaires et les objets du domaine, l anglais pour la technique. Le squelette était intégralement en anglais, y compris ses commentaires : il enseignait donc l inverse de la règle par l exemple, et c est le seul endroit où celle-ci est garantie. 163 lignes traduites. Les noms d identifiants restent en anglais quand ils ne portent aucun concept du domaine. Co-Authored-By: Claude Opus 5 --- .../api/app/features/books/books.controller.ts | 9 +++++---- .../api/app/features/books/books.dto.ts | 16 +++++++++------- packages/server/skel/fullstack/api/app/routes.ts | 6 +++--- .../server/skel/fullstack/api/seeds/001-books.ts | 6 +++--- packages/server/skel/fullstack/e2e/books.spec.ts | 11 +++++++---- .../skel/fullstack/e2e/pages/books.page.ts | 6 +++--- .../books/components/books-list.test.tsx | 3 ++- .../src/features/books/components/books-list.tsx | 4 ++-- .../src/features/books/pages/books-page.tsx | 4 ++-- .../features/books/sections/add-book-section.tsx | 4 ++-- .../fullstack/front/src/features/books/types.ts | 7 ++++--- .../skel/fullstack/front/src/lib/query-client.ts | 2 +- .../skel/fullstack/front/src/test/handlers.ts | 4 ++-- .../skel/fullstack/front/src/test/render.tsx | 5 +++-- .../skel/fullstack/front/src/test/setup.ts | 4 ++-- 15 files changed, 50 insertions(+), 41 deletions(-) diff --git a/packages/server/skel/fullstack/api/app/features/books/books.controller.ts b/packages/server/skel/fullstack/api/app/features/books/books.controller.ts index fbb533f6..e2f268ac 100644 --- a/packages/server/skel/fullstack/api/app/features/books/books.controller.ts +++ b/packages/server/skel/fullstack/api/app/features/books/books.controller.ts @@ -4,12 +4,13 @@ import type { ApiHandler } from '@igojs/server'; import Book from './Book'; import * as dto from './books.dto'; -// The `type` is what a client branches on — the status alone cannot tell two -// business situations apart. It is a URI, and the slug belongs to the project. +// Le `type` est ce sur quoi un client branche — le statut seul ne distingue pas +// deux situations métier. C'est une URI, et le slug appartient au projet. const BOOK_NOT_FOUND = '/problems/book-not-found'; -// The schemas below give req.body and req.query their types: no shape is -// declared twice, and a field that is not in the schema is a compile error. +// Les schémas ci-dessous donnent leurs types à req.body et req.query : aucune +// forme n'est déclarée deux fois, et un champ absent du schéma est une erreur de +// compilation. export const index: ApiHandler<{ query: typeof dto.ListBooks }> = async (req, res) => { const { page, limit, published } = req.query; diff --git a/packages/server/skel/fullstack/api/app/features/books/books.dto.ts b/packages/server/skel/fullstack/api/app/features/books/books.dto.ts index 2f4210c9..4136054d 100644 --- a/packages/server/skel/fullstack/api/app/features/books/books.dto.ts +++ b/packages/server/skel/fullstack/api/app/features/books/books.dto.ts @@ -1,8 +1,9 @@ import { z } from 'zod'; import type { BookRow } from './Book'; -// Incoming: what the API accepts. Coercion and defaults are applied before the -// controller runs, so req.body and req.query already hold the right types. +// Entrant : ce que l'API accepte. Coercition et valeurs par défaut sont +// appliquées avant le contrôleur, donc req.body et req.query portent déjà les +// bons types. export const CreateBook = z.object({ title: z.string().min(1).max(255), author: z.string().min(1).max(255), @@ -15,15 +16,16 @@ export const UpdateBook = CreateBook.partial(); export const ListBooks = z.object({ page: z.coerce.number().int().min(1).default(1), limit: z.coerce.number().int().min(1).max(100).default(25), - // z.coerce.boolean() would turn 'false' into true: URL flags need this form + // z.coerce.boolean() transformerait 'false' en true : un drapeau d'URL exige + // cette forme published: z .enum(['true', 'false']) .transform((v) => v === 'true') .optional(), }); -// Outgoing: the barrier between the ORM model and the API. Adding a column to -// the model exposes nothing until it is named here. +// Sortant : la barrière entre le modèle de l'ORM et l'API. Ajouter une colonne +// au modèle n'expose rien tant qu'elle n'est pas nommée ici. export const serialize = (book: BookRow) => ({ id: book.id, title: book.title, @@ -33,8 +35,8 @@ export const serialize = (book: BookRow) => ({ createdAt: book.created_at, }); -// The ORM pagination also carries `links`, meant for rendering page numbers in -// a template: an API client builds its own navigation. +// La pagination de l'ORM porte aussi `links`, prévu pour afficher des numéros +// de page dans un gabarit : un client d'API construit sa propre navigation. export const serializePage = (pagination: { page: number; nb: number; diff --git a/packages/server/skel/fullstack/api/app/routes.ts b/packages/server/skel/fullstack/api/app/routes.ts index ae61cc09..ca9b6424 100644 --- a/packages/server/skel/fullstack/api/app/routes.ts +++ b/packages/server/skel/fullstack/api/app/routes.ts @@ -1,5 +1,5 @@ -// Define your routes here -// Check http://expressjs.com/en/guide/routing.html for documentation +// Déclarer les routes ici +// Documentation : http://expressjs.com/en/guide/routing.html import type { Express } from 'express'; @@ -7,7 +7,7 @@ import books from './features/books/books.routes'; // export const init = (app: Express) => { - // mounted under config.api.prefix -> /api/books + // monté sous config.api.prefix -> /api/books app.api('/books', books); app.get('/', (req, res) => { diff --git a/packages/server/skel/fullstack/api/seeds/001-books.ts b/packages/server/skel/fullstack/api/seeds/001-books.ts index 17b990ec..5be158d7 100644 --- a/packages/server/skel/fullstack/api/seeds/001-books.ts +++ b/packages/server/skel/fullstack/api/seeds/001-books.ts @@ -1,8 +1,8 @@ import Book from '../app/features/books/Book'; -// Seeds give a fresh checkout something to look at. Data the application needs -// to run belongs in a migration — this runs only outside production, and only -// when someone asks for it. +// Les seeds donnent de quoi regarder à un dépôt fraîchement cloné. Une donnée +// dont l'application a besoin pour tourner relève d'une migration — ceci ne +// s'exécute qu'hors production, et seulement sur demande. export default async () => { await Book.create({ title: 'Dune', author: 'Frank Herbert', pages: 412 }); await Book.create({ title: 'Neuromancer', author: 'William Gibson', pages: 271 }); diff --git a/packages/server/skel/fullstack/e2e/books.spec.ts b/packages/server/skel/fullstack/e2e/books.spec.ts index d30d41e7..17fb0616 100644 --- a/packages/server/skel/fullstack/e2e/books.spec.ts +++ b/packages/server/skel/fullstack/e2e/books.spec.ts @@ -2,9 +2,9 @@ import { expect, test } from '@playwright/test'; import { BooksPage } from './pages/books.page'; -// E2E covers the wiring end to end — browser, front build, proxy, API, database. -// Everything below that is already covered faster by the front and back tests, -// so this file stays short on purpose. +// Un E2E couvre le câblage de bout en bout — navigateur, build du front, proxy, +// API, base. Tout ce qui se couvre plus bas l'est déjà plus vite par les tests +// du front et du back, donc ce fichier reste court à dessein. test.describe('books', () => { test('should list the books served by the API', async ({ page }) => { const books = new BooksPage(page); @@ -19,7 +19,10 @@ test.describe('books', () => { const books = new BooksPage(page); await books.goto(); - // the database is shared with the other tests, so the title has to be ours + // La base est partagée avec les autres tests, et ils tournent en parallèle : + // ce test se donne donc une donnée qui lui appartient. Vaut aussi pour une + // donnée qu'on modifie — trancher une entrée des seeds fait passer le test + // une fois, puis échouer. const title = `Dune ${Date.now()}`; await books.addBook({ title, author: 'Frank Herbert', pages: '412' }); diff --git a/packages/server/skel/fullstack/e2e/pages/books.page.ts b/packages/server/skel/fullstack/e2e/pages/books.page.ts index 23617b0d..a1788fa9 100644 --- a/packages/server/skel/fullstack/e2e/pages/books.page.ts +++ b/packages/server/skel/fullstack/e2e/pages/books.page.ts @@ -1,8 +1,8 @@ import type { Locator, Page } from '@playwright/test'; -// A page object exposes locators and the actions that reach them. It holds no -// assertion: what counts as correct belongs to the test, so the same locator -// can be expected present in one test and absent in another. +// Un page object expose des locators et les actions qui y mènent. Il ne porte +// aucune assertion : ce qui est correct appartient au test, si bien qu'un même +// locator peut être attendu présent dans un test et absent dans un autre. export class BooksPage { readonly heading: Locator; readonly loading: Locator; diff --git a/packages/server/skel/fullstack/front/src/features/books/components/books-list.test.tsx b/packages/server/skel/fullstack/front/src/features/books/components/books-list.test.tsx index 46b48553..e9e5497c 100644 --- a/packages/server/skel/fullstack/front/src/features/books/components/books-list.test.tsx +++ b/packages/server/skel/fullstack/front/src/features/books/components/books-list.test.tsx @@ -5,7 +5,8 @@ import { aBook } from '@/test/handlers'; import { BooksList } from './books-list'; -// A pure component needs no providers: props in, markup out. +// Un composant pur n'a besoin d'aucun provider : des props en entrée, du +// balisage en sortie. describe('BooksList', () => { it('should list every book', () => { render(); diff --git a/packages/server/skel/fullstack/front/src/features/books/components/books-list.tsx b/packages/server/skel/fullstack/front/src/features/books/components/books-list.tsx index 877d9ac1..5906d619 100644 --- a/packages/server/skel/fullstack/front/src/features/books/components/books-list.tsx +++ b/packages/server/skel/fullstack/front/src/features/books/components/books-list.tsx @@ -1,7 +1,7 @@ import type { Book } from '../types'; -// Pure: everything arrives through props. No useQuery here — see the data -// injection rule in the front conventions. +// Pur : tout arrive par les props. Pas de useQuery ici — voir la règle +// d'injection des données dans les conventions du front. export function BooksList({ books }: { books: Book[] }) { if (books.length === 0) { return

      No book yet.

      ; diff --git a/packages/server/skel/fullstack/front/src/features/books/pages/books-page.tsx b/packages/server/skel/fullstack/front/src/features/books/pages/books-page.tsx index e42d29c8..b509d3cb 100644 --- a/packages/server/skel/fullstack/front/src/features/books/pages/books-page.tsx +++ b/packages/server/skel/fullstack/front/src/features/books/pages/books-page.tsx @@ -2,8 +2,8 @@ import { useBooks } from '../api'; import { BooksList } from '../components/books-list'; import { AddBookSection } from '../sections/add-book-section'; -// A page assembles. Loading and error states are handled explicitly rather -// than left to a spinner that never resolves. +// Une page assemble. Les états de chargement et d'erreur sont traités +// explicitement, plutôt que laissés à un indicateur qui tourne sans fin. export function BooksPage() { const { data, isPending, isError, error } = useBooks(); diff --git a/packages/server/skel/fullstack/front/src/features/books/sections/add-book-section.tsx b/packages/server/skel/fullstack/front/src/features/books/sections/add-book-section.tsx index 2bc7bf8f..f0be1df1 100644 --- a/packages/server/skel/fullstack/front/src/features/books/sections/add-book-section.tsx +++ b/packages/server/skel/fullstack/front/src/features/books/sections/add-book-section.tsx @@ -6,8 +6,8 @@ import { useCreateBook } from '../api'; const EMPTY = { title: '', author: '', pages: '' }; -// A section owns its mutation. The server is the authority on validity: its -// per-field errors are displayed as they come, without being re-derived here. +// Une section possède sa mutation. Le serveur est l'autorité sur la validité : +// ses erreurs par champ s'affichent telles quelles, sans être redérivées ici. export function AddBookSection() { const [form, setForm] = useState(EMPTY); const createBook = useCreateBook(); diff --git a/packages/server/skel/fullstack/front/src/features/books/types.ts b/packages/server/skel/fullstack/front/src/features/books/types.ts index 3f6281f5..43980c27 100644 --- a/packages/server/skel/fullstack/front/src/features/books/types.ts +++ b/packages/server/skel/fullstack/front/src/features/books/types.ts @@ -1,6 +1,7 @@ -// Mirrors the DTO the back serializes. Kept by hand: the back is JavaScript, -// so there is no contract to generate from — a mismatch shows up in the -// feature tests, which run against the real payload shape. +// Reflète le DTO que le back sérialise. Écrit à la main : le back est en +// JavaScript, il n'y a donc aucun contrat à partir duquel générer — un écart se +// voit dans les tests de feature, qui tournent contre la forme réelle de la +// charge. export interface Book { id: number; title: string; diff --git a/packages/server/skel/fullstack/front/src/lib/query-client.ts b/packages/server/skel/fullstack/front/src/lib/query-client.ts index 815485d0..379c66b7 100644 --- a/packages/server/skel/fullstack/front/src/lib/query-client.ts +++ b/packages/server/skel/fullstack/front/src/lib/query-client.ts @@ -6,7 +6,7 @@ export const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, - // a 404 or a validation error will not fix itself on retry + // un 404 ou une erreur de validation ne se corrigera pas en réessayant retry: (failureCount, error) => !(error instanceof ApiError && error.problem.status < 500) && failureCount < 2, }, diff --git a/packages/server/skel/fullstack/front/src/test/handlers.ts b/packages/server/skel/fullstack/front/src/test/handlers.ts index b9d25c23..841a3ea0 100644 --- a/packages/server/skel/fullstack/front/src/test/handlers.ts +++ b/packages/server/skel/fullstack/front/src/test/handlers.ts @@ -12,8 +12,8 @@ export const aBook = (overrides: Partial = {}): Book => ({ ...overrides, }); -// Default handlers describe the happy path; a test overrides the one case it -// is about with server.use(). +// Les handlers par défaut décrivent le cas nominal ; un test surcharge avec +// server.use() le seul cas qui le concerne. export const handlers = [ http.get('/api/books', () => HttpResponse.json({ diff --git a/packages/server/skel/fullstack/front/src/test/render.tsx b/packages/server/skel/fullstack/front/src/test/render.tsx index 430113a9..c2f35d7d 100644 --- a/packages/server/skel/fullstack/front/src/test/render.tsx +++ b/packages/server/skel/fullstack/front/src/test/render.tsx @@ -2,8 +2,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render } from '@testing-library/react'; import type { ReactElement } from 'react'; -// A fresh client per test: a cache shared between tests makes them pass or -// fail depending on their order. Retries off so an error surfaces at once. +// Un client neuf par test : un cache partagé entre les tests les fait passer ou +// échouer selon leur ordre. Les réessais sont coupés pour qu'une erreur remonte +// immédiatement. export function renderWithProviders(ui: ReactElement) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, diff --git a/packages/server/skel/fullstack/front/src/test/setup.ts b/packages/server/skel/fullstack/front/src/test/setup.ts index 91f1d1e7..62812657 100644 --- a/packages/server/skel/fullstack/front/src/test/setup.ts +++ b/packages/server/skel/fullstack/front/src/test/setup.ts @@ -4,8 +4,8 @@ import { afterAll, afterEach, beforeAll } from 'vitest'; import { server } from './msw-server'; -// MSW intercepts at the network level, so the production apiClient runs -// untouched: swapping the HTTP wrapper does not break these tests. +// MSW intercepte au niveau du réseau, donc le vrai apiClient tourne sans +// modification : remplacer l'enveloppe HTTP ne casse pas ces tests. beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); afterEach(() => { server.resetHandlers(); From 049e3e791105a9873bf29d5f9d39202f654471c9 Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Thu, 10 Sep 2026 10:25:33 +0200 Subject: [PATCH 50/80] =?UTF-8?q?docs(skel):=20=C3=A9crire=20les=20convent?= =?UTF-8?q?ions=20manquantes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quatre règles qui n existaient nulle part, chacune vérifiée sur cette validation. La langue du code : le français porte le métier, l anglais la technique, avec le critère de tranchage — le nom désigne-t-il quelque chose dont le client parle ? Le franglais assumé est le compromis retenu : traduire un domaine métier ajoute une charge mentale à chaque lecture et introduit des contresens, pour un bénéfice nul quand l équipe et le client parlent français. Les données E2E : la règle ne couvrait que la création. Le piège rencontré est la modification d une donnée de seed — un test vert qui devient rouge sans qu aucun code n ait changé, et qui reverdit après un reseed. shadcn : l arborescence le présupposait. C est une recommandation, et src/components/ui/ est décrit par son rôle. L observabilité : ce qui la désactive, les deux pièges d instrumentation.ts, et la règle sur beforeSend — les spans du navigateur portent la racine de la trace, les échantillonner casse la corrélation front/back. Co-Authored-By: Claude Opus 5 --- packages/server/skel/fullstack/CLAUDE.md | 49 +++++++++++++++++++ packages/server/skel/fullstack/README.md | 22 +++++++++ packages/server/skel/fullstack/e2e/CLAUDE.md | 12 +++-- .../server/skel/fullstack/front/CLAUDE.md | 24 ++++++--- 4 files changed, 98 insertions(+), 9 deletions(-) diff --git a/packages/server/skel/fullstack/CLAUDE.md b/packages/server/skel/fullstack/CLAUDE.md index 285a2b8b..89d2e9ef 100644 --- a/packages/server/skel/fullstack/CLAUDE.md +++ b/packages/server/skel/fullstack/CLAUDE.md @@ -61,6 +61,55 @@ Les E2E tournent contre le **build**, pas le serveur de développement. Ils sont lents : tout ce qui peut être couvert plus bas doit l'être plus bas. `pnpm test` ne les lance pas — `pnpm test:e2e` est une commande à part. +## Observabilité + +Désactivée tant que la destination est absente : `OTEL_EXPORTER_OTLP_ENDPOINT` +côté API, `VITE_FARO_URL` côté front. Ne pas inventer d'autre drapeau. + +`api/instrumentation.ts` est chargé par `--import`, donc **avant** igo : +OpenTelemetry doit remplacer `express` et `mysql2` avant leur chargement. Deux +conséquences à ne pas défaire : + +- sa première ligne est `import '@igojs/server/env'`, sans quoi le `.env` n'est + pas encore lu et toute variable `OTEL_*` vaut `undefined` — le SDK ne démarre + alors pas, **sans erreur ni donnée** ; +- ne rien y importer de l'application : charger `@igojs/server` tirerait + `express`, soit précisément ce qu'on devait précéder. + +Dans `front/src/observability.ts`, `beforeSend` n'échantillonne que les +**événements**. Exceptions, mesures Web Vitals et spans du navigateur passent +toujours : les spans portent la racine de la trace, et les échantillonner casse +la corrélation front/back. + +## Langue du code + +**Le français porte le métier, l'anglais porte la technique.** + +| En français | En anglais | +| --- | --- | +| Commentaires | Noms de variables, fonctions, classes | +| Messages de commit | Fichiers et dossiers techniques | +| Objets du domaine (`Demande`, `Animal`) | Bibliothèques, API, mots-clés | +| Découpage en features (`features/demandes/`) | Types et interfaces techniques | +| Documentation (`README`, `CLAUDE.md`) | Libellés de test | + +Un modèle s'appelle donc `Demande` et vit dans `features/demandes/`, mais le +middleware qui estampille une requête s'appelle `tagRequest` et non +`marquerRequete` : il ne porte aucun concept du domaine, seulement de la +plomberie. + +Le critère : **le nom désigne-t-il quelque chose dont le client parle ?** Si +oui, français. Sinon, anglais. + +Ça produit du franglais assumé (`demande.partenaireId`), et c'est le compromis +retenu : traduire un domaine métier vers l'anglais ajoute une charge mentale à +chaque lecture et introduit des contresens, pour un bénéfice nul quand toute +l'équipe et le client parlent français. + +**Ce sont des préconisations.** Un client peut imposer autre chose — un projet +repris, une équipe internationale, une contrainte contractuelle. Dans ce cas la +règle change, mais elle reste uniforme sur le projet. + ## Commits [Conventional Commits](https://www.conventionalcommits.org), vérifiés par un diff --git a/packages/server/skel/fullstack/README.md b/packages/server/skel/fullstack/README.md index 996bdb79..8a1ccabc 100644 --- a/packages/server/skel/fullstack/README.md +++ b/packages/server/skel/fullstack/README.md @@ -61,6 +61,28 @@ Les E2E tournent contre le **build** du front, pas le serveur de développement c'est ce qui est déployé. Ils restent peu nombreux : tout ce qui peut être couvert plus bas doit l'être. +## Observabilité + +**Rien n'est envoyé par défaut** : `OTEL_EXPORTER_OTLP_ENDPOINT` côté API et +`VITE_FARO_URL` côté front sont commentés, et leur absence suffit à tout +désactiver. Un poste de développement ne consomme donc aucun quota, et les +tests E2E n'envoient rien. + +**L'application n'écrit jamais directement dans une plateforme.** Elle parle +OTLP à un collecteur local — [Grafana Alloy](https://grafana.com/docs/alloy/) — +qui relaie, filtre et dérive les métriques. C'est ce détour qui permet de +changer de destination sans toucher au code, et de collecter aussi les logs, la +base et le cache, qui ne parlent pas OTLP. + +**Alloy n'est pas actif ici**, sa configuration dépendant de la plateforme : +`alloy/config.alloy.example` est un point de départ à copier en +`config.alloy` et à adapter. Son en-tête liste ce qui est à revoir et les +pièges de cardinalité déjà mesurés. Sans collecteur en écoute, l'API n'envoie +rien — c'est la première chose à vérifier quand aucune donnée n'arrive. + +Le front est l'exception : il poste au collecteur Faro hébergé, un navigateur +n'atteignant pas un Alloy local. + ## Conventions [Conventional Commits](https://www.conventionalcommits.org), vérifiés par un diff --git a/packages/server/skel/fullstack/e2e/CLAUDE.md b/packages/server/skel/fullstack/e2e/CLAUDE.md index 425e0bae..58ca282e 100644 --- a/packages/server/skel/fullstack/e2e/CLAUDE.md +++ b/packages/server/skel/fullstack/e2e/CLAUDE.md @@ -69,9 +69,15 @@ seules : `await expect(x).toBeVisible()` attend déjà. ## Données -La base est partagée entre les tests, et ils tournent en parallèle. Un test qui -crée une donnée lui donne un nom qui lui appartient (`Dune ${Date.now()}`) -plutôt que de compter sur un état de départ. +La base est partagée entre les tests, et ils tournent en parallèle. **Un test +utilise un jeu de données qui lui appartient** — qu'il le crée ou qu'il le +modifie. + +À la création, un nom qui n'appartient qu'à lui (`Dune ${Date.now()}`) plutôt +qu'un état de départ supposé. À la modification, une donnée qu'il a créée +lui-même : trancher une entrée des seeds fait passer le test la première fois, +puis échouer — un test vert qui devient rouge sans qu'aucun code n'ait changé, +et qui reverdit après un reseed. On cherche alors la régression dans le code. Un projet qui a besoin d'un jeu de données dédié le pose lui-même — il n'y a pas de seed E2E ici. diff --git a/packages/server/skel/fullstack/front/CLAUDE.md b/packages/server/skel/fullstack/front/CLAUDE.md index ac1dae48..028e9224 100644 --- a/packages/server/skel/fullstack/front/CLAUDE.md +++ b/packages/server/skel/fullstack/front/CLAUDE.md @@ -21,10 +21,11 @@ normale de travailler. Le proxy `/api` vise `http://127.0.0.1:3000`. ``` src/ main.tsx point d'entrée, providers + observability.ts initialisation, importée en premier routes.tsx arbre de routes, lazy par feature components/ - ui/ composants copiés (shadcn) — purs, à créer - layout/ coquille de page + ui/ composants d'interface bas niveau — purs, à créer + layout/ coquille de page, frontières d'erreur features// pages/ composants de route — PEUVENT fetch sections/ blocs autonomes — PEUVENT fetch @@ -33,6 +34,7 @@ src/ types.ts types de la feature lib/ api-client.ts wrapper fetch typé, erreurs RFC 9457 + report-error.ts signaler une erreur rattrapée test/ handlers MSW, helper de rendu ``` @@ -57,6 +59,11 @@ ce qui permet au même build de tourner sur tous les environnements. par `useEffect`. L'état purement client passe par React context tant qu'il reste léger. +**Les deux frontières d'erreur ne sont pas redondantes** : celle de `main.tsx` +capte le rendu, l'`errorElement` de `routes.tsx` capte ce que react-router +intercepte lui-même (route lazy, `loader`). Retirer l'une rend ses erreurs +invisibles. + **Les états loading et error sont explicites** dans les pages et sections. Pas de composant qui suppose que les données sont là. @@ -80,7 +87,12 @@ Un test de feature couvre le cas nominal, l'erreur serveur, et la validation. ## Système de design -Tailwind est installé, sans bibliothèque de composants. -[shadcn/ui](https://ui.shadcn.com) est la recommandation — ses composants se -copient dans `src/components/ui/` et deviennent du code du projet. C'est un -choix, pas une obligation. +Tailwind est installé, sans bibliothèque de composants. `src/components/ui/` +accueille les composants d'interface bas niveau — bouton, champ, sélecteur : +purs, sans accès aux données, réutilisables partout. + +[shadcn/ui](https://ui.shadcn.com) est la recommandation pour les obtenir : ses +composants se copient dans ce dossier et deviennent du code du projet, qu'on +peut relire et modifier. **C'est une recommandation, pas une obligation** — un +projet qui écrit les siens à la main, ou qui part d'une autre bibliothèque, +range ses composants au même endroit. From cca24e3a6197c155f3c25227705e02beef06b34d Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Thu, 10 Sep 2026 10:27:21 +0200 Subject: [PATCH 51/80] style(skel): aligner observability.ts sur oxfmt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La forme des opérateurs en début de ligne ne suivait pas le formateur : un projet généré échouait à son propre format:check. Co-Authored-By: Claude Opus 5 --- packages/server/skel/fullstack/front/src/observability.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/server/skel/fullstack/front/src/observability.ts b/packages/server/skel/fullstack/front/src/observability.ts index d69d630f..65dbbf85 100644 --- a/packages/server/skel/fullstack/front/src/observability.ts +++ b/packages/server/skel/fullstack/front/src/observability.ts @@ -42,9 +42,10 @@ const failed = (attributes: Record | undefined) => { } // Un appel abouti porte toujours un statut ; son absence sur un événement de // requête signale un échec avant la réponse. - const estUneRequete = attributes?.['http.method'] !== undefined - || attributes?.['http.request.method'] !== undefined - || attributes?.['http.url'] !== undefined; + const estUneRequete = + attributes?.['http.method'] !== undefined || + attributes?.['http.request.method'] !== undefined || + attributes?.['http.url'] !== undefined; return estUneRequete && status === 0; }; From 419e26f6fb98b60fd6d95b7b958a4e506e77c49b Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Thu, 10 Sep 2026 10:43:57 +0200 Subject: [PATCH 52/80] =?UTF-8?q?feat(skel):=20auditer=20l=20accessibilit?= =?UTF-8?q?=C3=A9=20dans=20les=20tests=20E2E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois ADR exigent qu un écran ne porte aucune violation WCAG 2.1 AA, et le projet de validation l implémente — mais le squelette ne le testait pas. Il prescrivait donc une pratique qu il n outillait pas. axe ne juge que ce qu une machine peut vérifier, la moitié des critères environ. Mais cette moitié se régresse sans qu on s en aperçoive, là où le reste se relit. La documentation suit aux trois endroits utiles : où le test vit (e2e), ce qu il faut faire en écrivant un composant (front, dont les aria-label anglais de shadcn), et une ligne dans le README. Co-Authored-By: Claude Opus 5 --- packages/server/skel/fullstack/README.md | 3 +++ packages/server/skel/fullstack/e2e/CLAUDE.md | 7 ++++++ .../server/skel/fullstack/e2e/books.spec.ts | 22 +++++++++++++++++++ .../server/skel/fullstack/e2e/package.json | 1 + .../server/skel/fullstack/front/CLAUDE.md | 16 ++++++++++++++ 5 files changed, 49 insertions(+) diff --git a/packages/server/skel/fullstack/README.md b/packages/server/skel/fullstack/README.md index 8a1ccabc..63c47762 100644 --- a/packages/server/skel/fullstack/README.md +++ b/packages/server/skel/fullstack/README.md @@ -61,6 +61,9 @@ Les E2E tournent contre le **build** du front, pas le serveur de développement c'est ce qui est déployé. Ils restent peu nombreux : tout ce qui peut être couvert plus bas doit l'être. +Ils portent aussi l'audit d'accessibilité : aucun écran ne doit présenter de +violation WCAG 2.1 AA. + ## Observabilité **Rien n'est envoyé par défaut** : `OTEL_EXPORTER_OTLP_ENDPOINT` côté API et diff --git a/packages/server/skel/fullstack/e2e/CLAUDE.md b/packages/server/skel/fullstack/e2e/CLAUDE.md index 58ca282e..be41f08f 100644 --- a/packages/server/skel/fullstack/e2e/CLAUDE.md +++ b/packages/server/skel/fullstack/e2e/CLAUDE.md @@ -67,6 +67,13 @@ Jamais de sélecteur CSS ou XPath structurel. Pas de `waitForTimeout`. Les assertions `expect(locator)` réessaient toutes seules : `await expect(x).toBeVisible()` attend déjà. +## Accessibilité + +Un écran ne doit porter aucune violation WCAG 2.1 AA, vérifié par +`@axe-core/playwright`. Un écran ajouté au parcours s'ajoute au test : axe ne +juge que ce qui est vérifiable par une machine, mais cette moitié des critères +se régresse en silence. + ## Données La base est partagée entre les tests, et ils tournent en parallèle. **Un test diff --git a/packages/server/skel/fullstack/e2e/books.spec.ts b/packages/server/skel/fullstack/e2e/books.spec.ts index 17fb0616..ef6794c3 100644 --- a/packages/server/skel/fullstack/e2e/books.spec.ts +++ b/packages/server/skel/fullstack/e2e/books.spec.ts @@ -1,3 +1,4 @@ +import AxeBuilder from '@axe-core/playwright'; import { expect, test } from '@playwright/test'; import { BooksPage } from './pages/books.page'; @@ -38,3 +39,24 @@ test.describe('books', () => { await expect(books.errors.first()).toBeVisible(); }); }); + +// Les ADR exigent qu'un écran ne porte aucune violation WCAG 2.1 AA. axe ne +// juge que ce qu'une machine peut vérifier — la moitié des critères environ — +// mais cette moitié se régresse sans qu'on s'en aperçoive, alors que le reste +// se relit. +// +// Un écran ajouté au parcours s'ajoute ici : le coût est une ligne, l'oubli se +// paie en audit. +test.describe('accessibilité', () => { + test('les écrans ne portent aucune violation', async ({ page }) => { + const books = new BooksPage(page); + await books.goto(); + await expect(books.heading).toBeVisible(); + + const resultats = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) + .analyze(); + + expect(resultats.violations).toEqual([]); + }); +}); diff --git a/packages/server/skel/fullstack/e2e/package.json b/packages/server/skel/fullstack/e2e/package.json index 8716de26..bab81ad7 100644 --- a/packages/server/skel/fullstack/e2e/package.json +++ b/packages/server/skel/fullstack/e2e/package.json @@ -9,6 +9,7 @@ "typecheck": "tsc --noEmit" }, "devDependencies": { + "@axe-core/playwright": "^4.13.0", "@playwright/test": "^1.63.0", "@types/node": "^24.0.0", "typescript": "^7.0.0" diff --git a/packages/server/skel/fullstack/front/CLAUDE.md b/packages/server/skel/fullstack/front/CLAUDE.md index 028e9224..ba892d13 100644 --- a/packages/server/skel/fullstack/front/CLAUDE.md +++ b/packages/server/skel/fullstack/front/CLAUDE.md @@ -85,6 +85,22 @@ MSW intercepte au niveau réseau, donc le vrai `apiClient` tourne dans les tests Un test de feature couvre le cas nominal, l'erreur serveur, et la validation. +## Accessibilité + +Les composants se construisent sur les rôles ARIA, pas sur des `div` : c'est ce +qui rend un écran utilisable au clavier et au lecteur d'écran, et c'est aussi ce +qui rend les tests lisibles — `getByRole('button', { name: 'Envoyer' })` décrit +l'intention là où un sélecteur CSS décrit le balisage. + +Trois points qu'axe ne détecte pas et qui reviennent : + +- un bouton dont le libellé est une icône a besoin d'un `aria-label` ; +- un message d'erreur a besoin de `role="alert"` pour être annoncé ; +- un compteur qui change a besoin d'`aria-live` pour l'être aussi. + +Les composants copiés de shadcn/ui portent leurs `aria-label` **en anglais** : +les traduire fait partie de la reprise. + ## Système de design Tailwind est installé, sans bibliothèque de composants. `src/components/ui/` From f004919d3d32de12b3fb9e6b37e9fd97fb8017ef Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Thu, 10 Sep 2026 12:35:33 +0200 Subject: [PATCH 53/80] =?UTF-8?q?fix(server):=20rendre=20la=20CI=20verte?= =?UTF-8?q?=20et=20sortir=20les=20secrets=20du=20code=20g=C3=A9n=C3=A9r?= =?UTF-8?q?=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - retirer test/connect/requestloggerTest.js, resté sur l'API X-Request-Id remplacée par b797634 ; RequestIdTest devient TraceContextTest et vérifie aussi traceresponse et le trace id du span actif (@opentelemetry/api en dev), ainsi que le trace_id posé sur les logs émis pendant la requête - logger.provideRequestId/currentRequestId deviennent provideTraceId/ currentTraceId : plus de « request id » dans le vocabulaire du code - igo create tire les secrets de session dans le .env copié ; le squelette ne les écrit plus dans app/config.ts, qui disparaît (igo tolère son absence) - LOG_REQUESTS lu dans l'environnement, comme LOG_FORMAT et LOG_LEVEL : le plancher de statut est un réglage de déploiement, pas de l'application - config.appname et config.version remontent jusqu'au package.json le plus proche : `serve` démarre depuis dist/, qui n'en a pas - squelette : PUT remplace le livre entier (UpdateBook = BookInput, plus de partial() ni de default), tests PUT nominal/400/404, contraste text-slate-500 pour l'audit axe, dépendances OpenTelemetry en production puisque `serve` les importe, instrumentation importée en tête de app.ts plutôt que par --import (mesuré identique depuis dist/ : 2 spans http, 36 express, 4 mysql2) Co-Authored-By: Claude Fable 5.1 --- package-lock.json | 11 ++ packages/server/cli/create.js | 13 +++ packages/server/index.d.ts | 2 +- packages/server/package.json | 1 + packages/server/skel/fullstack/CLAUDE.md | 6 +- packages/server/skel/fullstack/api/CLAUDE.md | 22 +++- .../server/skel/fullstack/api/_.env.example | 26 +++-- packages/server/skel/fullstack/api/app.ts | 2 + .../server/skel/fullstack/api/app/config.ts | 14 --- .../api/app/features/books/books.dto.ts | 8 +- .../skel/fullstack/api/instrumentation.ts | 16 ++- .../server/skel/fullstack/api/package.json | 10 +- .../api/test/features/books/BooksTest.ts | 37 +++++++ .../features/books/components/books-list.tsx | 2 +- .../src/features/books/pages/books-page.tsx | 2 +- packages/server/src/config.js | 61 +++++++---- packages/server/src/connect/requestlogger.js | 2 +- packages/server/src/logger.js | 32 +++--- packages/server/test/ConfigTest.js | 31 ++++++ packages/server/test/CreateTest.js | 14 +++ .../{RequestIdTest.js => TraceContextTest.js} | 102 +++++++++++++++++- .../server/test/connect/requestloggerTest.js | 34 ------ 22 files changed, 323 insertions(+), 125 deletions(-) delete mode 100644 packages/server/skel/fullstack/api/app/config.ts rename packages/server/test/{RequestIdTest.js => TraceContextTest.js} (50%) delete mode 100644 packages/server/test/connect/requestloggerTest.js diff --git a/package-lock.json b/package-lock.json index 70857184..ffcf588f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1648,6 +1648,16 @@ "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", "license": "MIT" }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@parcel/watcher": { "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", @@ -12563,6 +12573,7 @@ "igo": "cli/igo.js" }, "devDependencies": { + "@opentelemetry/api": "^1.9.1", "mocha": "^12.0.0" }, "peerDependencies": { diff --git a/packages/server/cli/create.js b/packages/server/cli/create.js index 0a121dcf..9e672922 100644 --- a/packages/server/cli/create.js +++ b/packages/server/cli/create.js @@ -61,6 +61,18 @@ const replaceInDirectory = async (dir, replacements) => { } }; +// Session secrets belong in the .env nobody commits — not in the versioned +// example, and not in app/config where a generated value would end up in git. +const drawSecrets = async (envFile) => { + const content = await fs.readFile(envFile, 'utf8'); + const filled = content + .replace(/^COOKIE_SECRET=$/m, `COOKIE_SECRET=${utils.randomString(40)}`) + .replace(/^COOKIE_SESSION_KEYS=$/m, `COOKIE_SESSION_KEYS=${utils.randomString(40)}`); + if (filled !== content) { + await fs.writeFile(envFile, filled, 'utf8'); + } +}; + // A skeleton ships .env.example files; the project needs a .env to boot. A // missing one costs a confusing first error, so copy them — never overwriting // an existing .env. @@ -77,6 +89,7 @@ const seedEnvFiles = async (dir) => { const target = path.join(current, '.env'); if (!await fse.pathExists(target)) { await fse.copy(full, target); + await drawSecrets(target); copied.push(path.relative(dir, target)); } } diff --git a/packages/server/index.d.ts b/packages/server/index.d.ts index 91793937..2101579d 100644 --- a/packages/server/index.d.ts +++ b/packages/server/index.d.ts @@ -53,7 +53,7 @@ export interface Config { logformat: 'json' | 'human'; /** * true logs every request, false none. A number is a status floor: 400 keeps - * the errors and drops the successes. + * the errors and drops the successes. Read from LOG_REQUESTS. */ logrequests: boolean | number; /** diff --git a/packages/server/package.json b/packages/server/package.json index 80381758..64e423f1 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -71,6 +71,7 @@ "sass": "^1.0.0" }, "devDependencies": { + "@opentelemetry/api": "^1.9.1", "mocha": "^12.0.0" } } diff --git a/packages/server/skel/fullstack/CLAUDE.md b/packages/server/skel/fullstack/CLAUDE.md index 89d2e9ef..d7e293ac 100644 --- a/packages/server/skel/fullstack/CLAUDE.md +++ b/packages/server/skel/fullstack/CLAUDE.md @@ -66,9 +66,9 @@ ne les lance pas — `pnpm test:e2e` est une commande à part. Désactivée tant que la destination est absente : `OTEL_EXPORTER_OTLP_ENDPOINT` côté API, `VITE_FARO_URL` côté front. Ne pas inventer d'autre drapeau. -`api/instrumentation.ts` est chargé par `--import`, donc **avant** igo : -OpenTelemetry doit remplacer `express` et `mysql2` avant leur chargement. Deux -conséquences à ne pas défaire : +`api/instrumentation.ts` est la première ligne de `app.ts`, **avant** +`@igojs/server` : OpenTelemetry pose ses crochets sur `require`, et doit précéder +le chargement d'`express` et de `mysql2`. Deux conséquences à ne pas défaire : - sa première ligne est `import '@igojs/server/env'`, sans quoi le `.env` n'est pas encore lu et toute variable `OTEL_*` vaut `undefined` — le SDK ne démarre diff --git a/packages/server/skel/fullstack/api/CLAUDE.md b/packages/server/skel/fullstack/api/CLAUDE.md index 68dc91f9..7d24dc9f 100644 --- a/packages/server/skel/fullstack/api/CLAUDE.md +++ b/packages/server/skel/fullstack/api/CLAUDE.md @@ -13,6 +13,7 @@ pnpm lint # oxlint pnpm format # oxfmt pnpm typecheck # tsc --noEmit pnpm build # -> dist/ +pnpm serve # dist/, configuré par l'environnement : pas de .env dans dist ``` Depuis la racine : `pnpm --filter ./api test`. MySQL et Valkey doivent tourner @@ -30,7 +31,7 @@ app/ .service.ts logique métier, dès qu'elle branche .ts le modèle ORM du domaine shared/ ce qui est transversal — models, services, utils - config.ts surcharge de la config igo + config.ts surcharge de la config igo — optionnel, absent au départ routes.ts montage sql/ migrations, une par fichier daté seeds/ données de dev, jouées à la demande @@ -43,6 +44,25 @@ migre dans `shared/models/` — c'est la seule règle, et elle demande du jugeme Une feature peut importer chez une autre (`../dossiers/Dossier`) : l'organisation porte la propriété, pas l'isolation. +## Configuration + +igo lit `app/config.ts` s'il existe. Le seul réglage qu'un projet a +généralement à poser : + +```ts +import type { Config } from '@igojs/server'; + +export const init = (config: Config) => { + // Une ligne par requête est le premier poste d'un volume de logs, et les + // succès n'apprennent rien que les métriques ne portent déjà. + config.logrequests = 400; +}; +``` + +À poser quand l'observabilité est branchée — pas avant, sinon on perd les seules +traces d'activité dont on dispose. Les secrets de session viennent du `.env`, +jamais de ce fichier. + ## Conventions **Les routes API se montent avec `app.api()`** — le préfixe `/api` vient de diff --git a/packages/server/skel/fullstack/api/_.env.example b/packages/server/skel/fullstack/api/_.env.example index fa524a5d..9d794f38 100644 --- a/packages/server/skel/fullstack/api/_.env.example +++ b/packages/server/skel/fullstack/api/_.env.example @@ -1,9 +1,9 @@ -# Copy to .env — never commit .env +# Copié en .env par `igo create` — ne jamais committer .env NODE_ENV=dev HTTP_PORT=3000 -# Sessions: generate with `openssl rand -hex 32`. igo refuses to start in -# production with the default values. +# Tirés au sort par `igo create` dans le .env, jamais dans le code. Laissés +# vides ici : igo refuse de démarrer en production avec ses valeurs par défaut. COOKIE_SECRET= COOKIE_SESSION_KEYS= @@ -16,23 +16,27 @@ MYSQL_DATABASE={project.name} REDIS_HOST=127.0.0.1 REDIS_PORT=6379 -# json in production, human elsewhere +# json en production, human ailleurs # LOG_FORMAT=json # LOG_LEVEL=info +# 400 en production : les métriques portent déjà latence et taux d'erreur, seules +# les erreurs méritent une ligne. true en intégration pour tout voir. +# LOG_REQUESTS=400 # SMTP_HOST= # SMTP_USER= # SMTP_PASSWORD= # SMTP_FROM= -# --- Observability (optional) --------------------------------------------- -# Nothing is sent while OTEL_EXPORTER_OTLP_ENDPOINT is unset: no quota spent on -# a development machine, and no local data mixed into production's. +# --- Observabilité (optionnelle) ------------------------------------------- +# Rien n'est envoyé tant que OTEL_EXPORTER_OTLP_ENDPOINT est absent : aucun +# quota consommé sur un poste de développement, aucune donnée locale mêlée à +# celles de la production. # -# The application never writes to a platform directly. It speaks OTLP to a -# local collector — Grafana Alloy — which relays, filters and derives metrics. -# Point this at that collector, not at a vendor URL. +# L'application n'écrit jamais directement dans une plateforme. Elle parle OTLP +# à un collecteur local — Grafana Alloy — qui relaie, filtre et dérive les +# métriques. Viser ce collecteur, pas l'URL d'un fournisseur. # OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 -# Name the service appears under. Defaults to the package name. +# Nom sous lequel le service apparaît. Par défaut, le nom du paquet. # OTEL_SERVICE_NAME={project.name} diff --git a/packages/server/skel/fullstack/api/app.ts b/packages/server/skel/fullstack/api/app.ts index f9e11e89..a56603b4 100644 --- a/packages/server/skel/fullstack/api/app.ts +++ b/packages/server/skel/fullstack/api/app.ts @@ -1,3 +1,5 @@ +import './instrumentation'; + import { app } from '@igojs/server'; app.run(); diff --git a/packages/server/skel/fullstack/api/app/config.ts b/packages/server/skel/fullstack/api/app/config.ts deleted file mode 100644 index c3e6ec87..00000000 --- a/packages/server/skel/fullstack/api/app/config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Config } from '@igojs/server'; - -export const init = (config: Config) => { - config.cookieSecret = '{RANDOM_1}'; - config.cookieSession.keys = ['{RANDOM_2}']; - - // Une ligne par requête est le premier poste d'un volume de logs, et les - // requêtes qui réussissent n'apprennent rien que les métriques ne portent - // déjà : latence par route et taux d'erreur se dérivent des spans. - // - // À décommenter quand l'observabilité est branchée — pas avant, sinon on perd - // les seules traces d'activité dont on dispose. - // config.logrequests = 400; -}; diff --git a/packages/server/skel/fullstack/api/app/features/books/books.dto.ts b/packages/server/skel/fullstack/api/app/features/books/books.dto.ts index 4136054d..ea60c043 100644 --- a/packages/server/skel/fullstack/api/app/features/books/books.dto.ts +++ b/packages/server/skel/fullstack/api/app/features/books/books.dto.ts @@ -4,14 +4,16 @@ import type { BookRow } from './Book'; // Entrant : ce que l'API accepte. Coercition et valeurs par défaut sont // appliquées avant le contrôleur, donc req.body et req.query portent déjà les // bons types. -export const CreateBook = z.object({ +const BookInput = z.object({ title: z.string().min(1).max(255), author: z.string().min(1).max(255), pages: z.number().int().positive(), - published: z.boolean().default(false), + published: z.boolean(), }); -export const UpdateBook = CreateBook.partial(); +// Un livre naît non publié. +export const CreateBook = BookInput.extend({ published: z.boolean().default(false) }); +export const UpdateBook = BookInput; export const ListBooks = z.object({ page: z.coerce.number().int().min(1).default(1), diff --git a/packages/server/skel/fullstack/api/instrumentation.ts b/packages/server/skel/fullstack/api/instrumentation.ts index 9e9e2360..ffe3cd9c 100644 --- a/packages/server/skel/fullstack/api/instrumentation.ts +++ b/packages/server/skel/fullstack/api/instrumentation.ts @@ -8,16 +8,12 @@ import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; import { NodeSDK } from '@opentelemetry/sdk-node'; import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'; -// Chargé par --import, AVANT igo : OpenTelemetry instrumente en remplaçant les -// modules au moment du require, donc ce fichier doit passer en premier. Importé -// depuis app.ts, il n'instrumenterait ni express ni mysql2. -// -// Conséquence : le .env n'est pas encore lu, puisque c'est la configuration -// d'igo qui appelle dotenv. Sans le premier import ci-dessus, toute variable -// OTEL_* lue plus bas vaudrait undefined et le SDK ne démarrerait pas — -// silencieusement, sans erreur ni donnée. Ce point d'entrée charge le .env sans -// rien initialiser, là où importer @igojs/server tirerait express et winston, -// soit précisément ce que ce fichier devait précéder. +// Importé en première ligne de app.ts : OpenTelemetry pose ses crochets sur +// `require`, donc ce fichier doit précéder @igojs/server, qui charge express et +// mysql2. Rien de l'application ne doit être importé ici. Le premier import +// charge le .env sans rien d'autre : la configuration d'igo n'a pas encore +// tourné, et sans lui toute variable OTEL_* vaudrait undefined — le SDK ne +// démarrerait pas, sans erreur ni donnée. // Les détecteurs par défaut ajoutent treize attributs de ressource — dont // process.pid, process.command_args, process.executable.path, host.id — et un diff --git a/packages/server/skel/fullstack/api/package.json b/packages/server/skel/fullstack/api/package.json index 871ac5d6..f2187b7e 100644 --- a/packages/server/skel/fullstack/api/package.json +++ b/packages/server/skel/fullstack/api/package.json @@ -7,8 +7,8 @@ "main": "dist/app.js", "scripts": { "build": "tsc && cp -R sql locales dist/", - "start": "tsx watch --import ./instrumentation.ts app.ts", - "serve": "cd dist && node --import ./instrumentation.js app.js", + "start": "tsx watch app.ts", + "serve": "cd dist && node app.js", "migrate": "igo db migrate", "seed": "NODE_OPTIONS=\"--import tsx\" igo db seed", "lint": "oxlint", @@ -22,9 +22,6 @@ "@igojs/igo": "{igo.version}", "@igojs/server": "{igo.version}", "@opentelemetry/api": "^1.9.1", - "zod": "^4.5.4" - }, - "devDependencies": { "@opentelemetry/auto-instrumentations-node": "^0.80.0", "@opentelemetry/exporter-metrics-otlp-http": "^0.222.0", "@opentelemetry/exporter-trace-otlp-http": "^0.222.0", @@ -32,6 +29,9 @@ "@opentelemetry/sdk-metrics": "^2.11.0", "@opentelemetry/sdk-node": "^0.222.0", "@opentelemetry/semantic-conventions": "^1.43.0", + "zod": "^4.5.4" + }, + "devDependencies": { "@types/express": "^5.0.6", "@types/mocha": "^10.0.10", "@types/node": "^24.0.0", diff --git a/packages/server/skel/fullstack/api/test/features/books/BooksTest.ts b/packages/server/skel/fullstack/api/test/features/books/BooksTest.ts index 0eb0f899..a14893af 100644 --- a/packages/server/skel/fullstack/api/test/features/books/BooksTest.ts +++ b/packages/server/skel/fullstack/api/test/features/books/BooksTest.ts @@ -91,6 +91,43 @@ describe('api/books', function () { }); }); + describe('PUT /api/books/:id', function () { + it('should replace the whole book', async () => { + const book = await createBook(); + + const res = await agent.put(`/api/books/${book.id}`, { + body: { title: 'Dune Messiah', author: 'Frank Herbert', pages: 256, published: true }, + }); + + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(res.data.title, 'Dune Messiah'); + assert.strictEqual(res.data.pages, 256); + assert.strictEqual(res.data.published, true); + }); + + it('should reject a partial body', async () => { + const book = await createBook(); + + const res = await agent.put(`/api/books/${book.id}`, { body: { title: 'Dune Messiah' } }); + + assert.strictEqual(res.statusCode, 400); + assert.deepStrictEqual(res.data.errors.map((e: { path: string }) => e.path).toSorted(), [ + 'author', + 'pages', + 'published', + ]); + }); + + it('should answer 404 for an unknown id', async () => { + const res = await agent.put('/api/books/999999', { + body: { title: 'Dune', author: 'Frank Herbert', pages: 412, published: false }, + }); + + assert.strictEqual(res.statusCode, 404); + assert.strictEqual(res.data.type, '/problems/book-not-found'); + }); + }); + describe('DELETE /api/books/:id', function () { it('should delete the book', async () => { const book = await createBook(); diff --git a/packages/server/skel/fullstack/front/src/features/books/components/books-list.tsx b/packages/server/skel/fullstack/front/src/features/books/components/books-list.tsx index 5906d619..f19f8b0d 100644 --- a/packages/server/skel/fullstack/front/src/features/books/components/books-list.tsx +++ b/packages/server/skel/fullstack/front/src/features/books/components/books-list.tsx @@ -15,7 +15,7 @@ export function BooksList({ books }: { books: Book[] }) { {book.title} {book.author}
      - {book.pages} pages + {book.pages} pages
    • ))}
    diff --git a/packages/server/skel/fullstack/front/src/features/books/pages/books-page.tsx b/packages/server/skel/fullstack/front/src/features/books/pages/books-page.tsx index b509d3cb..055a3fe3 100644 --- a/packages/server/skel/fullstack/front/src/features/books/pages/books-page.tsx +++ b/packages/server/skel/fullstack/front/src/features/books/pages/books-page.tsx @@ -22,7 +22,7 @@ export function BooksPage() { {data && ( <> -

    {data.page.total} in total

    +

    {data.page.total} in total

    )} diff --git a/packages/server/src/config.js b/packages/server/src/config.js index 7116b38f..d5cfe2d1 100644 --- a/packages/server/src/config.js +++ b/packages/server/src/config.js @@ -3,42 +3,64 @@ if (process.env.NODE_ENV !== 'production') { require('dotenv').config({ quiet: true }); } +const path = require('path'); + const config = {}; module.exports = config; const DEFAULT_COOKIE_SECRET = 'abcdefghijklmnopqrstuvwxyz'; const DEFAULT_SESSION_KEY = 'aaaaaaaaaaa'; -// A project without a readable package.json still has to boot. +// The nearest package.json at or above projectRoot: a build directory (dist/) +// has none of its own, and a project without one at all still has to boot. const readProjectPackage = (projectRoot) => { - try { - return require(projectRoot + '/package.json'); - } catch { - return {}; + let dir = path.resolve(projectRoot); + for (;;) { + try { + return require(path.join(dir, 'package.json')); + } catch { + const parent = path.dirname(dir); + if (parent === dir) { + return {}; + } + dir = parent; + } } }; -// Reads the project package.json on first access rather than at init(), then -// caches it: a value set by the application always wins. -const defineProjectValue = (target, property, override, packageKey) => { +// Computed on first access rather than at init(), then cached: projectRoot can +// still be reassigned after init(), and a value set by the application wins. +const defineLazy = (target, property, compute) => { + const settle = (value) => Object.defineProperty(target, property, { + value, writable: true, configurable: true, enumerable: true + }); Object.defineProperty(target, property, { configurable: true, enumerable: true, get() { - const value = override || readProjectPackage(target.projectRoot)[packageKey]; - Object.defineProperty(target, property, { - value, writable: true, configurable: true, enumerable: true - }); + const value = compute(); + settle(value); return value; }, - set(value) { - Object.defineProperty(target, property, { - value, writable: true, configurable: true, enumerable: true - }); - }, + set: settle, }); }; +const defineProjectValue = (target, property, override, packageKey) => + defineLazy(target, property, () => override || readProjectPackage(target.projectRoot)[packageKey]); + +// LOG_REQUESTS=true|false|; anything else keeps the default. +const parseLogRequests = (value, fallback) => { + if (value === 'true' || value === 'false') { + return value === 'true'; + } + const floor = Number(value); + return value && Number.isInteger(floor) && floor > 0 ? floor : fallback; +}; + +module.exports.parseLogRequests = parseLogRequests; +module.exports.readProjectPackage = readProjectPackage; + // module.exports.init = function() { @@ -155,8 +177,9 @@ module.exports.init = function() { config.logformat = process.env.LOG_FORMAT || (config.env === 'production' ? 'json' : 'human'); // true logs every request, false none. A number is a status floor: 400 keeps // the errors and drops the successes, which is what keeps a log bill down - // once latency and error rate come from metrics. - config.logrequests = config.env !== 'test'; + // once latency and error rate come from metrics. A deployment setting, like + // the format, hence LOG_REQUESTS. + config.logrequests = parseLogRequests(process.env.LOG_REQUESTS, config.env !== 'test'); // Keys whose value redact() replaces, in crash emails and in the request line // of a failed request. Left null, the default covers what authenticates a diff --git a/packages/server/src/connect/requestlogger.js b/packages/server/src/connect/requestlogger.js index eb44681f..d1888316 100644 --- a/packages/server/src/connect/requestlogger.js +++ b/packages/server/src/connect/requestlogger.js @@ -152,7 +152,7 @@ const shouldLog = (status) => { return true; }; -logger.provideRequestId(() => storage.getStore()?.traceId); +logger.provideTraceId(() => storage.getStore()?.traceId); // One line per request, carrying the id every log of that request is stamped // with. Mounted by igo before the routes. diff --git a/packages/server/src/logger.js b/packages/server/src/logger.js index 2d0f0d0e..667c7d85 100644 --- a/packages/server/src/logger.js +++ b/packages/server/src/logger.js @@ -34,16 +34,14 @@ const logger = winston.createLogger({ ] }); -// Stamps every log emitted during a request with the id of that request, so -// its lines can be pulled together — and matched with what the client reports. -// -// The name is trace_id, the one OpenTelemetry uses: when instrumentation is on -// it has already stamped it, and when it is off igo fills the same field. One -// name for one value, whether the application is instrumented or not. -const withRequestId = winston.format((info) => { - const requestId = module.exports.currentRequestId(); - if (requestId && !info.trace_id) { - info.trace_id = requestId; +// Stamps every log emitted during a request with its trace id, so the lines of +// one request can be pulled together — and matched with what the client reports. +// When the OpenTelemetry winston instrumentation is on, it has already set the +// field; igo only fills it when nothing else did. +const withTraceId = winston.format((info) => { + const traceId = module.exports.currentTraceId(); + if (traceId && !info.trace_id) { + info.trace_id = traceId; } return info; }); @@ -51,14 +49,14 @@ const withRequestId = winston.format((info) => { // module.exports = logger; -// Set by the request logger; kept here so logger.js does not depend on the -// error handler, which already depends on config and mailer. -let currentRequestId = () => undefined; +// Provided by the request logger, which owns the per-request storage; kept as +// an injection so logger.js depends on nothing that depends on it. +let currentTraceId = () => undefined; -module.exports.currentRequestId = (...args) => currentRequestId(...args); +module.exports.currentTraceId = () => currentTraceId(); -module.exports.provideRequestId = (fn) => { - currentRequestId = fn; +module.exports.provideTraceId = (fn) => { + currentTraceId = fn; }; // @@ -76,7 +74,7 @@ module.exports.init = () => { } : undefined; logger.format = winston.format.combine( - withRequestId(), + withTraceId(), config.logformat === 'json' ? jsonFormat() : humanFormat() ); diff --git a/packages/server/test/ConfigTest.js b/packages/server/test/ConfigTest.js index 540522c8..d5b78a63 100644 --- a/packages/server/test/ConfigTest.js +++ b/packages/server/test/ConfigTest.js @@ -1,6 +1,7 @@ require('./init'); const assert = require('assert'); +const path = require('path'); const config = require('@igojs/server').config; describe('igo.config', () => { @@ -12,6 +13,36 @@ describe('igo.config', () => { assert.strictEqual(config.appname, projectPackage.name); assert.strictEqual(config.version, projectPackage.version); }); + + // `serve` scripts run from dist/, which has no package.json of its own + it('should climb to the nearest package.json when projectRoot is a build directory', () => { + const found = config.readProjectPackage(path.join(__dirname, 'project', 'app')); + assert.strictEqual(found.name, require('./project/package.json').name); + }); + + it('should boot a project with no package.json at all', () => { + assert.deepStrictEqual(config.readProjectPackage(path.parse(__dirname).root), {}); + }); + }); + + // init() runs once per process, so the parser is tested on its own + describe('LOG_REQUESTS', () => { + const parse = config.parseLogRequests; + + it('should read a status floor', () => { + assert.strictEqual(parse('400', true), 400); + }); + + it('should read true and false', () => { + assert.strictEqual(parse('true', false), true); + assert.strictEqual(parse('false', true), false); + }); + + it('should keep the default when unset or not understood', () => { + assert.strictEqual(parse(undefined, true), true); + assert.strictEqual(parse('loud', false), false); + assert.strictEqual(parse('0', true), true); + }); }); describe('config.checkSecrets', () => { diff --git a/packages/server/test/CreateTest.js b/packages/server/test/CreateTest.js index e88ef54d..0dc85f49 100644 --- a/packages/server/test/CreateTest.js +++ b/packages/server/test/CreateTest.js @@ -57,6 +57,20 @@ describe('cli/create', function() { 'business errors carry their own problem type'); }); + it('should draw the session secrets into the .env, never into the code', async () => { + await create({ _: ['create', 'myapi'], skel: 'fullstack' }); + + const env = fs.readFileSync(path.join(tmp, 'myapi', 'api', '.env'), 'utf8'); + assert.match(env, /^COOKIE_SECRET=[A-Za-z0-9]{40}$/m); + assert.match(env, /^COOKIE_SESSION_KEYS=[A-Za-z0-9]{40}$/m); + + const app = fs.readdirSync(path.join(tmp, 'myapi', 'api', 'app'), { recursive: true }) + .filter(f => f.endsWith('.ts')) + .map(f => fs.readFileSync(path.join(tmp, 'myapi', 'api', 'app', f), 'utf8')); + assert(!app.some(source => source.includes('cookieSecret')), + 'a generated secret would be committed with the code'); + }); + it('should ship migrations and seeds in the api skeleton', async () => { await create({ _: ['create', 'myapi'], skel: 'fullstack' }); diff --git a/packages/server/test/RequestIdTest.js b/packages/server/test/TraceContextTest.js similarity index 50% rename from packages/server/test/RequestIdTest.js rename to packages/server/test/TraceContextTest.js index b0e1a93d..269f129f 100644 --- a/packages/server/test/RequestIdTest.js +++ b/packages/server/test/TraceContextTest.js @@ -1,11 +1,15 @@ require('./init'); -const assert = require('assert'); +const assert = require('assert'); +const otel = require('@opentelemetry/api'); +const winston = require('winston'); +const { Writable } = require('stream'); +const { config, logger } = require('@igojs/server'); const middleware = require('../src/connect/requestlogger'); // Drives the middleware and returns what it settled on. -const run = (headers = {}) => { +const run = (headers = {}, next = () => {}) => { const sent = {}; const req = { method: 'GET', originalUrl: '/', headers }; const res = { @@ -13,13 +17,73 @@ const run = (headers = {}) => { setHeader: (name, value) => { sent[name] = value; }, on: () => {}, }; - middleware(req, res, () => {}); + middleware(req, res, next); return { traceId: req.traceId, sent }; }; +// The OpenTelemetry API ships without a context manager: its `with()` runs the +// callback but `active()` keeps answering the root context. This one is just +// enough to make a span active for the duration of a callback. +class StackContextManager { + constructor() { this.stack = [otel.ROOT_CONTEXT]; } + active() { return this.stack[this.stack.length - 1]; } + with(context, fn, thisArg, ...args) { + this.stack.push(context); + try { + return fn.call(thisArg, ...args); + } finally { + this.stack.pop(); + } + } + bind(context, target) { return target; } + enable() { return this; } + disable() { return this; } +} + +const SPAN = { traceId: '0af7651916cd43dd8448eb211c80319c', spanId: 'b7ad6b7169203331', traceFlags: 1 }; + +// Runs fn with a span carrying SPAN active, the way a registered SDK would. +const withActiveSpan = (fn) => { + otel.context.setGlobalContextManager(new StackContextManager()); + try { + const span = otel.trace.wrapSpanContext(SPAN); + return otel.context.with(otel.trace.setSpan(otel.context.active(), span), fn); + } finally { + otel.context.disable(); + } +}; + +// Captures the JSON lines the logger writes while fn runs. +const captureLogs = (fn) => { + const lines = []; + const transports = logger.transports.slice(); + const saved = { format: logger.format, level: logger.level, logformat: config.logformat }; + + logger.clear(); + logger.add(new winston.transports.Stream({ + stream: new Writable({ + write(chunk, encoding, callback) { lines.push(JSON.parse(chunk.toString())); callback(); }, + }), + })); + config.logformat = 'json'; + logger.init(); + lines.length = 0; + logger.level = 'info'; + try { + fn(); + } finally { + logger.clear(); + transports.forEach(t => logger.add(t)); + config.logformat = saved.logformat; + logger.format = saved.format; + logger.level = saved.level; + } + return lines; +}; + const TRACE_ID = /^[0-9a-f]{32}$/; -describe('request identity', function() { +describe('trace context', function() { // Without a registered SDK there is no active span, so igo produces a value // of its own — with the shape of a trace id, so that the day instrumentation @@ -83,8 +147,38 @@ describe('request identity', function() { assert.notStrictEqual(traceId, '4bf92f3577b34da6a3ce929d0e0e4736'); }); + // A registered SDK has already reconciled the inbound header into the active + // span: that span is the identity, even when the header says otherwise. + it('should adopt the trace id of the active span over the inbound header', () => { + const { traceId } = withActiveSpan(() => run({ + traceparent: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + })); + assert.strictEqual(traceId, SPAN.traceId); + }); + + // traceresponse is the way back defined by Trace Context Level 2: it carries + // the server span id, which is what lets a browser attach its span to it. + it('should send traceresponse with the active span when instrumented', () => { + const { sent } = withActiveSpan(() => run()); + assert.strictEqual(sent.traceresponse, `00-${SPAN.traceId}-${SPAN.spanId}-01`); + }); + + it('should stamp every log emitted during the request with its trace id', () => { + let traceId; + const lines = captureLogs(() => { + ({ traceId } = run({}, () => logger.info('inside the request', { step: 1 }))); + }); + assert.strictEqual(lines.length, 1); + assert.strictEqual(lines[0].message, 'inside the request'); + assert.strictEqual(lines[0].trace_id, traceId); + }); + // X-Request-Id is gone: one identity, under the name the spec gives it. And // traceresponse needs a server span, which only a registered SDK provides. + it('should expose no id outside of a request', () => { + assert.strictEqual(middleware.traceId(), undefined); + }); + it('should send no X-Request-Id', () => { assert.strictEqual(run().sent['X-Request-Id'], undefined); }); diff --git a/packages/server/test/connect/requestloggerTest.js b/packages/server/test/connect/requestloggerTest.js deleted file mode 100644 index 1362e330..00000000 --- a/packages/server/test/connect/requestloggerTest.js +++ /dev/null @@ -1,34 +0,0 @@ -require('../init'); - -const assert = require('assert'); -const agent = require('@igojs/server').dev.agent; - -const requestlogger = require('@igojs/server/src/connect/requestlogger'); - -describe('connect/requestlogger', function() { - - it('should expose a request id to the handler and the client', async () => { - const res = await agent.get('/'); - assert.match(res.headers['X-Request-Id'], /^[0-9a-f-]{36}$/); - }); - - it('should give each request its own id', async () => { - const first = await agent.get('/'); - const second = await agent.get('/'); - assert.notStrictEqual(first.headers['X-Request-Id'], second.headers['X-Request-Id']); - }); - - it('should reuse an id issued upstream, so one request can be followed across services', async () => { - const res = await agent.get('/', { headers: { 'x-request-id': 'from-the-proxy' } }); - assert.strictEqual(res.headers['X-Request-Id'], 'from-the-proxy'); - }); - - it('should ignore an absurdly long inbound id', async () => { - const res = await agent.get('/', { headers: { 'x-request-id': 'x'.repeat(300) } }); - assert.match(res.headers['X-Request-Id'], /^[0-9a-f-]{36}$/); - }); - - it('should not leak a request id outside of a request', () => { - assert.strictEqual(requestlogger.requestId(), undefined); - }); -}); From 9b9309fd4850e5d516a1168271d1ee38d480e632 Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Thu, 10 Sep 2026 14:33:13 +0200 Subject: [PATCH 54/80] =?UTF-8?q?fix(server):=20identit=C3=A9=20de=20trace?= =?UTF-8?q?=20sur=20toute=20r=C3=A9ponse,=20requ=C3=AAtes=20API=20sans=20m?= =?UTF-8?q?iddlewares=20de=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - traceresponse émis sur chaque réponse : span et drapeaux réels quand un SDK est enregistré, span-id tiré au sort et drapeaux 00 sinon — igo fabrique le trace id en entrée, il le renvoie en sortie ; req.traceId déclaré sur Request - unlessApi() : flash, locals, assets et dust ne tournent plus sur une requête API. Le seul flash écrivait dans la session à chaque GET, et chaque réponse JSON posait un cookie de session que rien ne lisait - redact() ne parcourt que les objets simples et les tableaux : un Date sortait en {} et un Buffer était éclaté - CLI : une ligne d'erreur et sortie, au lieu de deux piles et d'un undefined ; les erreurs MySQL nomment hôte:port/base, la cause habituelle étant un autre serveur sur le port - zod passe en devDependencies : rien dans src/ ne le requiert, la validation accepte toute bibliothèque Standard Schema Co-Authored-By: Claude Fable 5.1 --- docs/server/api.md | 4 +++ package-lock.json | 7 +++--- packages/server/index.d.ts | 4 +++ packages/server/package.json | 6 ++--- packages/server/src/api/index.js | 7 ++++++ packages/server/src/app.js | 9 ++++--- packages/server/src/connect/errorhandler.js | 26 ++++++++++++++++++++ packages/server/src/connect/requestlogger.js | 23 ++++++++--------- packages/server/src/redact.js | 10 ++++++++ packages/server/test/CliErrorTest.js | 19 ++++++++++++++ packages/server/test/RedactTest.js | 8 ++++++ packages/server/test/TraceContextTest.js | 11 ++++++--- packages/server/test/UnlessApiTest.js | 26 ++++++++++++++++++++ packages/server/test/types/valid.ts | 3 ++- 14 files changed, 137 insertions(+), 26 deletions(-) create mode 100644 packages/server/test/CliErrorTest.js create mode 100644 packages/server/test/UnlessApiTest.js diff --git a/docs/server/api.md b/docs/server/api.md index 41b7b4d9..432c9d12 100644 --- a/docs/server/api.md +++ b/docs/server/api.md @@ -21,6 +21,10 @@ repeated in the code. Override it in `app/config.js` if you need to: config.api.prefix = '/v1'; ``` +An API request skips the middlewares that only serve rendered pages — flash +scope, view locals, asset manifest — so no session cookie is set until a +controller writes to `req.session`. + ## Anatomy of a domain ``` diff --git a/package-lock.json b/package-lock.json index ffcf588f..67b0edb4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12369,6 +12369,7 @@ "version": "4.5.4", "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -12566,15 +12567,15 @@ "webpack": "^5.109.2", "webpack-cli": "^7.2.2", "webpack-dev-server": "^6.0.0", - "winston": "^3.19.0", - "zod": "^4.5.4" + "winston": "^3.19.0" }, "bin": { "igo": "cli/igo.js" }, "devDependencies": { "@opentelemetry/api": "^1.9.1", - "mocha": "^12.0.0" + "mocha": "^12.0.0", + "zod": "^4.5.4" }, "peerDependencies": { "autoprefixer": "^10.4.0", diff --git a/packages/server/index.d.ts b/packages/server/index.d.ts index 2101579d..8b053d99 100644 --- a/packages/server/index.d.ts +++ b/packages/server/index.d.ts @@ -18,6 +18,10 @@ declare global { */ api(path: string, ...handlers: Array): Application; } + interface Request { + /** Trace id of the request: the active span's when instrumented, an inbound traceparent's, or one igo generated. */ + traceId: string; + } } } diff --git a/packages/server/package.json b/packages/server/package.json index 64e423f1..3b8d0e0a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -57,8 +57,7 @@ "webpack": "^5.109.2", "webpack-cli": "^7.2.2", "webpack-dev-server": "^6.0.0", - "winston": "^3.19.0", - "zod": "^4.5.4" + "winston": "^3.19.0" }, "peerDependencies": { "autoprefixer": "^10.4.0", @@ -72,6 +71,7 @@ }, "devDependencies": { "@opentelemetry/api": "^1.9.1", - "mocha": "^12.0.0" + "mocha": "^12.0.0", + "zod": "^4.5.4" } } diff --git a/packages/server/src/api/index.js b/packages/server/src/api/index.js index 69924415..8d2e658b 100644 --- a/packages/server/src/api/index.js +++ b/packages/server/src/api/index.js @@ -32,4 +32,11 @@ module.exports.wire = () => { } }; +// Wraps a middleware that only serves rendered pages — flash scope, view +// locals, asset manifest — so an API request skips it. What it skips is not +// only wasted work: the flash scope writes to the session on every GET, and +// that alone made every JSON response set a session cookie nothing reads. +module.exports.unlessApi = (middleware) => (req, res, next) => + problem.isApiRequest(req) ? next() : middleware(req, res, next); + module.exports.problem = problem; diff --git a/packages/server/src/app.js b/packages/server/src/app.js index e87de21e..51a2656c 100644 --- a/packages/server/src/app.js +++ b/packages/server/src/app.js @@ -11,6 +11,7 @@ const cache = require('./cache'); const config = require('./config'); const db = require('@igojs/db'); const assets = require('./connect/assets'); +const { unlessApi } = require('./api'); const errorHandler = require('./connect/errorhandler'); const flash = require('./connect/flash'); const locals = require('./connect/locals'); @@ -112,7 +113,7 @@ module.exports.configure = async () => { app.use(requestLogger); - app.use(flash); + app.use(unlessApi(flash)); app.use(validator); // fix crash if lang is incorrect (in query or in cookies) @@ -121,9 +122,9 @@ module.exports.configure = async () => { app.use(validateLang(whitelist, config.i18n.fallbackLng)); app.use(i18nMiddleware.handle(i18next)); - app.use(locals); - app.use(assets); - app.use(igodust.middleware); + app.use(unlessApi(locals)); + app.use(unlessApi(assets)); + app.use(unlessApi(igodust.middleware)); // Auto-wire @igojs/component if installed in the project. // Registers component.middleware + GET /__component/templates and /__component/component. diff --git a/packages/server/src/connect/errorhandler.js b/packages/server/src/connect/errorhandler.js index 029fd5fc..479b2f87 100644 --- a/packages/server/src/connect/errorhandler.js +++ b/packages/server/src/connect/errorhandler.js @@ -235,8 +235,30 @@ const handle = (err, req, res) => { res.status(500).send(stacktrace); }; +// A CLI command has no request to answer and no server to keep alive: one +// line saying what failed, then exit. The database errors name the server +// they failed against, since the usual cause is another MySQL on the port. +const DB_ERROR = /^(ER_|ECONNREFUSED$|ETIMEDOUT$|ENOTFOUND$|EHOSTUNREACH$)/; + +const describeCliError = (err) => { + const message = err?.message || String(err); + if (!DB_ERROR.test(err?.code || '')) { + return message; + } + const { host, port, database } = config.mysql || {}; + return `MySQL ${host}:${port}/${database}: ${message}`; +}; + +const failCli = (err) => { + console.error(`\x1b[31m✖\x1b[0m ${describeCliError(err)}`); + process.exit(1); +}; + // Handle unhandled promise rejections process.on('unhandledRejection', (err) => { + if (global.IGO_CLI) { + return failCli(err); + } const context = asyncLocalStorage.getStore(); if (context && context.req && context.res) { @@ -251,6 +273,9 @@ process.on('unhandledRejection', (err) => { // Handle uncaught exceptions - log, send email, then exit process.on('uncaughtException', (err) => { + if (global.IGO_CLI) { + return failCli(err); + } const context = asyncLocalStorage.getStore(); const handled = !!(context && context.req && context.res); @@ -322,6 +347,7 @@ module.exports.errorSQL = (err) => { // Exposed for testing module.exports._test = { + describeCliError, escapeHtml, redact, checkThrottle, diff --git a/packages/server/src/connect/requestlogger.js b/packages/server/src/connect/requestlogger.js index d1888316..ddcdf3e3 100644 --- a/packages/server/src/connect/requestlogger.js +++ b/packages/server/src/connect/requestlogger.js @@ -37,9 +37,16 @@ const activeSpanContext = () => { const activeTraceId = () => activeSpanContext()?.traceId ?? null; -// Only known when a SDK is registered: without a span there is no server side -// for a client to attach to, so no traceresponse is sent. -const activeSpanId = () => activeSpanContext()?.spanId ?? null; +// The way back, as W3C Trace Context Level 2 defines it: the trace id, the +// server span id a browser can attach its own span to, and whether the server +// recorded the trace. Without a SDK igo minted the trace id itself, so it mints +// the span id the same way and reports the trace as not recorded. +const traceresponse = (traceId) => { + const span = activeSpanContext(); + const id = span?.spanId ?? randomBytes(8).toString('hex'); + const flags = (span?.traceFlags ?? 0).toString(16).padStart(2, '0'); + return `00-${traceId}-${id}-${flags}`; +}; // The version is deliberately not pinned to `00`: Trace Context Level 2 exists, // and the spec asks implementations to stay lenient about an unknown version @@ -169,14 +176,8 @@ module.exports = (req, res, next) => { req.traceId = traceId; - // traceresponse is what W3C Trace Context Level 2 defines for the way back, - // and it carries the server span id as well — which is what lets a browser - // attach its span to the server's. No X-Request-Id: one identity, under the - // name the specification gives it. - const spanId = activeSpanId(); - if (spanId) { - res.setHeader('traceresponse', `00-${traceId}-${spanId}-01`); - } + // No X-Request-Id: one identity, under the name the specification gives it. + res.setHeader('traceresponse', traceresponse(traceId)); captureResponseBody(res); diff --git a/packages/server/src/redact.js b/packages/server/src/redact.js index bbdf3db0..06a077fa 100644 --- a/packages/server/src/redact.js +++ b/packages/server/src/redact.js @@ -39,6 +39,13 @@ const DEFAULT_SENSITIVE_KEYS = new RegExp( const pattern = () => config.sensitiveKeys || DEFAULT_SENSITIVE_KEYS; +// Only plain objects and arrays are walked. A Date or a Buffer copied key by +// key comes out as `{}`, which is worse than the value it replaced. +const isPlain = (value) => { + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +}; + // Returns a copy with every sensitive value replaced. Circular references are // tracked: a request body can hold one, and a crash report must not recurse // until the stack gives out. @@ -46,6 +53,9 @@ const redact = (value, seen = new WeakSet()) => { if (!value || typeof value !== 'object') { return value; } + if (!Array.isArray(value) && !isPlain(value)) { + return value; + } if (seen.has(value)) { return '[circular]'; } diff --git a/packages/server/test/CliErrorTest.js b/packages/server/test/CliErrorTest.js new file mode 100644 index 00000000..57aba7cb --- /dev/null +++ b/packages/server/test/CliErrorTest.js @@ -0,0 +1,19 @@ +require('./init'); + +const assert = require('assert'); +const errorhandler = require('@igojs/server/src/connect/errorhandler'); + +// A CLI command that fails prints one line and exits; the database errors name +// the server, since the usual cause is another MySQL listening on the port. +describe('CLI failures', function() { + const { describeCliError } = errorhandler._test; + + it('should name the database server behind a MySQL error', () => { + const err = Object.assign(new Error('Unknown database \'audit\''), { code: 'ER_BAD_DB_ERROR' }); + assert.match(describeCliError(err), /^MySQL [^:]+:\d+\/\w+: Unknown database 'audit'$/); + }); + + it('should pass any other error through', () => { + assert.strictEqual(describeCliError(new Error('boom')), 'boom'); + }); +}); diff --git a/packages/server/test/RedactTest.js b/packages/server/test/RedactTest.js index 36aa174d..5ab2fb2f 100644 --- a/packages/server/test/RedactTest.js +++ b/packages/server/test/RedactTest.js @@ -10,6 +10,14 @@ describe('redact', function() { config.sensitiveKeys = null; }); + it('should leave a Date or a Buffer as it is', () => { + const when = new Date('2026-01-01'); + const out = redact({ when, raw: Buffer.from('ab'), nested: { password: 'x' } }); + assert.strictEqual(out.when, when); + assert(Buffer.isBuffer(out.raw)); + assert.strictEqual(out.nested.password, '[redacted]'); + }); + it('should redact the usual English field names', () => { const out = redact({ password: 'x', token: 'y', authorization: 'z', cookie: 'c' }); assert.deepStrictEqual(out, { diff --git a/packages/server/test/TraceContextTest.js b/packages/server/test/TraceContextTest.js index 269f129f..b8798552 100644 --- a/packages/server/test/TraceContextTest.js +++ b/packages/server/test/TraceContextTest.js @@ -173,8 +173,7 @@ describe('trace context', function() { assert.strictEqual(lines[0].trace_id, traceId); }); - // X-Request-Id is gone: one identity, under the name the spec gives it. And - // traceresponse needs a server span, which only a registered SDK provides. + // X-Request-Id is gone: one identity, under the name the spec gives it. it('should expose no id outside of a request', () => { assert.strictEqual(middleware.traceId(), undefined); }); @@ -183,8 +182,12 @@ describe('trace context', function() { assert.strictEqual(run().sent['X-Request-Id'], undefined); }); - it('should send no traceresponse without instrumentation', () => { - assert.strictEqual(run().sent.traceresponse, undefined); + // igo minted the trace id, so it mints the span id too, and says the trace + // was not recorded: the client still gets the id support will look for. + it('should send traceresponse with a generated span id when not instrumented', () => { + const { traceId, sent } = run(); + assert.strictEqual(sent.traceresponse, `00-${traceId}-${sent.traceresponse.slice(36, 52)}-00`); + assert.match(sent.traceresponse.slice(36, 52), /^[0-9a-f]{16}$/); }); }); diff --git a/packages/server/test/UnlessApiTest.js b/packages/server/test/UnlessApiTest.js new file mode 100644 index 00000000..800c26ab --- /dev/null +++ b/packages/server/test/UnlessApiTest.js @@ -0,0 +1,26 @@ +require('./init'); + +const assert = require('assert'); +const { unlessApi } = require('@igojs/server/src/api'); + +// The middlewares that only serve rendered pages must not run on an API +// request: the flash scope alone writes to the session on every GET, which +// made every JSON response set a session cookie nothing reads. +describe('unlessApi', function() { + const calls = []; + const wrapped = unlessApi((req, res, next) => { calls.push(req.path); next(); }); + + beforeEach(() => { calls.length = 0; }); + + it('should skip a view middleware on an API request', () => { + let reached = false; + wrapped({ path: '/api/books', headers: {} }, {}, () => { reached = true; }); + assert(reached); + assert.deepStrictEqual(calls, []); + }); + + it('should run it on a page request', () => { + wrapped({ path: '/books', headers: {} }, {}, () => {}); + assert.deepStrictEqual(calls, ['/books']); + }); +}); diff --git a/packages/server/test/types/valid.ts b/packages/server/test/types/valid.ts index 96203936..d38191f9 100644 --- a/packages/server/test/types/valid.ts +++ b/packages/server/test/types/valid.ts @@ -15,7 +15,8 @@ const ListBooks = z.object({ export const create: ApiHandler<{ body: typeof CreateBook }> = (req, res) => { const title: string = req.body.title; const pages: number = req.body.pages; - res.status(201).json({ title, pages }); + const traceId: string = req.traceId; + res.status(201).json({ title, pages, traceId }); }; create.body = CreateBook; From 46e0a492a6c8f4d65e19a7b3346c77970a5b6ab2 Mon Sep 17 00:00:00 2001 From: Mickael Coquer Date: Thu, 10 Sep 2026 14:38:19 +0200 Subject: [PATCH 55/80] docs(server): aligner la documentation sur le trace id et les deux squelettes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - logging.md : plus de request_id ni de X-Request-Id ; req.traceId, traceparent en entrée, traceresponse en sortie, trace_id sur les logs, LOG_REQUESTS et le plancher de statut, redact() et config.sensitiveKeys - getting-started.md et CLAUDE.md : deux squelettes, tailwind et fullstack ; plus d'exemple nginx - api.md : app/features//, comme le squelette - ADR : @axe-core/playwright remplace axe-playwright, trace_id remplace requestId Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 2 +- docs/adr/strategie-de-test-front.md | 4 +- docs/adr/strategie-observabilite.md | 4 +- docs/server/api.md | 4 +- docs/server/getting-started.md | 14 ++----- docs/server/logging.md | 64 ++++++++++++++++++++++------- 6 files changed, 61 insertions(+), 31 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5b4dfc6d..479031ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,7 @@ Igo is a Node.js full-stack web framework built on Express, providing ORM, templ - **CLI:** `packages/server/cli/igo.js` - **JSON API layer:** `packages/server/src/api/` - **TypeScript types:** `packages/server/index.d.ts` -- **Project skeletons:** `packages/server/skel/` — `api`, `front` and `fullstack` scaffold TypeScript projects with their own tooling (pnpm, oxlint, oxfmt); the others are igo apps +- **Project skeletons:** `packages/server/skel/` — `fullstack` scaffolds a TypeScript API + React SPA monorepo with its own tooling (pnpm, oxlint, oxfmt); `tailwind` is the server-rendered igo app ### @igojs/component (Reactive Components) - Single-file `.dust` components (`