Nodefony 10.0.0-beta.5
Framework Node.js fullstack — HTTP & WebSocket, même contexte

Serveurs — HTTP, HTTPS, HTTP/2, WebSocket

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

Ce sont les portes d’entrée du processus : ce qui ouvre les sockets, accepte les connexions et transmet chaque requête — web ou temps réel — au même pipeline. Une application Nodefony ouvre jusqu’à deux ports (5151 en clair, 5152 en TLS) et y adosse quatre serveurs (HTTP, HTTPS/HTTP-2, WS, WSS). Cette page décrit ce qu’ils écoutent, comment on les règle, comment ils démarrent, comment ils s’arrêtent, et pourquoi ils ne doivent jamais tomber. Chaque fait est ancré sur le code.

📍 Documentation › @nodefony/http › Serveurs

🧠 Le modèle mental — deux ports, quatre serveurs, un pipeline#

Un serveur Nodefony n’est pas un « framework qui écoute ». C’est un assemblage de services : chaque serveur est un service injectable qui possède son socket, et tous délèguent au même orchestrateur, HttpKernel. Le WebSocket n’a pas de port à lui : il se greffe sur le serveur HTTP correspondant.

Trois idées à retenir, et tout le reste en découle :

  1. Un serveur = un service DI. Ils sont déclarés dans @services([…]) du module (src/packages/@nodefony/http/index.ts#nodefony) et instanciés par le conteneur, comme n’importe quel service — donc introspectables, testables, remplaçables.
  2. Le WS hérite de son porteur. server-websocket reçoit le http.Server déjà en écoute (Websocket.createServer(), server-websocket.ts#Websocket.createServer) ; server-websocket-secure reçoit le https.Server. Couper HTTPS coupe donc le WSS, sans autre réglage.
  3. Les quatre convergent vers HttpKernel. Les serveurs ne connaissent ni les routes, ni les sessions, ni le firewall : ils branchent onHttpRequest / onWebsocketUpgrade et se taisent.

📖 Lexique#

Terme Sens
Bind / listen Réserver un port auprès du noyau. Opération atomique : elle réussit, ou le noyau répond EADDRINUSE.
EADDRINUSE Code d’erreur système « adresse déjà utilisée » — un autre processus occupe le port.
ALPN Application-Layer Protocol Negotiation : le client et le serveur choisissent h2 ou http/1.1 pendant le TLS.
h2 / HTTP-2 Version multiplexée de HTTP : plusieurs flux (streams) dans une seule connexion TCP.
allowHTTP1 Option Node qui autorise un serveur HTTP/2 à servir aussi les clients HTTP/1.1 restés en arrière.
Upgrade Requête HTTP GET + en-tête Upgrade: websocket qui bascule la connexion en WebSocket (réponse 101).
Frame Unité de message WebSocket (RFC 6455).
Zombie (half-open) Socket dont le pair a disparu sans fermer proprement : TCP paraît ouvert, personne n’est en face.
Ping / Pong Frames de contrôle WebSocket servant de battement de cœur (keep-alive).
Drain Vidange : laisser finir les requêtes en cours avant de fermer, plutôt que couper les sockets.
Liveness / Readiness Sondes cloud : « le processus est-il vivant ? » / « peut-il recevoir du trafic ? ».
SIGTERM Signal d’arrêt poli envoyé par l’orchestrateur (k8s, docker stop) avant le SIGKILL.
SAN Subject Alternative Name : la liste des noms/IP qu’un certificat couvre réellement (RFC 9525).
mkcert Outil de développement qui installe une autorité de certification locale de confiance sur la machine.
Reverse-proxy / edge Le nginx / HAProxy / ingress placé devant l’application, souvent porteur du TLS.
X-Forwarded-* En-têtes ajoutés par un proxy pour dire l’IP, l’hôte et le protocole d’origine du client.
CSWSH Cross-Site WebSocket Hijacking : une page tierce ouvre un WebSocket authentifié par le cookie de la victime.
SO_REUSEPORT Option système permettant à N processus d’écouter le même port, le noyau répartissant les connexions.

Qu’est-ce qu’un serveur, ici ?#

Imagine un immeuble avec deux entrées : une porte de service (HTTP en clair, 5151) et une porte principale sécurisée (TLS, 5152). Derrière chaque porte, un hall unique : peu importe par où l’on entre, on aboutit au même accueil, qui oriente vers le bon bureau. Le WebSocket, lui, n’est pas une troisième porte : c’est un interphone installé sur les portes existantes — il emprunte la même serrure, la même adresse, la même politique de sécurité.

Concrètement, un serveur Nodefony a quatre responsabilités, et rien d’autre :

  1. Ouvrir — réserver un port, ou en négocier un autre si celui-ci est pris.
  2. Régler le transport — délais, taille des en-têtes, TLS, compression WebSocket, limites HTTP/2.
  3. Transmettre — passer chaque requête / connexion au HttpKernel, sans jamais l’interpréter.
  4. Fermer proprement — vider les requêtes en cours, prévenir les clients WebSocket, libérer le port.

Tout ce qui suit (routing, session, firewall, contrôleur) appartient au pipeline, décrit dans pipeline-requete.

La vision Nodefony#

Le différenciateur du framework — HTTP et WebSocket dans le même contexte de contrôleur — se joue ici, au niveau du transport. Trois choix structurent l’implémentation.

Node natif, rien d’autre. node:http, node:https, node:http2 et la bibliothèque ws. Pas d’abstraction de serveur maison, pas de runtime alternatif. Ce que Node sait faire, Nodefony le laisse faire — il n’ajoute que ce que Node ne fournit pas : la politique de port, le drain, le keep-alive WS, les probes.

Un port TLS qui parle deux protocoles. Quand servers.https.protocol vaut "2.0" (le défaut), Nodefony crée un serveur HTTP/2 sécurisé avec allowHTTP1: true (ServerHttps.createServerH2(), server-https.ts#ServerHttps.createServerH2) : les clients modernes négocient h2 par ALPN, les autres restent en HTTP/1.1 sur le même port. Le protocole effectif est relu socket par socket pour taguer le contexte (server-https.ts#ServerHttps.createServerH2).

Le WebSocket n’est jamais un citoyen de seconde zone. Il est adossé au serveur HTTP porteur (server-websocket.ts#Websocket.createServer), passe par le même rate-limit d’IP que les requêtes HTTP — un upgrade est une requête HTTP (HttpKernel.onWebsocketUpgrade(), http-kernel.ts#HttpKernel.onWebsocketUpgrade) —, hérite de la même session et du même firewall, et se ferme avec le même soin qu’une réponse HTTP.

ℹ️ Note

Le serveur HTTP/3 (QUIC) est réservé, pas implémenté : la clé http3 existe dans le schéma, marquée reserved (config.ts#httpConfigSchema). Elle ne fait rien aujourd’hui.

🚀 Démarrage rapide#

Dans une application générée par nodefony create app, les serveurs sont déjà là : le manifeste charge @nodefony/http, et le kernel ouvre 5151 et 5152 au boot. On n’écrit que ses écarts.

1. La topologie — quels serveurs, sur quels ports#

La topologie appartient à l’application (bloc servers), pas au module : c’est une propriété du déploiement.

// nodefony.config.ts — la topologie réseau de l'app
export default defineConfig((ctx) => ({
  // Un conteneur doit écouter TOUTES les interfaces : un bind 127.0.0.1 n'est
  // jamais atteint par le port mapping Docker/k8s.
  domain: ctx.isProd ? "0.0.0.0" : "127.0.0.1",
  servers: {
    http: { port: 5151 },
    // protocol "2.0" = HTTP/2 (h2) avec repli HTTP/1.1 sur le MÊME port.
    https: { port: 5152, protocol: "2.0" },
    // Port occupé : en dev on glisse au suivant (annoncé) ; en prod on échoue.
    portPolicy: ctx.isProd ? "strict" : "auto",
  },
  modules: ["@nodefony/http", "@nodefony/framework"],
}));

2. Le réglage — le comportement de chaque serveur#

Le réglage appartient au module : il vit dans nodefony/config/http.ts, que le manifeste passe à use("@nodefony/http", …). Toutes les clés ci-dessous sont facultatives : ce sont les défauts du schéma Zod, écrits ici pour les montrer.

// nodefony/config/http.ts — réglage transport, le fragment que
// `nodefony.config.ts` passe à `use("@nodefony/http", httpConfig())`
import type { IHttpConfigInput } from "@nodefony/http";

export const httpConfig = () =>
  ({
    // Ne pas exposer l'identité du serveur (anti-empreinte, recommandé en prod).
    headerServer: null,
    // Barrière Host : le domaine canonique est toujours accepté, + ceux-ci.
    trustedHosts: ["app.example.com"],
    // Un seul reverse-proxy en amont ? alors et alors seulement, lui faire confiance.
    trustProxy: false,
    http: {
      requestTimeout: 30_000, // anti slow-loris (réception complète)
      keepAliveTimeout: 5_000, // réutilisation de la socket TCP
      shutdownTimeout: 5_000, // drain avant destruction forcée
    },
    http2: {
      maxConcurrentStreams: 100, // défense CVE-2023-44487 (Rapid Reset)
      maxSessionMemory: 10, // Mo par session h2
    },
    websocket: {
      maxPayload: 1024 * 1024, // 1 MiB → au-delà, close 1009
      keepaliveInterval: 20_000, // ping toutes les 20 s
      keepaliveGracePeriod: 10_000, // pong attendu sous 10 s, sinon zombie
    },
    // Probes cloud-native — actives par défaut.
    health: {
      enabled: true,
      livenessPath: "/livez",
      readinessPath: "/readyz",
    },
    certificates: { strategy: "auto" }, // mkcert en dev, sinon auto-signé
  }) satisfies IHttpConfigInput;

3. Le contrôleur qui répond sur les deux transports#

Rien de spécifique aux serveurs : c’est justement le propos. Le même contrôleur sert le web et le temps réel, et lit le transport dans son contexte.

// nodefony/controller/PingController.ts — complet, compile tel quel
import { Controller, controller, Get, route } from "@nodefony/framework";
import type { Context } from "@nodefony/http";

@controller("/ping")
class PingController extends Controller {
  constructor(context: Context) {
    super("PingController", context);
  }

  // HTTP : répond sur http://…:5151/ping ET https://…:5152/ping (h2 inclus).
  @Get("/")
  async ping() {
    return this.renderJson({
      type: this.context?.type, // "http" | "websocket" — la famille
      scheme: this.context?.scheme, // "http" | "https" — vu par le client
      alpnProtocol: this.context?.alpnProtocol, // "http/1.1" | "h2" | "h2c"
    });
  }

  // WebSocket : ws://…:5151/ping/live et wss://…:5152/ping/live.
  // `message == null` = handshake (la connexion vient de s'ouvrir).
  @route("ping-live", {
    path: "/live",
    requirements: { methods: ["WEBSOCKET"], protocol: "" },
  })
  async live(message: string | Buffer | null) {
    if (message == null) {
      return this.renderJson({ handshake: true });
    }
    return this.render(message.toString());
  }
}

export default PingController;

4. Ce qu’on observe au boot#

Le kernel démarre les serveurs à la phase onReady (Kernel.ts#Kernel.onReady). En développement, le bilan de démarrage (renderStartupHuman, startupScreen.ts) dit d’abord où aller, puis ce qui est à regarder, puis les adresses d’écoute :

  ✓  Prêt en 6,1 s — Nodefony 10.0.0 · development

  OUVRIR
    ➜  Application   https://localhost:5152/
    ➜  Studio        https://localhost:5152/nodefony

  ÉTAT
    Mode        development · rechargement auto
    Écoute      HTTP + WS 127.0.0.1:5151 · HTTP/2 + WSS 127.0.0.1:5152

L’adresse à ouvrir n’est pas l’adresse de liaison : un serveur lié à 127.0.0.1 ou à 0.0.0.0 s’ouvre sur localhost (openableHost, bootReport.ts#openableHost) — les passkeys refusent une IP comme domaine, et un cookie posé sous un nom ne part pas sous l’autre. Hors d’un terminal, le même bilan sort ligne à ligne (open.app: https://localhost:5152/, listen.https: 127.0.0.1:5152) ; pour une machine, nodefony status --json le rend en entier.

Hors écran animé (production, CI, --debug, sortie non-terminal), les bannières par serveur sortent aussi (ServerHttp.showBanner(), server-http.ts#ServerHttp.showBanner, appelées par le kernel — Kernel.ts#Kernel.onReady) :

Server Listen on http://127.0.0.1:5151 Family: IPv4 Protocol : 1.1

5. Vérifier depuis le terminal#

# HTTP/1.1 en clair
curl -s http://127.0.0.1:5151/ping
# {"type":"http","scheme":"http","alpnProtocol":"http/1.1"}

# HTTP/2 sur le port TLS (-k : le certificat de dev n'est pas dans le trust store)
curl -sk --http2 https://127.0.0.1:5152/ping
# {"type":"http","scheme":"https","alpnProtocol":"h2"}

# Le même port TLS sert encore HTTP/1.1 (allowHTTP1) — repli négocié par ALPN
curl -sk --http1.1 https://127.0.0.1:5152/ping
# {"type":"http","scheme":"https","alpnProtocol":"http/1.1"}

# Probes cloud-native, servies par les DEUX serveurs
curl -si http://127.0.0.1:5151/livez  | head -1   # HTTP/1.1 200 OK
curl -sik https://127.0.0.1:5152/readyz | head -1  # HTTP/2 200

Cas cloud-native — un seul port, TLS terminé à l’ingress#

C’est le déploiement nominal en Kubernetes : l’ingress porte le certificat, le pod sert en clair. https: false désactive HTTPS et le WSS qui en hérite, et supprime au passage toute génération de certificat au boot.

// nodefony.config.ts — pod k8s derrière un ingress qui termine le TLS
import { defineConfig, use } from "nodefony";
import { httpConfig } from "./nodefony/config/http";

export default defineConfig((ctx) => ({
  domain: ctx.isProd ? "0.0.0.0" : "127.0.0.1",
  servers: {
    https: false, // un seul port exposé : 5151
  },
  modules: [use("@nodefony/http", httpConfig(ctx)), "@nodefony/framework"],
}));
// nodefony/config/http.ts — le fragment que le manifeste ci-dessus appelle
import type { ConfigContext } from "nodefony";
import type { IHttpConfigInput } from "@nodefony/http";
import type { env } from "../../env";

export const httpConfig = (ctx: ConfigContext<typeof env>) =>
  ({
    // L'ingress est l'unique point d'entrée → ses X-Forwarded-* sont fiables.
    trustProxy: ctx.isProd ? "uniquelocal" : false,
    // L'ingress filtre déjà le Host ; sinon lister les vhosts servis.
    trustedHosts: ["app.example.com"],
  }) satisfies IHttpConfigInput;

🔌 Les serveurs — quatre par défaut, h2c en option#

Choisir en cinq secondes :

Service Écoute Créé si… Rôle
server-http http:// — 5151 servers.http !== false HTTP/1.1 en clair.
server-https https:// — 5152 servers.https !== false TLS : HTTP/2 (défaut) ou HTTP/1.1.
server-websocket ws:// — 5151 server-http actif WebSocket adossé au serveur HTTP.
server-websocket-secure wss:// — 5152 server-https actif WebSocket adossé au serveur TLS.
server-h2c h2c — 5153 servers.h2c.enabled HTTP/2 en clair, pour un proxy inverse (option).
server-static (aucun port propre) toujours enregistré Fichiers statiques, en repli après le routing.

L’assemblage est fait par HttpKernel.initServers() (http-kernel.ts#HttpKernel.initServers) : chaque serveur est consulté sur son drapeau active, un serveur désactivé est sauté, pas créé (http-kernel.ts#HttpKernel.initServers). Les serveurs WebSocket ne sont montés que si leur porteur l’a été. server-h2c n’a pas de WebSocket adossé (voir sa section).

server-httpHTTP/1.1 en clair#

Le plus simple, et celui qui porte le trafic en cloud-native. ServerHttp.createServer() (server-http.ts#ServerHttp.createServer) crée le http.Server, applique les réglages de transport, branche onHttpRequest, puis écoute selon la politique de port.

  • Actif si servers.http n’est pas false — la décision est prise dès le constructeur (server-http.ts#ServerHttp.constructor), et le port est lu de la config d’app (ServerHttp.setPort(), server-http.ts#ServerHttp.setPort).
  • Réglages appliqués : requestTimeout (anti slow-loris), maxHeadersCount, timeout de socket (qui émet un événement onTimeout), keepAliveTimeout.
  • Erreurs de protocole : l’événement clientError est traité explicitement (server-http.ts#ServerHttp.createServer) — voir la section Résilience, c’est un piège Node à part entière.
server-httpsTLS, et HTTP/2 par défaut#

Deux branches dans un seul service, choisies sur servers.https.protocol (server-https.ts#ServerHttps.protocol) :

  • "2.0" (défaut) → http2.createSecureServer avec allowHTTP1: true (ServerHttps.createServerH2(), server-https.ts#ServerHttps.createServerH2). Les bornes anti-DoS HTTP/2 ne sont posées que si elles sont configurées, pour ne pas écraser les défauts de Node (maxSessionMemory, server-https.ts#ServerHttps.createServerH2). Les erreurs de session et de flux sont journalisées sans tuer le serveur (sessionError, server-https.ts#ServerHttps.listenWithPolicy).
  • "1.1" → https.createServer classique.

Le certificat vient du service certificates, lu au moment de la création via serviceCerticats (server-https.ts#ServerHttps.createServer) — voir la section dédiée.

❗ Important

Sur la branche HTTP/2, le protocole effectif se constate par requête (transport.ts#alpnProtocolOf) : un client h2 produit un contexte alpnProtocol: "h2", un client HTTP/1.1 sur le même port alpnProtocol: "http/1.1" — les deux avec type: "http" et serverType: "https". C’est ce qui rend le port TLS universel.

server-h2cHTTP/2 en clair, pour un proxy inverse#

Un proxy inverse qui termine le TLS (Envoy et Istio, HAProxy, Traefik) peut parler HTTP/2 en clair à ses amonts : c’est h2c, en connaissance préalable (RFC 9113 §3.3) — le client envoie directement la préface HTTP/2, sans négociation. ServerH2c.createServer() (server-h2c.ts#ServerH2c.createServer) le sert sur un port dédié, désactivé par défaut :

// nodefony.config.ts
servers: { h2c: { enabled: true } }, // port 5153 par défaut — ou NF__APP__SERVERS__H2C__ENABLED=true

Il emprunte le chemin HTTP/2 du port TLS — même pipeline, mêmes limites anti-DoS (http2Limits.ts#http2ServerLimits), même arrêt par GOAWAY — sans TLS : le contexte porte alpnProtocol: "h2c", serverType: "http", et son scheme vient du transport ou d’un proxy de confiance (trustProxy), jamais du pseudo-en-tête :scheme, que le client écrit lui-même.

Ce qu’il ne fait pas, délibérément :

  • HTTP/1.1 : un client HTTP/1.1 échoue sur la préface et la session est fermée. Les sondes de santé et les clients HTTP/1.1 restent sur le port HTTP — d’où l’avertissement au boot si servers.http vaut false alors que h2c est actif.
  • WebSocket : WebSocket sur HTTP/2 passe par le CONNECT étendu (RFC 8441), que ce serveur ne sert pas. Le proxy route Upgrade: websocket vers le port HTTP.
  • La bascule Upgrade: h2c depuis HTTP/1.1 : retirée par RFC 9113 §3.1 (contrebande h2c). Le port HTTP la refuse — 404 sur un chemin sans route WebSocket, 400 sinon — et ne répond jamais 101.
  • Partager un port : servers.h2c.port égal à servers.http.port ou servers.https.port est refusé au boot (portBinder.ts#h2cPortConflict).

Faut-il l’activer ? — h2c n’est pas « plus rapide » par principe#

Le gain classique de HTTP/2 est le multiplexage : plusieurs requêtes sur une connexion, sans blocage en tête de file. Il sert surtout un navigateur, limité à quelques connexions sur un réseau lent. Entre un proxy et un pod, sur un réseau local, le proxy garde déjà un pool de connexions HTTP/1.1 ouvertes qui masque ce blocage ; et côté Node, HTTP/2 ajoute du travail par requête (trames, compression HPACK des en-têtes, contrôle de flux) que le parseur HTTP/1.1 n’a pas. Sur de petites requêtes, un serveur Node en HTTP/2 sert souvent moins de requêtes par seconde qu’en HTTP/1.1. Le choix se fait sur la forme du trafic :

Forme du trafic h2c ? Pourquoi
gRPC requis gRPC n’existe qu’en HTTP/2.
Beaucoup de flux longs (SSE) par pod oui En HTTP/1.1, chaque flux occupe une connexion du proxy ; en h2c, ils partagent quelques connexions.
En-têtes lourds et répétés (cookies, jetons) probable HPACK les compresse d’une requête à l’autre.
REST classique, petites réponses mesurer Souvent neutre, parfois perdant.
WebSocket non Reste sur le port HTTP, en HTTP/1.1.
Plusieurs workers (nodefony cluster -w N) attention Le cluster Node répartit des connexions, pas des requêtes : avec les rares connexions h2c d’un proxy, un seul worker peut tout recevoir. Un process par pod n’a pas ce biais.

Ce que coûte h2c, mesuré — production en un seul process, même route triviale, même instrument (h2load), médiane de paires alternées ; débit de h2c rapporté à HTTP/1.1 sur la même machine, et le même écart pour Node seul, sans framework :

Forme (h2load) Node seul Nodefony
64 connexions × 1 flux — forme égale à HTTP/1.1 −60 % −48 %
4 connexions × 16 flux — la forme d’un proxy −14 % −18 %

L’écart est d’abord celui de HTTP/2 dans Node : à forme égale, Node seul perd davantage que Nodefony, dont le travail par requête dilue le coût du protocole. En forme proxy, le multiplexage regagne presque tout, et Nodefony reste dans la même zone. Sur une route triviale, h2c ne fait donc pas gagner de débit ; il se choisit pour ce que le tableau ci-dessus nomme — gRPC, flux longs, en-têtes lourds.

Mesurer avant de décider : même pod, même route, en ne changeant que le protocole amont du proxy. wrk ne parle que HTTP/1.1 ; h2load (nghttp2) parle les deux, ce qui garde le même instrument des deux côtés. Comparer le débit, le p99 et le CPU par requête — côté pod et côté proxy, qui paie aussi HPACK — en paires alternées, sur la médiane de plusieurs runs.

# vérifier le port : la préface HTTP/2 directe, sans TLS
curl --http2-prior-knowledge http://127.0.0.1:5153/readyz
# même route, même instrument, deux protocoles
h2load --h1 -n 100000 -c 64 http://127.0.0.1:5151/readyz   # HTTP/1.1
h2load      -n 100000 -c 64 http://127.0.0.1:5153/readyz   # h2c
server-websocketWebSocket en clair#

Il ne crée aucun socket : il reçoit le http.Server déjà en écoute et s’y greffe (server-websocket.ts#Websocket.server), puis relit address() pour connaître son port — il suit donc automatiquement un éventuel décalage de port.

  • Options transmises à ws telles quelles (compression, validation UTF-8, maxPayload…), avec deux réglages forcés par Nodefony : server et clientTracking: true, requis par broadcast() et par le battement de cœur (server-websocket.ts#Websocket.createServer).
  • Keep-alive armé à la création (startHeartbeat(), server-websocket.ts#Websocket.createServer) et par connexion (trackPong(), http-kernel.ts#HttpKernel.handleWebsocket, juste après l’acceptation du handshake).
  • Arrêt : il s’inscrit en tête des écouteurs de terminaison (prependOnceListener, server-websocket.ts#Websocket.createServer) — l’ordre compte, voir la section Arrêt gracieux.
server-websocket-secureWebSocket sur TLS#

Même code, une différence qui a son importance : il lit sa propre section de configuration, websocketSecure, et non websocket (server-websocket-secure.ts#WebsocketSecure.constructor). Régler websocket.maxPayload ne change donc rien au WSS ; les deux sections ont la même forme et les mêmes défauts.

server-staticles fichiers, en repli#

Pas de port : c’est un service greffé sur le pipeline. Depuis la bascule « router d’abord », il n’est consulté qu’après un échec de routage — une requête qui matche une route ne touche plus le disque. Il se désactive par statics.enabled: false (cas cloud-native : nginx ou un CDN sert les fichiers) sans pour autant supprimer les montages programmatiques.

⚙️ Configuration — deux niveaux, jamais mélangés#

C’est la distinction la plus utile de cette page, et celle qu’on rate le plus souvent.

Question Où ça se règle Source
Quels serveurs, sur quels ports ? servers (config d’app) serversSchema (src/nodefony/src/config/schema.ts#serversSchema)
Comment ces serveurs se comportent ? use("@nodefony/http", { … }) httpConfigSchema (config.ts#httpConfigSchema)

Autrement dit : la topologie est une propriété du déploiement (elle change entre le poste du dev, la CI et le cluster) ; le réglage est une propriété de l’application.

Niveau 1 — la topologie (servers)#

Option Type Défaut Effet
servers.http { port } | false { port: 5151 } Serveur en clair. false = TLS-only (le WS tombe avec).
servers.https { port, protocol } | false { port: 5152, protocol: "2.0" } Serveur TLS. false = nominal cloud-native (le WSS tombe).
servers.https.protocol "1.1" | "2.0" "2.0" HTTP/2 (h2) avec repli HTTP/1.1 par ALPN, ou HTTP/1.1 seul.
servers.h2c { enabled, port } { enabled: false, port: 5153 } HTTP/2 en clair pour un proxy inverse — section server-h2c.
servers.statics bool true Monte le service de fichiers statiques.
servers.portPolicy "auto" | "strict" auto en dev, strict en prod et test Que faire si le port est occupé (section suivante).
servers.portRetryAttempts int ≥ 0 20 En auto, nombre de ports essayés après le port désiré.

Défauts matérialisés dans defaultAppConfig (src/nodefony/src/config/defaults.ts#defaultAppConfig).

Niveau 2 — le transport HTTP / HTTPS#

Table dérivée de httpServerSchema (config.ts#httpServerSchema) ; la section https reprend les mêmes clés et en ajoute une (httpsServerSchema, config.ts#httpsServerSchema).

Option Type Défaut Effet
maxHeadersCount int 2000 Nombre maximum d’en-têtes par requête — anti header flooding.
keepAliveTimeout ms 5000 Délai de réutilisation de la socket TCP entre deux requêtes.
timeout ms 30000 Timeout global de socket, aligné sur responseTimeout. 0 = désactivé.
requestTimeout ms 30000 Délai de réception de la requête complète — anti slow-loris.
responseTimeout ms 30000 Délai d’envoi de la réponse complète (couche pipeline).
shutdownTimeout ms 5000 Drain au shutdown avant destruction forcée. Garder < grâce orchestrateur.
headers objet null En-têtes ajoutés à toutes les réponses.
rejectUnauthorized (https) bool false Rejette les certificats invalides. false en dev (auto-signés), true en prod.

Ces deux sections sont permissives (z.looseObject) : toute option supplémentaire de http.Server / net.Server / TLS (insecureHTTPParser, ciphers, minVersion…) est transmise telle quelle à Node. C’est délibéré — un schéma strict effacerait silencieusement une option légitime.

Niveau 2 — HTTP/2#

Depuis http2Schema (config.ts#http2Schema), appliqué seulement si défini (maxSessionMemory, server-https.ts#ServerHttps.createServerH2).

Option Type Défaut Effet
maxConcurrentStreams int 100 Flux concurrents par session h2 — défense CVE-2023-44487 (Rapid Reset).
maxSessionMemory Mo 10 Mémoire maximale par session h2 — borne l’amplification mémoire.

Niveau 2 — WebSocket (websocket et websocketSecure)#

Depuis websocketSchema (config.ts#websocketSchema). Les deux sections partagent la forme et les défauts ; le WSS lit websocketSecure (config.ts#httpConfigSchema).

Option Type Défaut Effet
keepaliveInterval ms 20000 Intervalle des pings — détecte les connexions zombies.
keepaliveGracePeriod ms 10000 Délai de grâce après un ping sans réponse avant fermeture.
closeTimeout ms 5000 Délai de fermeture propre avant destruction de la socket.
maxPayload octets 1 MiB Taille max d’un message entrant → au-delà, close 1009 (config.ts#websocketSchema).
allowedOrigins bool | str | list false Allowlist d’Origin au handshake — anti-CSWSH (config.ts#websocketSchema).
perMessageDeflate bool | objet false Compression RFC 7692. Désactivée par défaut : coût CPU/RAM + risque de zip bomb.
skipUTF8Validation bool false Désactive la validation UTF-8 des frames texte (RFC 6455 §8.1). À laisser false.
autoPong bool true Répond automatiquement aux pings entrants (RFC 6455 §5.5.2-3). À laisser true.
allowSynchronousEvents bool true Émet plusieurs frames d’un même chunk réseau de façon synchrone (débit vs équité).
maxBackpressure octets 4 MiB Seuil du tampon d’envoi par connexion (client lent à recevoir) — anti-OOM.
backpressurePolicy drop | close drop Au-delà du seuil : sauter la frame, ou fermer le client (close 1013).

⚠️ Attention

keepalive*, closeTimeout, maxBackpressure et backpressurePolicy sont des réglages Nodefony, pas des options de ws : la bibliothèque les ignore, c’est le framework qui les implémente. À l’inverse, server, noServer, clientTracking, host, port et backlog sont gérés par Nodefony et non exposés — les forcer n’aurait pas d’effet.

⚙️ Ports occupés — la politique de repli#

La situation#

Tu développes sur le framework et, dans un autre terminal, tu lances l’app d’un client. Les deux veulent 5151. Historiquement, la seconde mourait sur EADDRINUSE. En développement, ce n’est pas une panne, c’est une nuisance.

La règle#

resolvePortPolicy() (cœur, src/nodefony/src/service/dev/devProcess.ts#resolvePortPolicy — la même règle sert nodefony doctor) tranche selon l’environnement, et la valeur explicite gagne toujours :

Environnement Défaut Pourquoi
development auto Un port pris est une gêne. L’app démarre sur le port suivant, et le dit.
production strict Le port est un contrat (service k8s, ingress, sonde). Glisser en silence = pod « sain » injoignable.
test strict Un port occupé signale un serveur resté debout ; le banc doit s’arrêter, pas taper sur le serveur du voisin.

Le comportement observable#

WARNING  Port 5151 déjà occupé → HTTP écoute sur 5153.
         Figer le port : servers.http.port ; échouer au lieu de glisser :
         servers.portPolicy = "strict".

Le décalage est toujours annoncé (server-http.ts#ServerHttp.createServer) : jamais de dégradation silencieuse. Trois détails d’implémentation valent d’être connus, parce qu’ils expliquent des comportements surprenants.

  1. Le repli se décide au listen(), qui est atomique. Une seule sonde le précède, sur le port désiré : elle détecte un serveur déjà présent sur la boucle locale, que le noyau accorderait quand même à un 0.0.0.0 (detectPortConflict). Elle est sautée dans un worker de cluster (cluster.isWorker) : le port y est partagé par le maître, et le voisin qui répond est un frère, pas un intrus. La course résiduelle retombe sur EADDRINUSE (bindWithFallback(), portBinder.ts#bindWithFallback).
  2. Le port de l’autre serveur est réservé. Si HTTP est chassé de 5151, incrémenter naïvement le ferait voler 5152 à HTTPS, qui se décalerait à son tour. Les ports convoités par les autres serveurs sont sautés d’emblée (buildBindPlan(), portBinder.ts#buildBindPlan, réservation portBinder.ts#buildBindPlan).
  3. Le gestionnaire d’erreur durable est posé APRÈS le bind. Attaché avant, il verrait passer les EADDRINUSE de repli et terminerait le kernel en croyant à une panne (ServerHttp.attachErrorHandler(), server-http.ts#ServerHttp.attachErrorHandler).

Quand le bind échoue pour de bon — strict, ou tous les replis épuisés, ou une erreur qui n’est pas un conflit de port — c’est fatal : message explicite puis terminaison du processus (ServerHttp.reportBindError(), server-http.ts#ServerHttp.reportBindError). Un serveur qui n’écoute pas ne doit jamais laisser un processus se croire démarré.

Le corollaire : les ports effectifs sont publiés#

Si le port peut glisser, alors « le serveur écoute sur 5151 » n’est plus une vérité mais une convention — et nodefony status, nodefony stop ou l’attente de disponibilité sonderaient un port que personne n’écoute. HttpKernel.publishRuntimePorts() (http-kernel.ts#HttpKernel.publishRuntimePorts) écrit donc la topologie réelle (pid, ports obtenus, ports désirés) dans un fichier d’état, dans tous les environnements : une application qui déclare son port (NF_PORT, NF_PORT_HTTPS ou servers.http.port) sort aussi de la convention, même en strict. L’écriture est au mieux-effort — une image en lecture seule ne fait jamais tomber un serveur qui, lui, écoute très bien.

🔐 Certificats TLS#

La doctrine#

Générer un certificat est un confort de développement, pas une fonction de production. Nodefony n’est pas une autorité de certification : en production, on fournit un vrai certificat (Let’s Encrypt, ingress k8s, reverse-proxy). Le service crie un avertissement si ce n’est pas le cas (Certificate.resolveStrategy(), certificates.ts#Certificate.resolveStrategy).

Les quatre stratégies#

Réglées par certificates.strategy (certificatesSchema, config.ts#certificatesSchema), résolues par Certificate.resolveStrategy() (certificates.ts#Certificate.resolveStrategy).

Stratégie Quand l’utiliser Ce qui se passe
auto (défaut) On ne veut pas décider key+cert fournis → explicit ; sinon mkcert en dev ; sinon auto-signé.
explicit Production Charge key/cert/ca depuis la config. Erreur au boot si absents.
mkcert Développement avec HTTPS sans avertissement CA locale de confiance → HMR cross-origin et WSS sans erreur navigateur.
selfsigned Secours, CI, machine sans mkcert Auto-signé (node:crypto), non trusté.
// nodefony/config/http.ts — production : certificat fourni, jamais généré
import type { IHttpConfigInput } from "@nodefony/http";

export const httpConfig = () =>
  ({
    certificates: {
      strategy: "explicit",
      key: "/etc/tls/privkey.pem",
      cert: "/etc/tls/fullchain.pem",
      ca: "/etc/tls/chain.pem",
    },
    https: { rejectUnauthorized: true },
  }) satisfies IHttpConfigInput;

💡 Astuce

Pour un HTTPS de développement sans avertissement navigateur (indispensable au HMR cross-origin et au WSS) : brew install mkcert nss && mkcert -install. Nodefony le détecte tout seul, sinon il l’annonce et retombe sur l’auto-signé (certificates.ts#Certificate.resolveStrategy).

Ce que le chemin explicit évite#

Aucune génération : le certificat fourni est lu tel quel (certificates.ts#trustOutboundAuthorities). La génération de l’auto-signé, elle, n’emploie que node:crypto (x509.ts) — aucune dépendance cryptographique tierce n’est installée avec @nodefony/http.

Autorités sortantes — extraCaFiles#

Les certificats ci-dessus sont ceux que l’application présente. L’inverse compte autant : quand l’application appelle un autre service en https (Keycloak, une API partenaire, l’amont du proxy inverse), elle n’accepte que les certificats signés par une autorité qu’elle connaît. Une autorité publique ne demande rien, et celle de développement de Nodefony est reconnue d’office ; une autorité d’entreprise se déclare :

use("@nodefony/http", {
  certificates: {
    // Fichiers PEM, absolus ou relatifs à la racine de l'application.
    // Un fichier peut porter une chaîne entière (racine + intermédiaires).
    extraCaFiles: ["certs/autorite-entreprise.pem"],
  },
});

Le gabarit d’application la lit dans NF_TLS_CA_FILE. Les autorités sont ajoutées à celles du processus à l’enregistrement du module, avant tout service — donc aussi pour une commande en ligne (Http.onKernelRegister(), index.ts#Http.onKernelRegister ; trustCaFile(), certificates.ts#trustCaFile). Un fichier introuvable ou sans certificat interrompt le démarrage en nommant le chemin (code 78), plutôt qu’un fetch failed loin de sa cause — et, quand le chemin vient de NF_TLS_CA_FILE, le refus le dit. Une seule exception : l’autorité de développement (nodefony/config/certificates/ca/…) déclarée avant d’avoir été engendrée est ignorée en développement, une ligne du journal le dit ; elle n’a encore rien signé, et le serveur de développement lui fait confiance dès qu’il la fabrique. Rien n’est fabriqué à l’enregistrement : une commande console n’écrit aucun certificat. C’est l’équivalent de NODE_EXTRA_CA_CERTS, porté par la configuration : rien à poser au lancement, et la même écriture sous Windows.

Ce que chaque décor donne — le message du refus porte la même consigne (caFileRemedy(), certificates.ts), et chaque ligne est éprouvée par tests/unit/certificates.test.ts :

Décor Résultat
développement, autorité de dev pas encore engendrée démarre ; une ligne INFO dit qu’elle naîtra au premier npx nodefony development
développement ou production, autorité présente démarre ; l’autorité est reconnue pour les appels sortants
production, autorité de dev absente refus 78 : l’engendrer une fois (npx nodefony http:certificates), ou retirer la déclaration
autorité d’entreprise absente (tout décor) refus 78 : poser le fichier au chemin déclaré, ou retirer la déclaration
droit de lecture refusé refus 78 : donner le droit au compte qui lance l’application (Kubernetes : ConfigMap/Secret lisible)
un dossier déclaré au lieu du fichier refus 78 : désigner le fichier PEM qu’il contient
une clé privée, ou un certificat DER refus 78 : fournir le CERTIFICAT en PEM (openssl x509 -inform der -in a.der -out a.pem)

Quand le chemin vient de NF_TLS_CA_FILE, le refus le dit : c’est la ligne du .env à corriger.

Conformité de l’auto-signé#

Un certificat de développement bâclé fait perdre des heures (« not yet valid », « common name invalid »). Celui de Nodefony respecte les règles qui comptent :

Exigence Norme Mise en œuvre
Signature SHA-256, jamais SHA-1 RFC 5280, CA/B Forum selfSigned.hash par défaut sha256 (config.ts#selfSignedSchema)
Numéro de série aléatoire 128 bits RFC 5280 §4.1.2.2 Certificate.generateSerialHex() (certificates.ts#Certificate.generateSerialHex)
Le SAN fait foi, pas le CN RFC 9525 SAN dérivé du kernel si non fourni (config.ts#selfSignedSchema)
notBefore reculé (décalage d’horloge) pratique selfSigned.backdateMinutes, défaut 5 (config.ts#selfSignedSchema)
Clé privée non lisible par tous hygiène privateKeyMode 0600 (config.ts#certificatesSchema)

Régénération automatique#

Un certificat présent sur disque n’est pas forcément adéquat. Certificate.isCertAdequate() (certificates.ts#Certificate.isCertAdequate) le régénère s’il est expiré, s’il est signé en SHA-1, ou si son SAN ne couvre plus les noms requis — le dernier cas est celui qui sauve : changer le domaine d’écoute sans ce contrôle laisserait un certificat obsolète en place indéfiniment.

🛡️ Derrière un reverse-proxy#

Dès qu’un proxy est devant l’application, trois questions se posent — et trois réglages y répondent.

« Quelle est la vraie IP du client ? » → trustProxy#

Sans barrière, n’importe quel client peut envoyer X-Forwarded-For: 1.2.3.4 et usurper son IP : contournement de rate-limit, journaux d’audit falsifiés. Le défaut est donc false — ces en-têtes sont ignorés (config.ts#healthSchema), et l’IP retenue est celle de la socket réelle, non falsifiable.

Valeur Sens
false (défaut) Ne jamais faire confiance aux X-Forwarded-*.
true Confiance totale — uniquement si le proxy est l’unique entrée.
IP, CIDR, liste Confiance conditionnée à l’adresse de la socket.
"loopback", "linklocal", "uniquelocal" Préréglages de plages privées.

La politique est compilée une seule fois au premier usage (HttpKernel.getTrustProxyChecker(), http-kernel.ts#HttpKernel.getTrustProxyChecker, via buildTrustProxy(), trustProxy.ts#buildTrustProxy) : aucune structure allouée par requête. La résolution de l’IP cliente remonte la chaîne de droite à gauche depuis la socket réelle (resolveForwarded(), forwarded.ts#resolveForwarded) — conforme RFC 7239 et à la recommandation OWASP.

« Ce Host est-il le mien ? » → trustedHosts#

Barrière testée avant le routage, contre l’injection d’en-tête Host. Le domaine canonique du kernel est toujours accepté, plus le loopback en développement (HttpKernel.compileAlias(), http-kernel.ts#HttpKernel.compileAlias). false (défaut) = ce socle seul ; une liste ajoute des vhosts (exact ou joker d’un seul niveau, *.cdn.example.com) ; true désactive la barrière — à réserver au cas où le proxy filtre déjà le Host (config.ts#healthSchema).

« Cette page a-t-elle le droit d’ouvrir un WebSocket ? » → allowedOrigins#

Les navigateurs n’appliquent pas CORS aux WebSockets. Sans contrôle, une page tierce peut ouvrir un WS authentifié par le cookie de session de la victime (CSWSH, OWASP WSTG-CLNT-10). Nodefony exige donc par défaut que l’Origin du handshake corresponde au Host servi (HttpKernel.checkWebsocketOrigin(), http-kernel.ts#HttpKernel.checkWebsocketOrigin), avec tolérance loopback en développement et allowlist explicite pour les SPA cross-origine. Un refus se solde par un 403 au handshake, avant le 101 : la socket ne s’ouvre jamais (RFC 6455 §4.2.2).

ℹ️ Note

Une requête sans Origin (client non navigateur : script, agent, test) est acceptée (http-kernel.ts#HttpKernel.getWsOriginPolicy). Ce n’est pas un trou : un attaquant non navigateur n’a aucun besoin de CSWSH, il se connecte directement. Le contrôle protège les utilisateurs, pas le port.

Probes de santé — /livez et /readyz#

Deux questions différentes, deux réponses différentes — les confondre casse les déploiements.

Probe Question Réponse
/livez Le processus est-il vivant ? 200 tant qu’il sert, y compris pendant le drain.
/readyz Peut-il recevoir du trafic ? 200 si le boot est complet, que l’arrêt n’a pas commencé et qu’aucun composant ne retient la mise en service ; 503 sinon.

Le détail qui compte : livez reste à 200 pendant l’arrêt gracieux. Répondre 503 là ferait redémarrer le pod en plein drain par le kubelet, et casserait précisément ce qu’on essaie de protéger.

Implémentation : court-circuit total du pipeline dans HttpKernel.onHttpRequest() (http-kernel.ts#HttpKernel.onHttpRequest) — pas de contexte, pas de portée DI, pas de session, pas de journal par sonde, réponses pré-allouées (HttpKernel.#respondHealth(), http-kernel.ts#HttpKernel.#respondHealth). Et surtout : avant le rate-limit. Un kubelet qui reçoit un 429 croit le pod mort → cascade de redémarrages.

Option Type Défaut Effet
enabled bool true Expose les probes (healthSchema, config.ts#healthSchema).
livenessPath string /livez Chemin de la sonde de vie (livenessProbe.httpGet.path k8s).
readinessPath string /readyz Chemin de la sonde de disponibilité.
shutdownDelay ms 0 Délai entre la bascule 503 et le début du drain (propagation du LB).

Retenir la mise en service — kernel.setReadiness()#

Un pod peut être parfaitement démarré et incapable de servir : son schéma de base est en retard, un cache n’est pas chaud, un service dont il dépend n’a pas encore répondu. Tout composant peut alors retenir /readyz, et l’orchestrateur en tire la conséquence : il n’envoie pas de trafic, l’ancien exemplaire continue de servir, le déploiement s’arrête proprement.

// Dans un service, au boot puis à chaque re-vérification.
kernel.setReadiness("schema", false, "3 migrations en attente"); // → /readyz 503
// …le travail de migration s'applique (depuis l'extérieur, par un Job) :
kernel.setReadiness("schema", true); // → /readyz 200, sans redéploiement
kernel.clearReadiness("schema"); // le composant s'arrête : sa voix ne compte plus

Trois règles à connaître :

Ce que voit l’exploitant : le journal du pod NOMME ce qui retient (CRITIC à chaque bascule), le plan d’administration publie ready et readinessBlocked (GET /nodefony/kernel/api/livez ; les noms sont réservés à un appelant authentifié), et nodefony status affiche une ligne disponibilité. Le corps de /readyz, lui, reste une constante — c’est le prix à ne pas payer sur un chemin sondé toutes les 2 à 10 secondes.

Le match est strict sur l’URL brute : /livez?x=1 repart dans le pipeline normal. Les deux serveurs (HTTP et HTTPS) servent ces chemins — un kubelet configuré en scheme: HTTPS fonctionne.

Arrêt gracieux — la séquence du SIGTERM#

Un arrêt brutal coupe les requêtes en vol : à chaque mise à jour progressive, des utilisateurs voient une erreur. L’arrêt de Nodefony est une séquence ordonnée, et l’ordre est le cœur du sujet.

Étape 1 — la disponibilité bascule en premier. L’écouteur est posé à onPostReady pour être inséré en tête et donc s’exécuter en premier (http-kernel.ts#HttpKernel.init). readyz répond 503, le load balancer cesse d’envoyer du trafic, et shutdownDelay laisse à cette information le temps de se propager.

Étape 2 — les clients WebSocket sont prévenus. Websocket.terminate() (server-websocket.ts#Websocket.terminate) envoie un message applicatif puis une frame Close 1001 « Going Away » (server-websocket.ts#Websocket.terminate). Sans elle, la coupure TCP ferait voir un 1006 au client — code réservé, jamais émis sur le fil, indiscernable d’une panne réseau. Avec 1001, le client sait qu’il doit simplement se reconnecter.

Étape 3 — les requêtes HTTP se terminent. Le drain (createDrainTerminator(), serverShutdown.ts#createDrainTerminator) cesse d’accepter, puis traite chaque connexion selon son protocole :

Ce qui reste au-delà de shutdownTimeout (défaut 5000 ms, serverShutdown.ts#DEFAULT_SHUTDOWN_TIMEOUT) est détruit.

❗ Important

L’ordre WS-avant-HTTP n’est pas cosmétique. Les serveurs WebSocket s’inscrivent en prependOnceListener (server-websocket.ts#Websocket.createServer), les serveurs HTTP en once (server-http.ts#ServerHttp.createServer). Inversé, le drain détruirait les sockets déjà upgradées sans frame Close : retour du 1006 pour tous les clients temps réel.

Deux garde-fous encadrent la séquence : shutdownTimeout par serveur (le drain nominal) et shutdownDeadline global au kernel (défaut 15 s, src/nodefony/src/config/defaults.ts#defaultAppConfig), filet anti-écouteur bloqué. Les deux doivent rester sous la période de grâce de l’orchestrateur — 30 s en Kubernetes, 10 s avec Docker.

Résilience — ce qui ne doit jamais tuer le processus#

Un serveur runtime se juge à ce qu’il fait des cas anormaux. Quatre défenses vivent au niveau du transport.

En-têtes malformés — le piège clientError de Node#

Dès qu’un écouteur clientError est attaché, Node cesse de fermer la socket automatiquement. Un écouteur naïf (« je journalise et je passe ») transforme donc une requête malformée en fuite de socket et de descripteur de fichier — c’est-à-dire un vecteur de déni de service. Nodefony répond et ferme explicitement (handleClientError(), clientError.ts#handleClientError) : 431 si les en-têtes débordent (RFC 6585 §5), 400 sinon (clientError.ts#handleClientError), et rien du tout si la socket est déjà morte.

Connexions WebSocket zombies#

Une socket dont le pair a disparu sans frame Close reste « ouverte » côté TCP : slot mémoire et descripteur retenus pour personne. Le battement de cœur les réclame (startHeartbeat(), wsHeartbeat.ts#startHeartbeat) : un ping toutes les keepaliveInterval ms ; sans pong dans les keepaliveGracePeriod ms, la socket est détruite (terminate, wsHeartbeat.ts#IHeartbeatClient.terminate).

L’implémentation est délibérément frugale — c’est du chemin chaud : un seul setInterval par serveur, jamais un timer par connexion ; deux horodatages posés directement sur la socket (trackPong(), wsHeartbeat.ts#trackPong), donc zéro allocation par tick et aucun nettoyage à prévoir (les horodatages meurent avec la socket) ; une granularité de réveil plancher à 250 ms pour borner une configuration pathologique (tick, wsHeartbeat.ts#startHeartbeat) ; et un timer unref qui ne retient jamais le processus à l’arrêt.

Floods de connexions#

L’upgrade WebSocket est une requête HTTP : il passe donc par le même compteur de rate-limit par IP que les requêtes ordinaires, vérifié avant toute allocation de contexte (HttpKernel.onWebsocketUpgrade(), http-kernel.ts#HttpKernel.onWebsocketUpgrade), et AVANT le 101 : le handshake reçoit un 429 HTTP, jamais une socket ouverte puis fermée (websocketQuotaRefusal(), http-kernel.ts#HttpKernel.websocketQuotaRefusal), sans journalisation (un journal par handshake rejeté serait lui-même un amplificateur sous flood).

Un second plafond, désactivé par défaut, borne le nombre de connexions simultanées par IP : wsMaxConnectionsPerIp (config.ts#httpConfigSchema). En cloud-native, laisser null et déléguer à l’edge — nginx limit_conn, HAProxy sc_conn_cur — qui voit tout le trafic, rejette avant le coût du descripteur et du TLS, et couvre tous les pods. Ne l’activer que sur une machine sans ingress.

Bornes de payload#

maxPayload (1 MiB par défaut) fait fermer en 1009 « Message Too Big » ; maxBackpressure (4 MiB) protège contre le client lent à recevoir, dont le tampon d’envoi gonflerait jusqu’à l’OOM — un seul lent peut plomber une diffusion générale. La politique par défaut, drop, saute la frame et garde le client connecté.

⚡ Performance & mémoire#

Le transport est du chemin chaud absolu : ce qui coûte ici est multiplié par le nombre de requêtes par seconde. Les choix visibles dans le code :

Ordre de grandeur mesuré : un processus Node saturé sur un cœur tient environ 400 requêtes/s en boucle locale avec dégradation gracieuse (1600 connexions concurrentes, aucun crash) ; côté WebSocket, plus de 750 connexions simultanées et 33 000 à 38 000 messages/s soutenus. Rejouer ces mesures : skill nodefony-load-test. Gate mémoire avant tout commit touchant le pipeline : npm run test:memory (skill nodefony-check-memory-health).

Mise à l’échelle. Un processus = un pod. Le passage à l’échelle est horizontal, confié à l’orchestrateur ; sur une seule machine, nodefony cluster -w N fork des workers. Attention à la conséquence : broadcast() ne touche que les clients du même worker — un fan-out inter-processus demande le backplane realtime.

📜 Normes appliquées#

Domaine Norme Ancrage
HTTP/1.1 (sémantique, message) RFC 9110, 9112 node:http + pipeline HttpKernel.onHttpRequest() (http-kernel.ts#HttpKernel.onHttpRequest)
HTTP/2 RFC 9113 ServerHttps.createServerH2() (server-https.ts#ServerHttps.createServerH2)
HTTP/2 Rapid Reset CVE-2023-44487 maxConcurrentStreams (config.ts#http2Schema)
En-têtes trop volumineux → 431 RFC 6585 §5 handleClientError() (clientError.ts#handleClientError)
WebSocket — protocole RFC 6455 ws@8 + options (config.ts#certificatesSchema)
WebSocket — Close 1001 « Going Away » RFC 6455 §7.4.1 Websocket.terminate() (server-websocket.ts#Websocket.terminate)
WebSocket — 1009 « Message Too Big » RFC 6455 §7.4.1 maxPayload (config.ts#websocketSchema)
WebSocket — validation UTF-8 RFC 6455 §8.1 skipUTF8Validation (config.ts#websocketSchema)
WebSocket — compression RFC 7692 perMessageDeflate (config.ts#websocketSchema)
CSWSH (Origin au handshake) OWASP WSTG-CLNT-10 HttpKernel.checkWebsocketOrigin() (http-kernel.ts#HttpKernel.checkWebsocketOrigin)
En-têtes forwarded RFC 7239 resolveForwarded() (forwarded.ts#resolveForwarded)
Certificat — série, SAN, extensions RFC 5280 Certificate.generateSerialHex() (certificates.ts#Certificate.generateSerialHex)
Certificat — identité par le SAN RFC 9525 sanSchema (config.ts#sanSchema)

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

Symptôme Cause Correction
L’app écoute sur 5153 au lieu de 5151 portPolicy: "auto" (défaut dev) : le port était pris — c’est annoncé Libérer le port, ou servers.portPolicy: "strict" pour échouer franchement
En production, le pod est « sain » mais injoignable Un port glissé en silence Rien à faire : strict est le défaut hors dev — vérifier qu’on ne l’a pas forcé
nodefony status ne voit pas le serveur Ports sondés par convention alors qu’ils ont glissé Déjà géré : les ports effectifs sont publiés (http-kernel.ts#RouteMetaData)
Le WSS ignore websocket.maxPayload Le serveur secure lit websocketSecure, section distincte Régler les deux sections (server-websocket-secure.ts#WebsocketSecure.constructor)
Plus de WebSocket après avoir mis servers.https: false Le WSS est adossé au serveur HTTPS et tombe avec lui Attendu — utiliser ws:// sur le port clair, ou réactiver HTTPS
Le client WebSocket voit 1006 à chaque redéploiement Frame Close jamais reçue (socket coupée avant) Déjà géré : close 1001 avant le drain (server-websocket.ts#Websocket.terminate)
Requêtes coupées à chaque mise à jour progressive Destruction des connexions au lieu d’un drain Déjà géré (createDrainTerminator()) — vérifier shutdownTimeout < grâce k8s
Le pod redémarre en boucle pendant l’arrêt livez répondrait 503 pendant le drain Déjà géré : livez reste 200, seul readyz bascule
Cascade de redémarrages sous charge Sonde de santé soumise au rate-limit Déjà géré : les probes court-circuitent avant le rate-limit (http-kernel.ts#HttpKernel.handle)
curl --http2 renvoie du HTTP/1.1 servers.https.protocol: "1.1", ou client sans ALPN Passer protocol: "2.0" (défaut) et vérifier le client
Avertissement navigateur en HTTPS de développement Certificat auto-signé (mkcert absent) brew install mkcert nss && mkcert -install, puis redémarrer
Le certificat n’est pas régénéré après un changement de domaine On croit qu’un fichier présent suffit Déjà géré : le SAN est vérifié (certificates.ts#Certificate.isDev)
strategy: "explicit" fait échouer le boot key/cert absents de la configuration Fournir les deux chemins — l’échec est volontaire, jamais un repli silencieux
Une IP falsifiée passe dans les journaux d’audit trustProxy accordé trop largement Restreindre à l’IP/CIDR du proxy, ou revenir à false
Handshake WebSocket refusé en 403 Origin non autorisée (anti-CSWSH) Ajouter l’origine dans websocket.allowedOrigins
Handshakes WebSocket refusés en 429 Rate-limit d’IP, ou plafond de connexions concurrentes Vérifier rateLimit et wsMaxConnectionsPerIp

📡 Observabilité — Studio et CLI#

Studio. L’onglet Configuration rend la config du module en réglages documentés (type, défaut, état, valeur effective) à partir du JSON Schema dérivé du schéma Zod (src/packages/@nodefony/http/index.ts#Http.constructor). Le profiler expose les phases par requête, y compris l’origine du transport.

CLI. Trois commandes touchent directement aux serveurs :

Commande Rôle
nodefony http:network [interface] [--json] Interfaces réseau vues par le kernel (networkCommand.ts#Network.constructor).
nodefony http:certificates [--force] [--json] (Re)génère et décrit le certificat de dev (certificatesCommand.ts#Certificates.constructor).
nodefony proxy:generate <nginx|haproxy> [-o file] Dérive une configuration reverse-proxy de l’introspection réelle.

proxy:generate mérite un mot : la configuration nginx/HAProxy est dérivée des domaines de confiance, des ports effectifs et des dossiers statiques montés — donc elle ne diverge pas du code. Le résumé de certificat vient de Certificate.describe() (certificates.ts#Certificate.describe), source unique partagée par la commande, le boot et un futur écran d’administration.

Runtime. nodefony status et nodefony stop lisent les ports effectifs publiés au boot ; ils fonctionnent donc même après un décalage de port.

🧪 Tests & couverture#

Les chiffres exacts vivent dans la carte de tests de cette page (régénérée depuis vitest, jamais figés dans le Markdown).

Type Où
Unitaires unit/certificates.test.ts (stratégies, conformité), unit/trustProxy.test.ts (CIDR, préréglages, BlockList), unit/generateProxyConfig.test.ts
Unitaires — ports unit/portBinder.test.ts — repli sur de vraies sockets (un listen simulé ne prouverait rien du noyau)
Intégration HTTP http/http.test.ts, http/http1.test.ts, http/httpKernel.test.ts (pipeline, en-têtes, X-Request-Id)
Intégration TLS http/https.test.ts — handshake, version TLS, chiffrement, SAN localhost, HSTS
Intégration santé http/health.test.ts — /livez et /readyz sur HTTP et HTTPS, absence de Set-Cookie
Intégration Host http/host-misdirected.test.ts — barrière trustedHosts
Résilience http/resilience.test.ts — déconnexions abruptes, corps surdimensionnés, en-têtes malformés (clientError), rafales, aborts en pleine réponse HTTP/2
Timeouts / aborts http/timeout-abort.test.ts, http/abort-cleanup.test.ts, http/client-abort-499.test.ts
WebSocket websockets/websocket.test.ts, websocket-protocol.test.ts, websocket-limits.test.ts, websocket-binary-broadcast.test.ts
Charge / mémoire tests/load/ws-connections-load.test.ts (plafond de sockets), ws-messages-load.test.ts (débit + diffusion), http/memory.test.ts (gate mémoire)
E2E d’arrêt nodefony-load-test → run.sh graceful — readyz à 503, requête en vol servie, WS fermé en 1001, port libéré
E2E de bornes run.sh ws-handshake-rl (rate-limit du handshake), run.sh ws-conn-cap (plafond de connexions par IP)

Ce qui manque aujourd’hui : aucun test d’intégration ne couvre le décalage de port de bout en bout (le repli est prouvé unitairement sur de vraies sockets, pas via un boot complet), et il n’existe pas de banc dédié au keep-alive WebSocket (la détection de zombie est exercée indirectement par les tests de charge).

Suites : npm test (unitaires), npm run test:integration (serveur requis), npm run test:load et npm run test:memory (serveur lancé avec --expose-gc). Couverture : npm run coverage dans @nodefony/http — le pourcentage vit dans le rapport vitest, jamais figé ici. Skills associés : nodefony-load-test, nodefony-check-memory-health, nodefony-security-review.

🔗 Pour aller plus loin#