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

Proxy inverse — relayer un préfixe d'URL vers un autre serveur, sur la même origine

stable@nodefony/httpmis à jour 2026-10-03

Un standard téléphonique : l’appelant compose un seul numéro, et le standard transfère vers le bon poste selon ce qu’il demande. Nodefony fait de même avec un préfixe d’URL : tout ce qui commence par /billing/ part vers le service de facturation, le reste suit le routage de l’application. Le navigateur ne voit qu’un serveur, une origine, un certificat ; l’autre serveur peut rester sur la boucle locale. Requêtes HTTP et, sur demande, connexions WebSocket. Chaque fait ci-dessous est ancré sur le code.

📍 Documentation › @nodefony/http › Proxy inverse

🧠 Le modèle mental — un aiguillage AVANT le routage#

Trois idées suffisent pour raisonner :

  1. Un montage = un préfixe → une origine. "/billing/" → http://127.0.0.1:8080. Le préfixe le plus long gagne (ReverseProxy.match(), reverse-proxy.ts#ReverseProxy.match).
  2. L’aiguillage passe AVANT le routage. Une route attrape-tout de l’application ne peut pas avaler un préfixe monté (HttpKernel.routeHttpRequest(), http-kernel.ts#HttpKernel.routeHttpRequest).
  3. Sans montage, il ne coûte rien. Le pipeline lit un champ (mounts === null) et passe son chemin (ReverseProxy.mounts, reverse-proxy.ts#ReverseProxy.mounts).

📖 Lexique#

Terme Sens
Proxy inverse Serveur placé DEVANT d’autres serveurs, qui reçoit les requêtes à leur place et les leur transmet (reverse proxy).
Amont Le serveur qui reçoit la requête relayée (upstream) — ici une origine http(s)://hôte:port.
Montage Association d’un préfixe d’URL à un amont, avec ses réglages.
Origine Triplet schéma + hôte + port (https://app:5152). Le navigateur cloisonne cookies, CSP et contextes sécurisés par origine.
En-têtes de connexion En-têtes qui ne valent que pour UN saut (Connection, Keep-Alive, Upgrade…) — hop-by-hop, RFC 9110 §7.6.1. Jamais transmis.
Via En-tête qui liste les relais traversés (RFC 9110 §7.6.3). Sert aussi à détecter une boucle.
X-Forwarded-* En-têtes qui disent à l’amont qui est le vrai client : son IP (-For), son schéma (-Proto), l’hôte demandé (-Host).
trustProxy Liste des pairs dont Nodefony croit les en-têtes X-Forwarded-* — voir Serveurs.
trustedHosts Liste des noms d’hôte que l’application accepte ; un autre Host reçoit 421.
Upgrade Bascule d’une connexion HTTP/1.1 vers un autre protocole — ici WebSocket (RFC 6455 §4).
CSWSH Cross-Site WebSocket Hijacking : une page tierce ouvre un WebSocket avec les cookies de la victime. Paré par le contrôle d’Origin.
HMR Hot Module Replacement : rechargement à chaud de Vite, qui passe par un WebSocket.
Pool keep-alive Connexions vers l’amont gardées ouvertes et réutilisées, pour ne pas payer une poignée de main TCP par requête.
400 / 421 / 403 Requête ambiguë ou malformée · hôte non servi ici (Misdirected Request) · Origin refusée.
502 / 504 / 508 Amont injoignable (Bad Gateway) · amont muet (Gateway Timeout) · boucle de relais détectée (Loop Detected, RFC 5842 §7.2).

Qu’est-ce qu’un proxy inverse, et quel problème résout-il ?#

Une application réelle a rarement un seul serveur. Un service de facturation existant, un serveur de sources (Vite en développement), un outil interne : chacun écoute sur son port. Les exposer tels quels coûte cher :

Le proxy inverse ramène tout sur une origine : le client parle à Nodefony, Nodefony parle aux autres. Mal écrit, un proxy ouvre ses propres failles, et c’est contre elles que Nodefony est durci :

La vision Nodefony#

Le proxy est un service générique de @nodefony/http, reverse-proxy (ReverseProxy, reverse-proxy.ts#ReverseProxy). Il accepte deux sources de montages, soumises aux mêmes règles (proxyMountProblems(), rules.ts#proxyMountProblems) :

Ce qu’il fait différemment d’un nginx placé devant :

Le compromis assumé :

⚠️ Attention

Un préfixe monté est servi AVANT le pare-feu, la protection CSRF et les en-têtes de sécurité APPLICATIFS de l’application (CSP, Referrer-Policy, COOP). C’est une surface publique pour qui atteint Nodefony : l’amont porte sa propre politique d’accès (ReverseProxy.forward(), reverse-proxy.ts#ReverseProxy.forward). Ne montez jamais un service qui suppose être protégé par le pare-feu Nodefony.

Ce qui s’applique malgré tout, parce que posé à l’entrée, avant l’aiguillage :

La CSP à nonce de l’application, elle, n’est jamais imposée : elle casserait les pages d’un autre serveur, dont les scripts ne portent pas ce nonce.

🚀 Démarrage rapide#

Dans une application générée par nodefony create app, un service existant écoute sur 127.0.0.1:8080. On le publie sous /billing/, sur l’origine de l’application.

1. Un amont de démonstration#

Pour voir ce que reçoit l’amont, un serveur minimal qui renvoie les en-têtes reçus suffit :

// billing.ts — amont de démonstration : renvoie ce qu'il reçoit
import http from "node:http";

http
  .createServer((req, res) => {
    res.setHeader("content-type", "application/json");
    res.end(JSON.stringify({ path: req.url, headers: req.headers }, null, 2));
  })
  .listen(8080, "127.0.0.1");

2. Déclarer le montage#

// nodefony.config.ts — /billing/ relayé vers le service de facturation
export default defineConfig(() => ({
  modules: [
    use("@nodefony/http", {
      proxy: {
        mounts: {
          "/billing/": {
            target: "http://127.0.0.1:8080", // origine nue, sans chemin
            stripPrefix: true, // l'amont reçoit /factures, pas /billing/factures
          },
        },
      },
    }),
    "@nodefony/framework",
  ],
}));

3. Observer#

curl -sk -D - https://127.0.0.1:5152/billing/factures?mois=09
HTTP/1.1 200 OK
content-type: application/json
via: 1.1 nodefony-3f9a1c2e

{
  "path": "/factures?mois=09",
  "headers": {
    "host": "127.0.0.1:8080",
    "x-forwarded-for": "127.0.0.1",
    "x-forwarded-proto": "https",
    "x-forwarded-host": "127.0.0.1:5152",
    "x-forwarded-prefix": "/billing",
    "via": "1.1 nodefony-3f9a1c2e"
  }
}

⚙️ Configuration#

Section proxy de use("@nodefony/http", { … }), schéma Zod strict : une clé inconnue interrompt le démarrage en la nommant (proxySchema, config.ts#proxySchema).

Option Type Défaut Effet
timeoutMs entier 30000 Délai d’inactivité par défaut avec l’amont ; dépassé avant la réponse → 504.
connectTimeoutMs entier 5000 Délai d’établissement de la connexion ; un amont qui n’accepte pas → 504 sans attendre 30 s.
maxSockets entier 256 Connexions simultanées vers UN amont (pool dédié par montage) ; au-delà, les requêtes attendent. Borne aussi les tunnels WebSocket, hors pool : au-delà, 503.
mounts record {} Préfixe → montage. Préfixe le plus long d’abord.

Chaque montage (proxyMountSchema, config.ts#proxyMountSchema) :

Option Type Défaut Effet
target string — (requis) Origine de l’amont, http(s)://hôte[:port], sans chemin.
methods string[] toutes Méthodes relayées ; une autre méthode suit le routage de l’application.
websocket boolean false Relaie aussi l’upgrade WebSocket du préfixe.
stripPrefix boolean false Retire le préfixe du chemin transmis ; Location est alors ramené sous le préfixe.
preserveHost boolean false Transmet le Host du client au lieu de celui de la cible.
stripHeaders string[] [] En-têtes de requête à ne pas transmettre (cookie, authorization…).
timeoutMs entier proxy.timeoutMs Délai d’inactivité propre au montage, handshake WebSocket compris (jusqu’au 101). Ne s’applique pas à un WebSocket établi.
secure boolean true Vérifie le certificat d’une cible https. false n’est accepté que vers la boucle locale.
accessLog "all" | "errors" "all" Ligne d’accès des requêtes relayées : toutes, ou seulement celles en échec (statut ≥ 400), avec le statut rendu par l’amont.

Refusés au démarrage, avec le motif (proxyMountProblems(), rules.ts#proxyMountProblems) :

Choisir les réglages — en situation#

L’amont connaît son préfixe (Vite et son base, une appli servie sous /docs/) : ne rien retirer.

"/docs/": { target: "http://127.0.0.1:4000" } // l'amont reçoit /docs/guide

L’amont croit être à la racine (un service existant qu’on ne modifie pas) : retirer le préfixe. Ses redirections Location: /login sont ramenées en /billing/login (rewriteLocation(), forward.ts#rewriteLocation).

"/billing/": { target: "http://127.0.0.1:8080", stripPrefix: true }

❗ Important

stripPrefix ne réécrit ni le HTML ni le Path des cookies. Un amont qui écrit des liens absolus (href="/factures") les fera sortir du montage : servez-le sous son préfixe, ou faites-lui lire X-Forwarded-Prefix.

L’amont n’a pas à voir la session de l’application : retirer les authentifiants.

"/sources/": {
  target: "http://127.0.0.1:5173",
  methods: ["GET", "HEAD"], // un serveur de sources ne sert que des lectures
  stripHeaders: ["cookie", "authorization"],
}
Le client envoie… Résultat
GET /sources/app.js avec son cookie relayé, sans Cookie ni Authorization
POST /sources/app.js non relayé : suit le routage (404 si rien ne le prend)
GET /sources/..%2fsecret 400, l’amont n’est pas contacté
GET /sources/x avec Host: evil.example 421 (barrière d’hôte, si domainCheck)

Le cas Vite — rien à déclarer#

En développement, le front servi par Vite passe par ce proxy sans une ligne de configuration : @nodefony/frontend monte lui-même /_vite/<famille>/ vers le Vite de chaque famille. La page, ses modules, ses images et le WebSocket du rechargement à chaud partagent alors l’origine de l’application — une IP de réseau local, un téléphone ou un conteneur fonctionnent en HTTPS sans réglage.

Ce qui est fixé par @nodefony/frontend Pourquoi
methods: ["GET", "HEAD"] un serveur de sources ne sert que des lectures
websocket: true le rechargement à chaud passe par un WebSocket
stripHeaders: ["cookie", "authorization"] Vite n’a pas à voir la session de l’application
accessLog: "errors" une page charge des centaines de modules ; seuls les échecs méritent une ligne au journal
cible = le Vite de CETTE application, s’il sert un port repris par une autre application n’est jamais visé

Ce qui se règle reste côté frontend, dans use("@nodefony/frontend", { … }) : devHost (défaut 127.0.0.1, à garder — le navigateur ne joint jamais Vite directement) et devPort (défaut 5173, chaque famille prend le port suivant). Le préfixe /_vite/ est refusé dans proxy.mounts : il appartient au module (MODULE_PROXY_PREFIXES, rules.ts#MODULE_PROXY_PREFIXES). En production, Vite ne tourne pas et aucun montage n’est posé.

🔌 WebSocket relayé#

Avec websocket: true, l’upgrade du préfixe est relayé au lieu d’aller au serveur WebSocket de Nodefony. Un seul écouteur upgrade par serveur fait l’aiguillage : le proxy d’abord, puis ws, créé en noServer pour garder ses propres contrôles (path, verifyClient, maxPayload) (attachUpgradeDispatch(), upgradeDispatch.ts#attachUpgradeDispatch).

Avant de relayer, chaque contrôle rend la réponse qu’aurait rendue le serveur WebSocket de Nodefony (ReverseProxy.handleUpgrade(), reverse-proxy.ts#ReverseProxy.handleUpgrade) :

Le handshake… Réponse
porte un Host hors trustedHosts (si domainCheck) 421
a un chemin ambigu, ou n’est pas un handshake RFC 6455 conforme 400
porte une Origin refusée par la règle du serveur WebSocket 403
trouve déjà maxSockets tunnels ouverts vers cet amont 503
dépasse un quota par IP : débit de handshakes, ou wsMaxConnectionsPerIp 429
passe tous les contrôles relayé

Les quotas sont ceux d’un upgrade servi par Nodefony, appliqués par la même règle du noyau (HttpKernel.websocketQuotaRefusal(), http-kernel.ts#HttpKernel.websocketQuotaRefusal) ; un tunnel ouvert garde sa place jusqu’à sa fermeture. Seule la forme du refus diffère : le proxy répond avant le 101, il peut donc rendre un statut HTTP, là où le serveur WebSocket ne peut plus que fermer avec le code 1013.

Une fois l’upgrade transmis, un amont qui accepte la connexion puis se tait est coupé au bout de timeoutMs (504) ; ce délai cesse au 101, et un tunnel établi peut rester muet aussi longtemps qu’il veut.

Seul Upgrade: websocket est relayé : un tunnel h2c ou tout autre protocole raccordé octet pour octet échapperait à toute inspection (websocketHandshakeProblem(), rules.ts#websocketHandshakeProblem). Une fois la connexion établie, les deux sockets sont raccordées et détruites ensemble ; le TCP keep-alive détecte un pair mort (forwardUpgrade(), forward.ts#forwardUpgrade).

🧩 Étendre — monter depuis un module#

Un module se résout le service par nom, sans importer @nodefony/http. La cible peut être une fonction, rappelée à chaque requête : utile quand le port de l’amont n’est connu qu’à l’exécution. Elle rend undefined quand rien n’est relayable, et la requête suit alors le routage.

// Dans un service de module — un serveur enfant qui choisit son port au démarrage
const proxy = this.container?.get("reverse-proxy") as ReverseProxy | undefined;
proxy?.mount("/outil/", {
  target: () => (child.ready ? `http://127.0.0.1:${child.port}` : undefined),
  websocket: true,
});
// À l'arrêt de l'enfant :
proxy?.unmount("/outil/");

Exemple réel : @nodefony/frontend monte ainsi chaque famille Vite (voir « Le cas Vite »), avec une cible fonction qui ne vise un port que si le Vite de CETTE application y répond (FrontendService.mountDevProxy(), FrontendService.ts#FrontendService.mountDevProxy).

🏗️ Architecture interne — le parcours d’une requête relayée#

Ce que le relais fait aux en-têtes, dans les deux sens :

Vers l’amont (requestHeadersFor(), forward.ts#requestHeadersFor) Vers le client (responseHeadersFor(), forward.ts#responseHeadersFor)
retire les en-têtes de connexion, et ceux que nomme Connection retire les en-têtes de connexion, et ceux que nomme Connection
retire Expect (Nodefony a déjà répondu 100) et les stripHeaders garde les Set-Cookie multiples
réécrit Host sur la cible (sauf preserveHost) ramène Location sous le préfixe (stripPrefix)
X-Forwarded-* : prolongés d’un pair trustProxy, recommencés sinon prolonge Via
X-Forwarded-Prefix : à la suite de celui d’un pair trustProxy
Forwarded d’un pair trustProxy : prolongé de ce saut, jamais créé
prolonge Via ; recalcule le cadrage du corps

Les échecs, dans l’ordre où ils peuvent survenir (forwardRequest(), forward.ts#forwardRequest) :

Chaque montage a son pool keep-alive dédié, jamais l’agent global de Node : sous NODE_USE_ENV_PROXY (poste d’entreprise), l’agent global enverrait un amont local au proxy de l’entreprise (ReverseProxy.agentFor(), reverse-proxy.ts#ReverseProxy.agentFor). Les pools sont fermés à l’arrêt du noyau (ReverseProxy.closeAll(), reverse-proxy.ts#ReverseProxy.closeAll) ; un relais encore en vol part ensuite sur une connexion à usage unique, sans recréer de pool que personne ne fermerait.

🔐 Sécurité#

📜 Normes appliquées#

Norme Exigence Comment le code s’y conforme
RFC 9110 §7.6.1 Un intermédiaire retire les en-têtes de connexion Liste fixe + jetons de Connection, dans les deux sens (requestHeadersFor(), forward.ts#requestHeadersFor)
RFC 9110 §7.6.3 Un intermédiaire ajoute son entrée à Via Pseudonyme par processus, prolongé à l’aller et au retour (forward.ts#requestHeadersFor)
RFC 9112 §6.3 Un seul cadrage de corps ; Transfer-Encoding l’emporte Content-Length retiré quand le corps est découpé (bodyFramingOf(), forward.ts#bodyFramingOf)
RFC 9112 §6.1 Codage de transfert non compris → 501 Tout autre que chunked seul est refusé, jamais réétiqueté (forward.ts#viaEntry)
RFC 9113 §8.2.2 Pas d’en-tête de connexion en HTTP/2 Réponse refusée par la sortie h2 → 502, jamais une exception non rattrapée
RFC 7239 §8.1 Ne croire Forwarded que d’un relais de confiance trustProxy décide (ReverseProxy.forward(), reverse-proxy.ts#ReverseProxy.forward)
RFC 7239 §4 · §6 Chaque relais ajoute son élément ; IPv6 entre crochets Élément for=…;proto=…;host=… ajouté, valeurs non-jeton entre guillemets
RFC 6585 §4 429 Too Many Requests Quota par IP dépassé sur un upgrade relayé, avant le 101
RFC 6455 §4.2.1 · §10.2 Handshake conforme ; contrôle d’Origin contre le CSWSH websocketHandshakeProblem() (rules.ts#websocketHandshakeProblem) + règle d’Origin du serveur WS
RFC 5842 §7.2 508 Loop Detected Emprunté à WebDAV pour une boucle de relais (viaLoops(), forward.ts#viaLoops)

⚡ Performance & mémoire#

📡 Observabilité#

Les montages se déclarent dans la configuration résolue (console d’administration, carte du module @nodefony/http) ; un montage posé ou retiré par un module est journalisé en DEBUG, et le remplacement d’un montage de configuration en WARNING. Une réponse relayée se reconnaît à son en-tête Via.

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

Symptôme Cause Correction
Le boot s’arrête sur proxy.mounts Préfixe /, réservé, ou cible avec un chemin Lire le motif : il nomme la clé et le geste (rules.ts#proxyMountProblems)
Une route de l’application sous le préfixe n’est jamais atteinte Le proxy passe AVANT le routage Attendu : choisir un préfixe qui n’appartient qu’à l’amont
L’amont redirige hors du montage stripPrefix + liens absolus dans le HTML de l’amont Servir l’amont sous son préfixe, ou lui faire lire X-Forwarded-Prefix
L’amont voit 127.0.0.1 comme client Lecture de l’IP de la socket au lieu de X-Forwarded-For Faire confiance à Nodefony côté amont (son équivalent de trustProxy)
X-Forwarded-For posé par le client disparaît Client direct, hors trustProxy Attendu : un client ne choisit pas son IP. Régler trustProxy pour un relais légitime
400 sur une URL qui contient %2F Chemin ambigu refusé Attendu : encoder différemment côté client, ou servir cette ressource hors du montage
504 après 5 s L’amont n’accepte pas la connexion Vérifier qu’il écoute ; ajuster connectTimeoutMs seulement si l’amont est lent à accepter
508 Loop Detected La cible renvoie vers Nodefony lui-même Corriger target : elle doit désigner l’amont, pas l’application
508 derrière une longue chaîne de relais Via porte déjà 10 sauts ou plus Raccourcir la chaîne : au-delà, le proxy présume une boucle
501 sur un envoi de corps Transfer-Encoding: gzip, chunked (ou autre codage) Envoyer le corps en chunked seul, ou compressé avec Content-Encoding
Upgrade relayé refusé en 429 Quota par IP : débit de handshakes ou wsMaxConnectionsPerIp Attendu sous flood ; sinon ajuster rateLimit ou wsMaxConnectionsPerIp (Rate-limit)
Upgrade relayé refusé en 503 maxSockets tunnels déjà ouverts vers cet amont Relever proxy.maxSockets, ou vérifier que les tunnels se ferment côté client
POST sous le préfixe rend 404 methods restreint Ajouter la méthode, ou laisser methods absent
Le WebSocket du préfixe arrive au serveur WS de Nodefony websocket absent (défaut false) websocket: true sur le montage

🧪 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 — relais unit/reverseProxy.test.ts — réseau réel : HTTP/1.1, front HTTP/2 vers amont HTTP/1.1, upgrade WebSocket (quotas d’un VRAI noyau, tunnels bornés, délai du handshake), refus au montage, durcissement, contrôles avant relais
Unitaires — config unit/proxyConfig.test.ts — schéma Zod strict, refus nommés, préfixes en double
Intégration http/vite-relay.test.ts — serveur de développement réel : image et client Vite relayés, en-têtes de transport présents et CSP absente, socket HMR sur l’origine de la page, écriture non relayée, 400, 421
Mémoire http/memory.test.ts — GET relayé et upgrade WebSocket relayé : pente du tas, scopes et contextes résiduels

Ces suites ont été vues échouer en débranchant le câblage (montage retiré, filtre d’en-têtes, garde du délai de connexion, quotas, plafond de tunnels, délai du handshake, plafond de Via, refus 501) : elles prouvent le branchement, pas seulement les fonctions.

Ce qui manque :

Suites : npm test (unitaires), npm run test:integration et npm run test:memory (serveur requis). Skills associés : nodefony-load-test, nodefony-check-memory-health, nodefony-security-review.

🔗 Pour aller plus loin#