@nodefony/http 10.0.0-alpha.5 → 10.0.0-alpha.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import { isPortListening } from "nodefony";
1
2
  //#region nodefony/src/servers/portBinder.ts
2
3
  /** Nombre de ports essayés après le désiré, en `auto`. */
3
4
  const DEFAULT_PORT_RETRY_ATTEMPTS = 20;
@@ -42,6 +43,69 @@ function buildBindPlan(which, servers, environment) {
42
43
  attempts: policy === "auto" ? servers?.portRetryAttempts ?? 20 : 0
43
44
  };
44
45
  }
46
+ /** Adresses qui désignent « toutes les interfaces » — rien ne s'y connecte. */
47
+ const WILDCARD_HOSTS = /* @__PURE__ */ new Set([
48
+ "",
49
+ "*",
50
+ "0.0.0.0",
51
+ "::",
52
+ "[::]"
53
+ ]);
54
+ /** Adresses de la boucle locale, toutes familles — c'est aussi ce qu'on REND. */
55
+ const LOOPBACK_HOSTS = ["127.0.0.1", "::1"];
56
+ /** Les mêmes, plus le nom qui les désigne — DÉRIVÉ, jamais recopié. */
57
+ const LOOPBACK_ALIASES = /* @__PURE__ */ new Set([...LOOPBACK_HOSTS, "localhost"]);
58
+ /**
59
+ * Adresses à interroger pour savoir si notre future liaison chevauche déjà un
60
+ * serveur vivant.
61
+ *
62
+ * La règle est celle du TRAFIC, pas celle du noyau : on sonde ce que notre
63
+ * liaison va RECEVOIR.
64
+ * - Écouter sur **toutes les interfaces** recouvre la boucle locale : on sonde
65
+ * ses deux adresses (`127.0.0.1` et `::1`). Un wildcard n'est pas une
66
+ * destination, on ne peut pas s'y connecter.
67
+ * - Écouter sur la **boucle locale** (`localhost` résout `::1` ici, `127.0.0.1`
68
+ * ailleurs — et les clients ne choisissent pas) : les deux, là encore.
69
+ * - Écouter sur une **adresse d'interface précise** : elle seule. Une connexion
70
+ * vers elle atteint le tiers qu'il ait lié cette adresse ou le wildcard, donc
71
+ * une cible suffit — et sonder la boucle locale ferait refuser un pod à cause
72
+ * d'un voisin de conteneur qui n'a jamais été en conflit.
73
+ *
74
+ * Fonction PURE : le banc l'éprouve sans ouvrir un socket.
75
+ *
76
+ * @param host - hôte passé à `listen` (`kernel.domain`).
77
+ * @returns les adresses à interroger, dans l'ordre.
78
+ */
79
+ function conflictProbeTargets(host) {
80
+ const value = (host ?? "").trim().toLowerCase();
81
+ if (WILDCARD_HOSTS.has(value) || LOOPBACK_ALIASES.has(value)) return LOOPBACK_HOSTS;
82
+ return [value];
83
+ }
84
+ /**
85
+ * Première adresse où un serveur répond DÉJÀ sur ce port, `null` si la voie est
86
+ * libre.
87
+ *
88
+ * @param port - port candidat.
89
+ * @param host - hôte que `listen` recevra (décide des adresses interrogées).
90
+ * @param probe - sonde de présence (injectée par le banc).
91
+ */
92
+ async function detectPortConflict(port, host, probe) {
93
+ for (const target of conflictProbeTargets(host)) if (await probe(port, target)) return target;
94
+ return null;
95
+ }
96
+ /**
97
+ * Erreur d'un conflit que le noyau aurait laissé passer.
98
+ *
99
+ * Porte `EADDRINUSE` : pour l'appelant c'est le même fait — le port n'est pas
100
+ * prenable — et une seule branche le traite. Le message, lui, doit dire ce que
101
+ * le code d'erreur seul ferait chercher au mauvais endroit : ici `listen` aurait
102
+ * RÉUSSI, et c'est la liaison d'une AUTRE adresse qui capte le trafic.
103
+ */
104
+ function portConflictError(port, host) {
105
+ const error = /* @__PURE__ */ new Error(`Port ${port} déjà servi par un autre processus sur ${host} — le noyau l'aurait pourtant accordé (deux adresses distinctes coexistent sur le même port), et cette application aurait écouté sans rien recevoir. Libérer le port (nodefony status · nodefony stop), en figer un autre (servers.http.port / servers.https.port), ou autoriser le repli (servers.portPolicy = "auto", défaut en développement).`);
106
+ error.code = "EADDRINUSE";
107
+ return error;
108
+ }
45
109
  /** Prochain candidat : incrémente, en sautant ce que les autres serveurs veulent. */
46
110
  function nextCandidate(from, reserved) {
47
111
  let port = from + 1;
@@ -84,31 +148,39 @@ function isPortUnavailable(code, port) {
84
148
  * (`ENOTFOUND`, `EACCES` sous 1024 — cf `isPortUnavailable`), soit un port
85
149
  * indisponible après épuisement des essais (le fallback n'est PAS infini).
86
150
  */
87
- function bindWithFallback(server, host, plan) {
88
- return new Promise((resolve, reject) => {
89
- let candidate = plan.desired;
90
- let used = 0;
91
- const attempt = () => {
92
- const onError = (error) => {
93
- server.removeListener("listening", onListening);
94
- if (!isPortUnavailable(error.code, candidate) || plan.desired === 0 || used >= plan.attempts) return reject(error);
95
- used += 1;
96
- candidate = nextCandidate(candidate, plan.reserved);
97
- attempt();
98
- };
99
- const onListening = () => {
100
- server.removeListener("error", onError);
101
- resolve({
102
- address: server.address(),
103
- shiftedFrom: candidate === plan.desired ? null : plan.desired
104
- });
105
- };
106
- server.once("error", onError);
107
- server.once("listening", onListening);
108
- server.listen(candidate, host);
151
+ async function bindWithFallback(server, host, plan, probe = isPortListening) {
152
+ let candidate = plan.desired;
153
+ let used = 0;
154
+ /**
155
+ * Une tentative d'écoute. L'erreur est RENDUE, jamais levée depuis le
156
+ * callback : les deux écouteurs se retirent dans les deux cas, et la boucle
157
+ * ci-dessous reste le seul endroit qui décide.
158
+ */
159
+ const listenOnce = (port) => new Promise((settle) => {
160
+ const onError = (error) => {
161
+ server.removeListener("listening", onListening);
162
+ settle(error);
109
163
  };
110
- attempt();
164
+ const onListening = () => {
165
+ server.removeListener("error", onError);
166
+ settle(null);
167
+ };
168
+ server.once("error", onError);
169
+ server.once("listening", onListening);
170
+ server.listen(port, host);
111
171
  });
172
+ for (;;) {
173
+ const conflict = plan.desired === 0 || candidate !== plan.desired ? null : await detectPortConflict(candidate, host, probe);
174
+ const failure = conflict ? portConflictError(candidate, conflict) : await listenOnce(candidate);
175
+ if (failure === null) return {
176
+ address: server.address(),
177
+ shiftedFrom: candidate === plan.desired ? null : plan.desired
178
+ };
179
+ if (!conflict && !isPortUnavailable(failure.code, candidate)) throw failure;
180
+ if (plan.desired === 0 || used >= plan.attempts) throw failure;
181
+ used += 1;
182
+ candidate = nextCandidate(candidate, plan.reserved);
183
+ }
112
184
  }
113
185
  //#endregion
114
- export { DEFAULT_PORT_RETRY_ATTEMPTS, bindWithFallback, buildBindPlan, resolvePortPolicy };
186
+ export { DEFAULT_PORT_RETRY_ATTEMPTS, bindWithFallback, buildBindPlan, conflictProbeTargets, detectPortConflict, resolvePortPolicy };
@@ -14,6 +14,24 @@
14
14
  * `listen()` est, lui, **atomique** : soit il réussit, soit le noyau dit
15
15
  * `EADDRINUSE`. On retente donc sur l'échec réel, jamais sur une prédiction.
16
16
  *
17
+ * ## Pourquoi une SONDE malgré cela
18
+ *
19
+ * Le `listen()` est atomique, mais il ne dit pas tout : un port peut être **déjà
20
+ * servi** et le noyau l'accorder quand même. Lier `0.0.0.0:P` alors qu'un autre
21
+ * processus tient `127.0.0.1:P` **réussit** sur macOS et les BSD — ce sont deux
22
+ * liaisons distinctes, et les connexions locales partent à la PLUS SPÉCIFIQUE.
23
+ * Entre familles d'adresses (`::1` tenu, `0.0.0.0` demandé), même linux
24
+ * l'accorde. L'application annonce alors `READY`, publie ses ports… et ne reçoit
25
+ * rien : une panne muette, et un banc qui interroge le serveur du VOISIN.
26
+ *
27
+ * On constate donc, AVANT de binder, qu'aucun serveur ne répond déjà là où notre
28
+ * liaison va recevoir (`isPortListening` — une connexion en boucle locale, aucun
29
+ * outil système, même verdict sur les trois plateformes). Ce n'est pas la sonde
30
+ * « ce port est-il libre ? » écartée plus haut : elle ne remplace pas le repli
31
+ * atomique, qui reste le seul à trancher la course — elle ajoute le seul conflit
32
+ * que le noyau n'exprime JAMAIS. La course résiduelle (un tiers prend le port
33
+ * entre la sonde et le bind) retombe donc sur `EADDRINUSE`, inchangé.
34
+ *
17
35
  * ## Pourquoi sauter les ports réservés
18
36
  *
19
37
  * HTTP veut 5151, HTTPS veut 5152. Si 5151 est pris, incrémenter naïvement ferait
@@ -86,6 +104,45 @@ export interface ServersPortConfig {
86
104
  * @param environment - `kernel.environment` (arbitre le défaut de la politique).
87
105
  */
88
106
  export declare function buildBindPlan(which: "http" | "https", servers: ServersPortConfig | undefined, environment: string | undefined): BindPlan;
107
+ /**
108
+ * Sonde « un serveur répond-il déjà ici ? ».
109
+ *
110
+ * Injectable pour que le banc éprouve la DÉCISION sans dépendre d'un décor
111
+ * réseau ; le défaut est `isPortListening`, exposé par le cœur pour ne pas être
112
+ * recopié de paquet en paquet.
113
+ */
114
+ export type PortListeningProbe = (port: number, host: string) => Promise<boolean>;
115
+ /**
116
+ * Adresses à interroger pour savoir si notre future liaison chevauche déjà un
117
+ * serveur vivant.
118
+ *
119
+ * La règle est celle du TRAFIC, pas celle du noyau : on sonde ce que notre
120
+ * liaison va RECEVOIR.
121
+ * - Écouter sur **toutes les interfaces** recouvre la boucle locale : on sonde
122
+ * ses deux adresses (`127.0.0.1` et `::1`). Un wildcard n'est pas une
123
+ * destination, on ne peut pas s'y connecter.
124
+ * - Écouter sur la **boucle locale** (`localhost` résout `::1` ici, `127.0.0.1`
125
+ * ailleurs — et les clients ne choisissent pas) : les deux, là encore.
126
+ * - Écouter sur une **adresse d'interface précise** : elle seule. Une connexion
127
+ * vers elle atteint le tiers qu'il ait lié cette adresse ou le wildcard, donc
128
+ * une cible suffit — et sonder la boucle locale ferait refuser un pod à cause
129
+ * d'un voisin de conteneur qui n'a jamais été en conflit.
130
+ *
131
+ * Fonction PURE : le banc l'éprouve sans ouvrir un socket.
132
+ *
133
+ * @param host - hôte passé à `listen` (`kernel.domain`).
134
+ * @returns les adresses à interroger, dans l'ordre.
135
+ */
136
+ export declare function conflictProbeTargets(host: string | undefined): readonly string[];
137
+ /**
138
+ * Première adresse où un serveur répond DÉJÀ sur ce port, `null` si la voie est
139
+ * libre.
140
+ *
141
+ * @param port - port candidat.
142
+ * @param host - hôte que `listen` recevra (décide des adresses interrogées).
143
+ * @param probe - sonde de présence (injectée par le banc).
144
+ */
145
+ export declare function detectPortConflict(port: number, host: string | undefined, probe: PortListeningProbe): Promise<string | null>;
89
146
  /**
90
147
  * Écoute sur `plan.desired`, ou sur le prochain port libre si `attempts > 0`.
91
148
  *
@@ -99,4 +156,4 @@ export declare function buildBindPlan(which: "http" | "https", servers: ServersP
99
156
  * (`ENOTFOUND`, `EACCES` sous 1024 — cf `isPortUnavailable`), soit un port
100
157
  * indisponible après épuisement des essais (le fallback n'est PAS infini).
101
158
  */
102
- export declare function bindWithFallback(server: Listenable, host: string, plan: BindPlan): Promise<BindResult>;
159
+ export declare function bindWithFallback(server: Listenable, host: string, plan: BindPlan, probe?: PortListeningProbe): Promise<BindResult>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodefony/http",
3
- "version": "10.0.0-alpha.5",
3
+ "version": "10.0.0-alpha.6",
4
4
  "description": "Serveurs HTTP, HTTPS, HTTP/2 et WebSocket natifs pour Nodefony : sessions, contextes de requête, certificats TLS",
5
5
  "author": "Christophe CAMENSULI <ccamensuli@gmail.com>",
6
6
  "main": "./dist/index.js",
@@ -75,7 +75,7 @@
75
75
  "@types/xml2js": "0.4.14",
76
76
  "@vitest/coverage-v8": "5.0.0",
77
77
  "chai": "6.2.2",
78
- "nodefony": "^10.0.0-alpha.5",
78
+ "nodefony": "^10.0.0-alpha.6",
79
79
  "rimraf": "6.1.3",
80
80
  "tsx": "4.23.13",
81
81
  "vitest": "5.0.0"
@@ -84,7 +84,7 @@
84
84
  "readmeFilename": "README.md",
85
85
  "contributors": [],
86
86
  "peerDependencies": {
87
- "nodefony": "^10.0.0-alpha.5",
87
+ "nodefony": "^10.0.0-alpha.6",
88
88
  "zod": "^4.6.1"
89
89
  },
90
90
  "files": [