Nodefony
Framework Node.js fullstack — HTTP & WebSocket, même contexte

Routage — de l'URL à l'action

stable@nodefony/frameworkmis à jour 2026-07-19

Le routage répond à une question, sur chaque requête : quel bout de ton code doit traiter cette URL ? Nodefony y répond avec une table ordonnée de routes où le premier motif qui correspond gagne — pas de score de spécificité, pas de magie. La même table sert le HTTP et le WebSocket : une action WS se déclare comme une action HTTP, avec un transport différent. Tout ci-dessous est ancré sur le code.

📍 DocumentationFrameworkRoutage

🧠 Le modèle mental — une table ordonnée, le premier match gagne#

Une route, c’est un motif d’URL + des contraintes (méthode, domaine, sous-protocole) + une action de contrôleur. Le Router garde toutes les routes du processus dans une seule liste, dans leur ordre de déclaration, et la parcourt jusqu’au premier motif satisfait.

Trois faits à retenir avant tout le reste :

  1. L’ordre de déclaration EST la priorité. Une route paramétrée déclarée avant une route littérale gagne sur le chemin littéral — c’est figé par le banc de non-régression (routing-nonregression.test.ts:83).
  2. Le routeur ne lève jamais de 404. Aucun match = resolver.resolve === false, sans exception ; le 404 est décidé plus loin, après le repli sur les fichiers statiques (HttpError("Not Found", 404), http-kernel.ts:798).
  3. Le chemin est vérifié avant la méthode, et le domaine entre les deux — c’est ce qui produit un 403 plutôt qu’un 405 bavard quand la route appartient à un autre vhost (Route.match(), Route.ts:212).

📖 Lexique#

Terme Sens (dans cette page)
Route Un motif d’URL + ses contraintes + l’action de contrôleur qui la sert.
Table de routes La liste Route[] unique du processus, dans l’ordre de déclaration.
Motif (pattern) L’expression régulière compilée depuis le chemin déclaré.
Variable de route Un segment capturé, noté {nom} — jamais à cheval sur un /.
Wildcard / catch-all Le * final, qui absorbe tout le reste du chemin (y compris les /).
Requirement Contrainte attachée à la route : methods, protocol, domain, ou une regex par variable.
Littérale/dynamique Partition interne : chemin sans métacaractère (lookup direct) vs chemin à motif (scan).
Passe 1 / Passe 2 Recherche du match, puis (si échec) calcul de l’en-tête Allow d’un 405.
Allow En-tête listant les méthodes servies par un chemin (RFC 9110 §15.5.6).
Vhost Hôte virtuel : le même serveur sert plusieurs noms de domaine, avec des routes différentes.
Duplex Un même chemin servi en HTTP et en WebSocket.
methodOverride Méthode HTTP logique d’une invocation WS, quand le transport seul (WEBSOCKET) ne suffit pas.
Resolver L’objet par requête qui porte la route trouvée, ses variables, puis appelle l’action.

Qu’est-ce que le routage ?#

Imagine le standard téléphonique d’un immeuble. Un appel arrive avec un numéro (/api/books/42) ; le standard consulte son tableau, ligne par ligne, et passe la communication au premier poste dont le numéro correspond. Si personne ne correspond, il essaie la boîte aux lettres (les fichiers statiques), et sinon il répond « ce numéro n’existe pas » (404).

Le routage, c’est ce tableau. Trois problèmes qu’il doit résoudre, et que tous les frameworks tranchent différemment :

La vision Nodefony#

L’arbitrage est explicite, pas calculé. Beaucoup de routeurs trient les routes par « spécificité » (le motif le plus précis gagne) — pratique jusqu’au jour où l’on ne comprend plus pourquoi telle route passe devant telle autre. Nodefony garde l’ordre de déclaration : la table est parcourue de haut en bas, le premier motif satisfait l’emporte (Router.resolve(), router.ts:230). Le compromis assumé : c’est à toi de déclarer le littéral avant le paramétré. En échange, tu peux lire l’ordre dans ton contrôleur.

La performance ne change pas la sémantique. Sous le capot, la table est partitionnée : les chemins littéraux (aucun {var}, aucun métacaractère) vivent dans une Map path → candidates en lookup O(1) ; les chemins dynamiques restent un scan regex (buildRouteIndex(), router.ts:92). À la résolution, les deux flux sont fusionnés par position d’insertion — la séquence de candidats est exactement celle du scan linéaire complet, moins les littérales d’un autre chemin, qui ne pouvaient de toute façon pas correspondre (Router.resolve(), router.ts:221). C’est cette équivalence que fige le banc de non-régression : un refacto du routeur doit le repasser à l’identique.

Une seule table pour HTTP et WebSocket. Il n’y a pas de « routeur WS » séparé : une action WS est une route dont les méthodes déclarées contiennent WEBSOCKET (Route.matchRequirements(), Route.ts:649). C’est le différenciateur du framework — le même contrôleur, le même contexte, les mêmes décorateurs.

Le routeur passe avant les fichiers statiques. Une requête qui correspond à une route ne paie jamais le stat du serveur de fichiers : le repli statique n’est tenté que si la résolution a échoué (serverStatic.handle(), http-kernel.ts:1200).

ℹ️ Note

Le routage n’a aucune option de configuration. Le schéma Zod du module n’expose qu’un sac d’options de Service pour le Router (config.ts:36) — tout se déclare par décorateurs, dans le contrôleur, à côté du code qu’ils servent. Pas de routes.yaml, pas de table centrale à maintenir.

🚀 Démarrage rapide#

Dans une app générée par nodefony create app, le routage est déjà actif : @nodefony/framework est dans le manifeste modules de nodefony.config.ts. Il ne reste qu’à écrire un contrôleur.

Le contrôleur — cinq routes qui couvrent tous les cas#

// nodefony/controllers/CatalogController.ts — complet, compile tel quel
import {
  Controller,
  controller,
  route,
  Get,
  Post,
  Param,
  Query,
} from "@nodefony/framework";
import type { ContextType } from "@nodefony/http";

// Le préfixe s'ajoute DEVANT le chemin de chaque route de la classe.
@controller("/api/catalog")
class CatalogController extends Controller {
  constructor(context: ContextType) {
    super("catalog", context);
  }

  // GET /api/catalog        — chemin littéral, lookup O(1)
  @Get("")
  async list(@Query("page") page?: string) {
    return this.renderJson({ page: Number(page ?? 1) });
  }

  // GET /api/catalog/book/{isbn} — `{isbn}` = UN segment, jamais deux
  @Get("/book/{isbn}")
  async one(@Param("isbn") isbn: string) {
    return this.renderJson({ isbn });
  }

  // POST sur le MÊME chemin qu'aucun GET ne sert → un GET ici renverra 405
  @Post("/book")
  async create() {
    return this.renderJson({ created: true });
  }

  // `@route` = la forme explicite : nom choisi + contraintes libres.
  // HEAD n'est PAS déduit de GET — il se déclare (cf Pièges).
  @route("route-catalog-files", {
    path: "/files/*",
    requirements: { methods: ["GET", "HEAD"] },
  })
  async files(rest: string) {
    return this.renderJson({ rest });
  }

  // MÊME contrôleur, transport WebSocket : `message` vaut null au handshake,
  // puis porte chaque frame reçue.
  @route("route-catalog-live", {
    path: "/live",
    requirements: { methods: ["WEBSOCKET"] },
  })
  async live(message: string | Buffer | null) {
    if (!message) return this.renderJson({ handshake: true });
    return this.render(message.toString());
  }
}

export default CatalogController;

Le câblage — déclarer le contrôleur au module de l’app#

Les routes sont créées à l’import du fichier (les décorateurs s’évaluent alors) ; @controllers rattache la classe au module au boot. nodefony create controller écrit ces deux lignes pour toi.

// index.ts (racine de l'app) — extrait
import { Kernel, Module } from "nodefony";
import { controllers } from "@nodefony/framework";
import config from "./nodefony.config.js";
import CatalogController from "./nodefony/controllers/CatalogController.js";

@controllers([CatalogController])
class App extends Module {
  constructor(kernel: Kernel) {
    super("app", kernel, import.meta.url, config);
  }
}

export default App;

Ce qu’on observe#

# 1) Chemin littéral + query string (la query n'entre PAS dans le matching)
curl -s 'http://localhost:5151/api/catalog?page=2'
# {"page":2}

# 2) Variable de route, valeur URL-décodée
curl -s http://localhost:5151/api/catalog/book/978-2-1234
# {"isbn":"978-2-1234"}

# 3) Slash final ignoré, casse ignorée — même route
curl -so /dev/null -w '%{http_code}\n' http://localhost:5151/API/Catalog/
# 200

# 4) Chemin connu, mauvaise méthode → 405 + Allow (RFC 9110 §15.5.6)
curl -si http://localhost:5151/api/catalog/book | grep -Ei '^(HTTP|allow)'
# HTTP/1.1 405 Method Not Allowed
# Allow: POST

# 5) Wildcard : tout le reste du chemin, séparateurs compris
curl -s http://localhost:5151/api/catalog/files/2026/rapport.pdf
# {"rest":"2026/rapport.pdf"}

# 6) Chemin inconnu → repli statique, puis 404
curl -so /dev/null -w '%{http_code}\n' http://localhost:5151/api/catalog/nope/nope
# 404

Le WebSocket, sur le même serveur et la même table :

npx wscat -c ws://localhost:5151/api/catalog/live
# < {"handshake":true}
# > bonjour
# < bonjour

Déclarer une route — trois formes#

La syntaxe des décorateurs est détaillée dans decorateurs ; ce qui suit est ce que chaque forme produit dans la table.

Forme Nom de la route Méthodes déclarées Quand l’utiliser
@Get @Post @Put @Delete @Patch auto : `Classe::methode` exactement une le cas courant, REST
@All(path) auto : `Classe::methode` aucune → toutes les méthodes proxy, capture-tout, page de repli
@route(nom, options) le tien (stable, réutilisable) requirements.methods (libre) WebSocket, multi-méthodes, contraintes fines

Comment une déclaration devient une route. Les décorateurs de méthode accumulent des métadonnées sur le constructeur (clé routes:definitions, routerDecorators.ts:16) ; c’est @controller(prefix) qui les lit et appelle Router.createRoute() pour chacune (controller(), routerDecorators.ts:129).

⚠️ Attention

@route/@Get doivent être SOUS @controller — les décorateurs de classe s’évaluent après ceux de méthode, et @controller doit trouver les métadonnées déjà posées. Un @controller placé au mauvais endroit ne crée aucune route, sans erreur : symptôme = 404 partout sur ce contrôleur.

Motifs de chemin et paramètres#

Le chemin déclaré est compilé une fois, à la création de la route, en une expression régulière ancrée et insensible à la casse (Route.compile(), Route.ts:395). La grammaire tient en cinq briques (REG_ROUTE, Route.ts:17) :

Écriture Motif compilé Capture Exemple
/books littéral /books (et /BOOKS, et /books/)
/books/{id} ([^/]+) id /books/42 ✅ · /books/a/b ❌ (un seul segment)
/books/{id}(\d+) (\d+) id, contrainte /books/42 ✅ · /books/abc ❌ (ne matche pas)
/files/* (.*)/? * et wildcard /files/a/b.txt ✅ · /files ❌ (le / est requis)
/report.{fmt} \.([^/]+) fmt /report.jsonfmt = "json"

Et trois comportements qui surprennent la première fois :

Une valeur par défaut rend le segment OPTIONNEL#

C’est le mécanisme le moins évident, et le plus utile. Déclarer un defaults pour une variable change le motif compilé : le segment devient facultatif ([^/]*) et son slash aussi (/?), puis la valeur par défaut est réinjectée quand la capture est vide (checkDefaultParameters(), Route.ts:99 · Route.hydrateDefaultParameters(), Route.ts:469).

@route("route-page", { path: "/page/{slug}", defaults: { slug: "home" } })
async page(slug: string) {
  return this.renderJson({ slug });
}
Requête slug reçu Pourquoi
/page/faq "faq" capture normale
/page "home" segment absent → défaut réinjecté
/page/ "home" slash final retiré, puis même cas que /page

Comment les valeurs arrivent dans l’action#

Les captures sont passées positionnellement, dans l’ordre des variables du chemin — c’est pourquoi la signature async method6(metier: string, format: string) suit l’ordre de /{metier}/{format}. Un wildcard est exposé sous les clés wildcard et *. Le Resolver en fabrique aussi un instantané nom → valeur par requête (Resolver.getMatchedParams(), Resolver.ts:170), lu par le contexte pour les métadonnées et par les décorateurs @Param.

❗ Important

Dès qu’un seul décorateur de paramètre (@Param, @Query, @Body…) est présent sur l’action, les arguments positionnels sont remplacés par les valeurs des décorateurs. On ne mélange pas les deux conventions dans une même signature.

⚙️ Ordre de résolution — trois situations#

L’ordre n’est pas un détail d’implémentation : c’est ta politique de routage. Trois situations concrètes, tirées du banc de non-régression.

Situation 1 — une fiche par identifiant, et une page « nouveau »#

Tu sers /books/{id} et tu veux aussi /books/new pour le formulaire de création. Les deux motifs correspondent à /books/new : {id} capturerait "new" comme un identifiant.

// ✅ le littéral D'ABORD — il gagne, et /books/42 tombe ensuite sur la paramétrée
@Get("/books/new")   newForm() {}
@Get("/books/{id}")  show(@Param("id") id: string) {}

// ❌ l'inverse : `show` reçoit id = "new", `newForm` n'est JAMAIS atteinte

Aucune spécificité n’est calculée : la première route déclarée qui correspond gagne (routing-nonregression.test.ts:83). La même règle vaut pour le catch-all *, qui absorbe tout ce qui le suit — un @All("*") déclaré tôt masque le reste du contrôleur.

💡 Astuce

Une exception utile : dans un contrôleur, une route dont le chemin vaut exactement "*" est repoussée en dernier au moment du montage — la capture-tout d’un contrôleur ne masque donc jamais ses propres routes, quel que soit l’ordre d’écriture (hasMagic, routerDecorators.ts:237). Ça ne vaut que pour "*" seul : /files/* reste ordonné comme les autres.

Situation 2 — le même chemin, deux méthodes#

Deux routes peuvent partager un chemin et se distinguer par la méthode. La passe 1 essaie la première, qui lève un 405 sur la méthode ; l’exception est mémorisée et le scan continue jusqu’à la route qui accepte la méthode (Router.resolve(), router.ts:230).

@Get("/book/{id}")    show() {}
@Delete("/book/{id}") remove() {}   // DELETE /book/42 → arrive bien ici

Si aucune route n’accepte la méthode, la passe 2 entre en scène : elle reparcourt la table, collecte toutes les méthodes servies par ce chemin sur ce vhost, et lève un 405 dont l’en-tête Allow est l’agrégat (collectSupportedMethods(), router.ts:31 ; en-tête posé sur la réponse, router.ts:31). C’est la conformité RFC 9110 §15.5.6 : Allow liste tout ce que la ressource accepte, pas seulement ce que la dernière route scannée acceptait.

Requête Réponse
DELETE /book/42 200 — la 2ᵉ route accepte
PATCH /book/42 405, Allow: GET, DELETE
GET /inexistant pas d’exception — repli statique, puis 404

Situation 3 — une route réservée à un domaine#

Une route restreinte par @Domain est invisible aux requêtes des autres vhosts : elle lève un 403 au lieu de participer au match (Route.matchHostname(), Route.ts:605). Le point de sécurité est l’ordre des vérifications : le domaine est vérifié avant la méthode. Sans cela, une route d’un autre vhost pourrait répondre 405 en révélant SES méthodes — une fuite d’information cross-domaine (Route.match(), Route.ts:298). La passe 2 applique la même règle : les routes d’un autre vhost sont exclues du calcul de Allow (isDomainAllowed, router.ts:270).

Si une autre route du même chemin sert tous les vhosts, le scan continue jusqu’à elle : le 403 n’interrompt pas la recherche, il ne conclut que s’il ne reste aucune candidate.

🔌 HTTP et WebSocket — la même table#

Une action WebSocket est une route ordinaire dont les méthodes déclarées contiennent la pseudo-méthode WEBSOCKET. C’est tout ce qui la distingue.

@route("route-chat", {
  path: "/chat/{room}",
  requirements: { methods: ["WEBSOCKET"], protocol: "chat-v1" },
})
async chat(room: string, message: string | Buffer | null) { /* … */ }

Ce qui change par rapport au HTTP :

Duplex — le même chemin en HTTP et en WS#

Déclarer methods: ["GET", "WEBSOCKET"] rend une action joignable par les deux transports. C’est ce que fait le data plane d’administration pour toutes ses lectures (AdminBroker.mountAll()Router.createRoute(), AdminBroker.ts:125). Deux conséquences :

Le routage par message (invoquer un chemin porté par une frame, sans toucher l’URL de la connexion) passe par le même resolve(), avec un chemin fourni en argument — l’état partagé de la socket n’est jamais muté (Router.resolve(), router.ts:230). Détails côté socket : socket Nodefony.

Vhosts — une route par domaine#

@Domain restreint une méthode (ou tout un contrôleur) à un ou plusieurs noms d’hôte. Les motifs acceptent l’exact ("marseille.fr") et le joker d’un label ("*.cdn.example.com"), compilés une fois au boot en expressions ancrées (Route.compileHost(), Route.ts:468).

@controller("/")
@Domain("marseille.fr") // SOUS @controller : les décorateurs de classe
class MarseilleController extends Controller {
  // s'appliquent de bas en haut
  @Get("/") home() {} // marseille.fr/ → 200 · autre-vhost/ → 403
}

Précédence, du plus fort au plus faible : @route({ host })@Domain sur la méthode › @Domain sur la classe (controller(), routerDecorators.ts:89). Une route sans domaine est servie sur tous les vhosts, et ne coûte rien au matching (hostRegexp absent → aucun test, Route.matchHostname(), Route.ts:605).

⚠️ Attention

@Domain déclare quels vhosts une route sert ; il ne remplace pas la barrière d’entrée. Un Host inconnu du serveur est rejeté en amont (421 Misdirected Request, checkValidDomain(), http-kernel.ts:1697) via la liste trustedHosts de @nodefony/http.

Préfixes — contrôleur, module, data plane#

Trois niveaux de préfixe coexistent, et un seul est à ta main.

  1. Le préfixe de contrôleur@controller("/api/catalog") est concaténé devant le chemin de chaque route de la classe, puis le chemin est normalisé : les // sont réduits et le slash final retiré (Route.setPattern(), Route.ts:551). Un chemin vide (@Get("")) désigne donc le préfixe lui-même.
  2. Le module propriétaire — il n’ajoute aucun préfixe d’URL. @controllers([…]) enregistre la classe au boot et propage le nom du module sur les routes déjà créées, pour l’introspection et les logs (Router.setController(), router.ts:417). Un module tiers et ton app peuvent porter deux contrôleurs homonymes sans collision : la clé du registre est module:Classe (router.ts:164).
  3. Le data plane d’administration — réservé, non négociable : /nodefony/<namespace>/api/<endpoint> (AdminBroker.resolvePath(), AdminBroker.ts:91). Trois segments minimum, pour ne jamais entrer en collision avec les routes de l’application ni avec la SPA de Studio.

💡 Astuce

Les routes de tes modules ne sont pas préfixées par leur nom de module — deux modules peuvent déclarer /api/users. C’est le premier déclaré (ordre du manifeste modules) qui gagne. Préfixe tes contrôleurs applicatifs pour éviter la collision silencieuse.

Nommer une route, la retrouver, l’appeler#

Chaque route porte un nom unique dans le processus : celui que tu donnes à @route, ou l’auto-nom Classe::methode des décorateurs de méthode (routerDecorators.ts:348). Le nom est le handle stable d’une route — il survit à un changement de chemin.

Besoin Comment
Retrouver une route par son nom router.getRoutes("ma-route") → l’objet Route (router.ts:326)
Lister toutes les routes router.getRoutes("") → la table complète (router.ts:387)
Savoir quelles routes couvrent un chemin router.matchRoutes("/api/x") → les résultats de regex (router.ts:376)
Appeler une autre action, en interne this.forward("module:Controller:action") (Controller.ts:445)
Retirer une route router.removeRoutes("ma-route") (router.ts:335)

Il n’existe pas de générateur d’URL inverse côté serveur (pas de path("ma-route", {id}) à la Symfony). Le chemin déclaré est lisible sur l’objet Route (route.path), et la substitution des {var} est faite là où on en a besoin — par exemple par la console Studio, qui remplace chaque variable par sa valeur encodée (buildUrl(), PlaygroundModel.ts:88). Pour un lien interne, écris le chemin ; pour un appel interne, utilise forward().

forward() n’est pas une redirection : il résout module:Controller:action et exécute l’action dans le même contexte de requête, sans repasser par le réseau (Resolver.parsePathernController(), Resolver.ts:185). Une vraie redirection HTTP passe par this.redirect(url, 302) ou @Redirect.

🧰 API publique#

Le routage s’utilise par décorateurs ; l’API impérative sert l’outillage (introspection, tests, modules qui montent des routes dynamiquement). Signatures complètes : .ai/symbols.json.

Symbole Usage réel
Router.createRoute(nom, options) Monter une route sans décorateur (data plane, module dynamique).
Router.setController(classe, module) Rattacher une classe à un module (fait par @controllers).
router.resolve(context) Le cœur : rend un Resolver (resolve === true si trouvé).
router.getRoutes(nom) · removeRoutes() Introspection et démontage.
Route#path · #variables · #pattern Ce que la route déclare, après compilation.
Route#toObject() · #toLogLine() Sérialisation pour l’API admin · ligne de log lisible (Route.ts:525).
Resolver#route · #variables Ce que la requête courante a matché.
Resolver#getMatchedParams() Les variables en nom → valeur (Resolver.ts:170).

🛑 Prudence

La table de routes est un état de processus, pas d’instance : Router.routes est une liste module-level partagée par tout le processus (router.ts:48). removeRoutes() sans argument la vide pour tout le monde — réservé aux bancs de test, qui sauvegardent et restaurent la table autour de chaque cas.

⚡ Performance & mémoire#

Le routage est sur le chemin chaud de chaque requête : tout y est précalculé au boot, rien n’y est alloué par requête.

📜 Normes appliquées#

Sujet Norme Où le code s’y conforme
405 + en-tête Allow agrégé RFC 9110 §15.5.6 passe 2 (collectSupportedMethods(), router.ts:31)
Cible identifiée par l’URI, hôte compris RFC 9110 §7.2 hôte vérifié avant la méthode (Route.match(), Route.ts:298)
403 sur ressource d’un autre vhost RFC 9110 §15.5.4 Route.matchHostname() (Route.ts:605)
404 quand rien ne correspond RFC 9110 §15.5.5 après repli statique (http-kernel.ts:688)
421 sur Host non servi RFC 9110 §15.5.20 checkValidDomain() (http-kernel.ts:1697)
Erreur de sous-protocole WS = 1002 RFC 6455 §7.4 Route.matchRequirements() (Route.ts:649)
Décodage pourcent des segments RFC 3986 §2.1 decode() (Route.ts:79)

📡 Observabilité — Studio#

La table de routes est introspectable en ligne, sans lire le code :

Au boot, avec le debug actif, chaque route est aussi journalisée en une ligne [MÉTHODES] chemin → @module/Controller.action (Route.toLogLine(), Route.ts:402).

⚠️ Pièges (symptôme → cause → correction)#

<!-- prettier-ignore -->

Symptôme Cause (dans le code) Correction
404 sur toutes les routes d’un contrôleur @controller évalué avant les @route/@Get de la classe Placer @controller au-dessus de la classe, décorateurs de méthode dans la classe
404 sur une route pourtant écrite Le fichier du contrôleur n’est jamais importé — les routes naissent à l’import Le déclarer dans @controllers([…]) du module
405 alors que la méthode « est déclarée » method: "GET" dans @route n’est pas filtrant Utiliser requirements: { methods: ["GET"] } ou @Get
405 sur une requête HEAD d’une route @Get HEAD n’est pas déduit de GET : c’est une méthode distincte Déclarer requirements: { methods: ["GET", "HEAD"] }
Une route paramétrée avale un chemin littéral Premier match dans l’ordre de déclaration, aucune spécificité Déclarer le littéral avant le paramétré
/files/* ne répond pas sur /files Le slash final est retiré avant le matching ; le motif exige /files/ Déclarer une seconde route pour le chemin nu
{id} ne capture pas a/b Une variable vaut [^/]+ — un seul segment, par construction Utiliser un wildcard * si le / doit être capturé
500 au lieu d’un non-match sur une contrainte Un requirement par variable non satisfait lève (chaîne brute, Route.ts:286) Préférer la contrainte inline {id}(\d+), qui ne matche pas
403 inattendu sur une route qui « existe » La route est restreinte à un autre vhost (@Domain) Retirer la restriction, ou servir ce vhost
Action WebSocket jamais atteinte Transport WEBSOCKET absent des méthodes déclarées requirements: { methods: ["WEBSOCKET"] }
Une action nommée session/request/method est refusée Le décorateur refuse tout nom déjà porté par Controller — il masquerait l’action Renommer l’action (réservés : tout membre de Controller/Servicesession, get, set, remove, request, response, method…)
Les routes d’un test « fuient » sur le test suivant Router.routes est un état de processus partagé Sauvegarder/restaurer la table autour de chaque cas

🧪 Tests & couverture#

Le routage est le sous-système du framework le plus densément couvert — les chiffres exacts vivent dans la carte de tests de la page (régénérée depuis vitest, jamais figés dans la prose).

Ce qui manque, dit franchement : aucun banc d’attaque dédié au routage (*.attack.test.ts) et aucun test de charge dédié — le coût de la résolution est mesuré indirectement par les bancs HTTP de tests/load/**. La couverture du vhost est portée par tests/integration/domain-routing.test.ts (@nodefony/http), hors périmètre compté ici.

Lancer : npm test (unitaires) et npm run test:integration (serveur requis) dans @nodefony/framework ; couverture via npm run coverage. Pour la charge, voir le skill nodefony-load-test.

🔗 Pour aller plus loin#