@nodefony/http 10.0.0-beta.2 → 10.0.0-beta.4

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.
Files changed (139) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorateParam.js +1 -2
  3. package/dist/index.js +19 -11
  4. package/dist/nodefony/command/assetsPublishCommand.js +5 -3
  5. package/dist/nodefony/command/certificatesCommand.js +5 -3
  6. package/dist/nodefony/command/networkCommand.js +5 -3
  7. package/dist/nodefony/command/proxyGenerateCommand.js +8 -3
  8. package/dist/nodefony/config/config.js +45 -6
  9. package/dist/nodefony/config/defineModuleConfig.js +2 -3
  10. package/dist/nodefony/interfaces/IReverseProxy.js +1 -0
  11. package/dist/nodefony/service/HttpAdminApi.js +2 -3
  12. package/dist/nodefony/service/ProfilerAdminApi.js +1 -2
  13. package/dist/nodefony/service/audit-logger.js +22 -16
  14. package/dist/nodefony/service/certificates.js +228 -19
  15. package/dist/nodefony/service/error-renderer.js +30 -13
  16. package/dist/nodefony/service/http-kernel.js +331 -82
  17. package/dist/nodefony/service/pretty-request-logger.js +5 -4
  18. package/dist/nodefony/service/request-logger.js +24 -16
  19. package/dist/nodefony/service/reverse-proxy.js +336 -0
  20. package/dist/nodefony/service/servers/clientError.js +22 -3
  21. package/dist/nodefony/service/servers/server-h2c.js +126 -0
  22. package/dist/nodefony/service/servers/server-http.js +5 -8
  23. package/dist/nodefony/service/servers/server-https.js +12 -24
  24. package/dist/nodefony/service/servers/server-static.js +7 -10
  25. package/dist/nodefony/service/servers/server-websocket-secure.js +18 -18
  26. package/dist/nodefony/service/servers/server-websocket.js +18 -18
  27. package/dist/nodefony/service/servers/serverShutdown.js +78 -18
  28. package/dist/nodefony/service/servers/wsHeartbeat.js +32 -17
  29. package/dist/nodefony/service/sessions/sessions-service.js +34 -20
  30. package/dist/nodefony/service/trace.js +1 -2
  31. package/dist/nodefony/service/upload/upload-service.js +5 -8
  32. package/dist/nodefony/service/x509.js +1 -2
  33. package/dist/nodefony/src/assets/collectAssets.js +1 -2
  34. package/dist/nodefony/src/assets/prebuiltUi.js +1 -2
  35. package/dist/nodefony/src/context/Context.js +51 -32
  36. package/dist/nodefony/src/context/domainMatcher.js +21 -4
  37. package/dist/nodefony/src/context/forwarded.js +3 -4
  38. package/dist/nodefony/src/context/http/HttpContext.js +98 -46
  39. package/dist/nodefony/src/context/http/Request.js +34 -38
  40. package/dist/nodefony/src/context/http/Response.js +39 -7
  41. package/dist/nodefony/src/context/http/SseStream.js +349 -0
  42. package/dist/nodefony/src/context/http/parser.js +36 -10
  43. package/dist/nodefony/src/context/http/urlFastPath.js +1 -2
  44. package/dist/nodefony/src/context/http2/Request.js +4 -8
  45. package/dist/nodefony/src/context/http2/Response.js +22 -9
  46. package/dist/nodefony/src/context/metaData.js +1 -2
  47. package/dist/nodefony/src/context/requestId.js +1 -2
  48. package/dist/nodefony/src/context/responseEnded.js +26 -0
  49. package/dist/nodefony/src/context/transport.js +45 -0
  50. package/dist/nodefony/src/context/trustProxy.js +1 -2
  51. package/dist/nodefony/src/context/websocket/Response.js +42 -6
  52. package/dist/nodefony/src/context/websocket/WebsocketContext.js +94 -20
  53. package/dist/nodefony/src/context/websocket/isWebsocketContext.js +17 -0
  54. package/dist/nodefony/src/context/websocket/websocketUrl.js +20 -0
  55. package/dist/nodefony/src/context/websocket/wsBackpressure.js +1 -2
  56. package/dist/nodefony/src/context/websocket/wsCloseReason.js +1 -2
  57. package/dist/nodefony/src/context/websocket/wsLogContent.js +1 -2
  58. package/dist/nodefony/src/cookies/cookie.js +3 -7
  59. package/dist/nodefony/src/errors/httpError.js +1 -2
  60. package/dist/nodefony/src/errors/thrownMessage.js +1 -2
  61. package/dist/nodefony/src/profiler/FrameProfile.js +1 -2
  62. package/dist/nodefony/src/profiler/Profiler.js +108 -30
  63. package/dist/nodefony/src/profiler/aggregate.js +422 -0
  64. package/dist/nodefony/src/proxy/forward.js +500 -0
  65. package/dist/nodefony/src/proxy/generateProxyConfig.js +14 -6
  66. package/dist/nodefony/src/proxy/rules.js +188 -0
  67. package/dist/nodefony/src/rateLimit/MemoryRateLimitStore.js +1 -2
  68. package/dist/nodefony/src/rateLimit/WsConnectionCounter.js +1 -2
  69. package/dist/nodefony/src/rateLimit/rateLimitFilters.js +1 -2
  70. package/dist/nodefony/src/servers/http2Limits.js +50 -0
  71. package/dist/nodefony/src/servers/kernelServers.js +12 -3
  72. package/dist/nodefony/src/servers/portBinder.js +41 -10
  73. package/dist/nodefony/src/servers/upgradeDispatch.js +128 -0
  74. package/dist/nodefony/src/session/session.js +30 -15
  75. package/dist/nodefony/src/session/sessionStoreFailure.js +1 -2
  76. package/dist/nodefony/src/session/storage/MemorySessionStorage.js +1 -2
  77. package/dist/nodefony/src/session/storage/RevocationGuardStorage.js +1 -2
  78. package/dist/nodefony/src/session/storage/sessionFilters.js +1 -2
  79. package/dist/nodefony/src/session/storage/sessionSort.js +1 -2
  80. package/dist/types/index.d.ts +12 -3
  81. package/dist/types/nodefony/config/config.d.ts +16 -0
  82. package/dist/types/nodefony/interfaces/IContext.d.ts +65 -4
  83. package/dist/types/nodefony/interfaces/IErrorRenderer.d.ts +1 -1
  84. package/dist/types/nodefony/interfaces/IRequest.d.ts +9 -4
  85. package/dist/types/nodefony/interfaces/IReverseProxy.d.ts +67 -0
  86. package/dist/types/nodefony/interfaces/IRouting.d.ts +26 -0
  87. package/dist/types/nodefony/interfaces/ISecurity.d.ts +6 -0
  88. package/dist/types/nodefony/interfaces/index.d.ts +3 -2
  89. package/dist/types/nodefony/service/audit-logger.d.ts +2 -1
  90. package/dist/types/nodefony/service/certificates.d.ts +147 -5
  91. package/dist/types/nodefony/service/error-renderer.d.ts +32 -1
  92. package/dist/types/nodefony/service/http-kernel.d.ts +157 -15
  93. package/dist/types/nodefony/service/reverse-proxy.d.ts +156 -0
  94. package/dist/types/nodefony/service/servers/clientError.d.ts +16 -0
  95. package/dist/types/nodefony/service/servers/server-h2c.d.ts +51 -0
  96. package/dist/types/nodefony/service/servers/server-http.d.ts +3 -3
  97. package/dist/types/nodefony/service/servers/server-https.d.ts +4 -4
  98. package/dist/types/nodefony/service/servers/server-websocket-secure.d.ts +2 -3
  99. package/dist/types/nodefony/service/servers/server-websocket.d.ts +2 -3
  100. package/dist/types/nodefony/service/servers/serverShutdown.d.ts +30 -16
  101. package/dist/types/nodefony/service/servers/wsHeartbeat.d.ts +26 -0
  102. package/dist/types/nodefony/service/sessions/sessions-service.d.ts +19 -1
  103. package/dist/types/nodefony/src/context/Context.d.ts +35 -6
  104. package/dist/types/nodefony/src/context/domainMatcher.d.ts +11 -1
  105. package/dist/types/nodefony/src/context/http/HttpContext.d.ts +48 -1
  106. package/dist/types/nodefony/src/context/http/Request.d.ts +20 -15
  107. package/dist/types/nodefony/src/context/http/Response.d.ts +14 -0
  108. package/dist/types/nodefony/src/context/http/SseStream.d.ts +155 -0
  109. package/dist/types/nodefony/src/context/http/parser.d.ts +18 -2
  110. package/dist/types/nodefony/src/context/http2/Request.d.ts +1 -1
  111. package/dist/types/nodefony/src/context/http2/Response.d.ts +15 -5
  112. package/dist/types/nodefony/src/context/responseEnded.d.ts +20 -0
  113. package/dist/types/nodefony/src/context/transport.d.ts +53 -0
  114. package/dist/types/nodefony/src/context/websocket/Response.d.ts +18 -2
  115. package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +57 -7
  116. package/dist/types/nodefony/src/context/websocket/isWebsocketContext.d.ts +15 -0
  117. package/dist/types/nodefony/src/context/websocket/websocketUrl.d.ts +16 -0
  118. package/dist/types/nodefony/src/profiler/FrameProfile.d.ts +13 -12
  119. package/dist/types/nodefony/src/profiler/Profiler.d.ts +69 -15
  120. package/dist/types/nodefony/src/profiler/aggregate.d.ts +215 -0
  121. package/dist/types/nodefony/src/proxy/forward.d.ts +168 -0
  122. package/dist/types/nodefony/src/proxy/generateProxyConfig.d.ts +16 -0
  123. package/dist/types/nodefony/src/proxy/rules.d.ts +88 -0
  124. package/dist/types/nodefony/src/servers/http2Limits.d.ts +37 -0
  125. package/dist/types/nodefony/src/servers/kernelServers.d.ts +10 -1
  126. package/dist/types/nodefony/src/servers/portBinder.d.ts +29 -5
  127. package/dist/types/nodefony/src/servers/upgradeDispatch.d.ts +69 -0
  128. package/dist/types/nodefony/src/session/session.d.ts +2 -0
  129. package/docs/cookies.md +85 -78
  130. package/docs/index.md +5 -1
  131. package/docs/observabilite.md +81 -81
  132. package/docs/rate-limit.md +83 -83
  133. package/docs/reverse-proxy.md +522 -0
  134. package/docs/servers.md +378 -230
  135. package/docs/session.md +180 -180
  136. package/docs/sse.md +287 -0
  137. package/docs/upload.md +120 -117
  138. package/package.json +10 -11
  139. package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
@@ -0,0 +1,500 @@
1
+ import http from "node:http";
2
+ import https from "node:https";
3
+ // nodefony/src/proxy/forward.ts
4
+ /**
5
+ * En-têtes de CONNEXION (RFC 9110 §7.6.1) : ils décrivent un saut, jamais le
6
+ * message — un intermédiaire ne les transmet pas. Les cinq premiers sont en
7
+ * outre INTERDITS en HTTP/2 (RFC 9113 §8.2.2) : Node lève
8
+ * `ERR_HTTP2_INVALID_CONNECTION_HEADERS` si une réponse les porte.
9
+ */
10
+ const HOP_BY_HOP = /* @__PURE__ */ new Set([
11
+ "connection",
12
+ "keep-alive",
13
+ "proxy-connection",
14
+ "transfer-encoding",
15
+ "upgrade",
16
+ "te",
17
+ "trailer",
18
+ "proxy-authorization",
19
+ "proxy-authenticate"
20
+ ]);
21
+ /**
22
+ * En-têtes qui décrivent la chaîne de relais. Crus d'un relais de confiance,
23
+ * écartés sinon : un client direct qui les pose ment sur son origine.
24
+ */
25
+ const FORWARDING = /* @__PURE__ */ new Set([
26
+ "forwarded",
27
+ "x-forwarded-for",
28
+ "x-forwarded-proto",
29
+ "x-forwarded-host",
30
+ "x-forwarded-port",
31
+ "x-forwarded-prefix",
32
+ "x-forwarded-scheme",
33
+ "x-real-ip"
34
+ ]);
35
+ /** Corps d'erreur du proxy — courts, sans détail interne. */
36
+ const BAD_GATEWAY = "Bad Gateway";
37
+ const GATEWAY_TIMEOUT = "Gateway Timeout";
38
+ const LOOP_DETECTED = "Loop Detected";
39
+ const NOT_IMPLEMENTED = "Not Implemented";
40
+ /**
41
+ * Sauts `Via` au-delà desquels un message est tenu pour une boucle. Notre
42
+ * pseudonyme ne repère une boucle qu'au RETOUR dans ce processus : derrière un
43
+ * répartiteur qui tourne sur N instances, cela peut prendre N+1 sauts, chacun
44
+ * tenant une connexion. Une chaîne légitime (CDN, répartiteur, ingress) en
45
+ * compte rarement plus de cinq.
46
+ */
47
+ const MAX_VIA_HOPS = 10;
48
+ /**
49
+ * Noms listés par l'en-tête `Connection` d'un message : eux aussi ne valent
50
+ * que pour ce saut (RFC 9110 §7.6.1).
51
+ *
52
+ * @param value - valeur brute de `Connection`
53
+ * @returns noms en minuscules, ou `null` s'il n'y en a aucun
54
+ */
55
+ function connectionTokens(value) {
56
+ if (value === void 0) return null;
57
+ const raw = Array.isArray(value) ? value.join(",") : value;
58
+ let tokens = null;
59
+ for (const t of raw.split(",")) {
60
+ const name = t.trim().toLowerCase();
61
+ if (name === "") continue;
62
+ (tokens ??= /* @__PURE__ */ new Set()).add(name);
63
+ }
64
+ return tokens;
65
+ }
66
+ /** Valeur d'en-tête aplatie (`a, b` pour un tableau). */
67
+ function flat(value) {
68
+ return Array.isArray(value) ? value.join(", ") : value;
69
+ }
70
+ /**
71
+ * Le message est-il déjà passé par CE processus ? Un `Via` qui porte notre
72
+ * pseudonyme signe une boucle (RFC 9110 §7.6 : un proxy ne se renvoie pas un
73
+ * message sans s'en protéger).
74
+ *
75
+ * @param via - en-tête `Via` reçu
76
+ * @param pseudonym - pseudonyme de ce processus
77
+ */
78
+ function viaLoops(via, pseudonym) {
79
+ const value = flat(via);
80
+ if (value === void 0) return false;
81
+ const hops = value.split(",");
82
+ if (hops.length >= 10) return true;
83
+ for (const hop of hops) if (hop.trim().split(/\s+/)[1] === pseudonym) return true;
84
+ return false;
85
+ }
86
+ /** Entrée `Via` de ce saut : `<version> <pseudonyme>` (RFC 9110 §7.6.3). */
87
+ function viaEntry(httpVersion, pseudonym) {
88
+ return `${httpVersion.startsWith("2") ? "2" : httpVersion || "1.1"} ${pseudonym}`;
89
+ }
90
+ /**
91
+ * Cadrage du corps reçu — décidé UNE fois, pour que l'amont lise exactement
92
+ * les octets que le proxy lui envoie (RFC 9112 §6.3). Un `Transfer-Encoding`
93
+ * l'emporte sur un `Content-Length` (que l'on retire alors) ; en HTTP/2, un
94
+ * flux sans fin à la tête porte un corps même sans longueur annoncée.
95
+ *
96
+ * @param req - requête reçue
97
+ * @returns le cadrage à appliquer vers l'amont
98
+ */
99
+ function bodyFramingOf(req) {
100
+ const coding = req.headers["transfer-encoding"];
101
+ if (coding !== void 0) {
102
+ if (coding === "chunked") return "chunked";
103
+ return coding.trim().toLowerCase() === "chunked" ? "chunked" : "unsupported";
104
+ }
105
+ if (req.headers["content-length"] !== void 0) return "length";
106
+ const stream = req.stream;
107
+ if (stream !== void 0 && !stream.endAfterHeaders) return "chunked";
108
+ return "none";
109
+ }
110
+ /**
111
+ * En-têtes de la requête à transmettre à l'amont.
112
+ *
113
+ * - écartés : pseudo-en-têtes HTTP/2, en-têtes de connexion et ceux que nomme
114
+ * `Connection`, `Expect` (le serveur a déjà répondu `100`), en-têtes retirés
115
+ * par le montage, chaîne de relais venue d'un pair NON fiable ;
116
+ * - `Host` réécrit sur la cible (sauf `preserveHost`) ;
117
+ * - `X-Forwarded-For` prolongé (pair fiable) ou recommencé ;
118
+ * `X-Forwarded-Proto`/`-Host` hérités d'un pair fiable, posés sinon ;
119
+ * `X-Forwarded-Prefix` quand le préfixe est retiré — à la suite de celui
120
+ * d'un pair fiable ; `Forwarded` d'un pair fiable prolongé de ce saut ;
121
+ * - `Via` prolongé de ce saut ;
122
+ * - cadrage du corps rendu cohérent ({@link bodyFramingOf}).
123
+ *
124
+ * @returns un objet neuf, prêt pour `http.request`
125
+ */
126
+ function requestHeadersFor(headers, mount, target, from, pseudonym, framing) {
127
+ const listed = connectionTokens(headers.connection);
128
+ const stripped = mount.stripHeaders;
129
+ const out = {};
130
+ for (const name of Object.keys(headers)) {
131
+ const lower = name.toLowerCase();
132
+ if (lower.charCodeAt(0) === 58) continue;
133
+ if (HOP_BY_HOP.has(lower) || listed?.has(lower)) continue;
134
+ if (lower === "host" || lower === "expect" || lower === "via") continue;
135
+ if (lower === "content-length" && framing !== "length") continue;
136
+ if (FORWARDING.has(lower) && !from.trustedPeer) continue;
137
+ if (stripped?.includes(lower)) continue;
138
+ const value = headers[name];
139
+ if (value !== void 0) out[lower] = value;
140
+ }
141
+ out.host = mount.preserveHost === true && from.host ? from.host : target.host;
142
+ const prior = from.trustedPeer ? flat(headers["x-forwarded-for"]) : void 0;
143
+ if (from.remoteAddress) out["x-forwarded-for"] = prior ? `${prior}, ${from.remoteAddress}` : from.remoteAddress;
144
+ out["x-forwarded-proto"] ??= from.scheme;
145
+ if (from.host) out["x-forwarded-host"] ??= from.host;
146
+ if (mount.stripPrefix === true) {
147
+ let outer = from.trustedPeer ? flat(headers["x-forwarded-prefix"]) : void 0;
148
+ if (outer?.endsWith("/")) outer = outer.slice(0, -1);
149
+ out["x-forwarded-prefix"] = (outer ?? "") + mount.prefix.slice(0, -1);
150
+ }
151
+ const forwarded = from.trustedPeer ? flat(headers.forwarded) : void 0;
152
+ if (forwarded) out.forwarded = `${forwarded}, ${forwardedElement(from)}`;
153
+ const via = flat(headers.via);
154
+ const hop = viaEntry(from.httpVersion, pseudonym);
155
+ out.via = via ? `${via}, ${hop}` : hop;
156
+ if (framing === "chunked") out["transfer-encoding"] = "chunked";
157
+ return out;
158
+ }
159
+ /** Caractères d'un `token` (RFC 9110 §5.6.2) — compilé une fois. */
160
+ const TOKEN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
161
+ /** Valeur d'un paramètre `Forwarded` : jeton nu, sinon chaîne entre guillemets (RFC 7239 §4). */
162
+ function forwardedValue(value) {
163
+ return TOKEN.test(value) ? value : `"${value.replace(/["\\]/g, "\\$&")}"`;
164
+ }
165
+ /**
166
+ * Élément `Forwarded` de ce saut : `for=` (une IPv6 entre crochets et
167
+ * guillemets, RFC 7239 §6), `proto=`, `host=`.
168
+ */
169
+ function forwardedElement(from) {
170
+ const address = from.remoteAddress;
171
+ let element = `for=${forwardedValue(address === void 0 ? "unknown" : address.includes(":") ? `[${address}]` : address)};proto=${forwardedValue(from.scheme)}`;
172
+ if (from.host) element += `;host=${forwardedValue(from.host)}`;
173
+ return element;
174
+ }
175
+ /**
176
+ * Ramène sous le préfixe un `Location` émis par un amont qui ignore son
177
+ * montage (`stripPrefix`) — l'équivalent de `proxy_redirect default` de
178
+ * nginx. Un chemin absolu (`/login`) et une URL sur l'origine de l'amont sont
179
+ * réécrits ; toute autre URL (un tiers) part intacte.
180
+ *
181
+ * @returns la valeur réécrite, ou celle reçue
182
+ */
183
+ function rewriteLocation(location, mount, target) {
184
+ if (mount.stripPrefix !== true) return location;
185
+ if (location.startsWith("/") && !location.startsWith("//")) return mount.prefix + location.slice(1);
186
+ if (location.startsWith(target.origin + "/") || location === target.origin) return mount.prefix + location.slice(target.origin.length + 1);
187
+ return location;
188
+ }
189
+ /**
190
+ * En-têtes de la réponse de l'amont à renvoyer au client : en-têtes de
191
+ * connexion écartés — y compris ceux que nomme `Connection` —, `Via`
192
+ * prolongé, `Location` ramené sous le préfixe (`stripPrefix`) ; le reste
193
+ * intact (un `set-cookie` multiple reste un tableau).
194
+ *
195
+ * @returns un objet neuf, valable en HTTP/1.1 comme en HTTP/2
196
+ */
197
+ function responseHeadersFor(headers, mount, target, hop) {
198
+ const listed = connectionTokens(headers.connection);
199
+ const out = {};
200
+ for (const name of Object.keys(headers)) {
201
+ if (HOP_BY_HOP.has(name) || listed?.has(name)) continue;
202
+ const value = headers[name];
203
+ if (value !== void 0) out[name] = value;
204
+ }
205
+ const via = flat(headers.via);
206
+ out.via = via ? `${via}, ${hop}` : hop;
207
+ if (typeof out.location === "string") out.location = rewriteLocation(out.location, mount, target);
208
+ return out;
209
+ }
210
+ /**
211
+ * Chemin transmis à l'amont : tel quel, ou sans le préfixe (`stripPrefix`).
212
+ *
213
+ * @param url - cible de la requête (chemin + requête), qui commence par le préfixe
214
+ * @param mount - montage qui relaie
215
+ * @returns le chemin, toujours avec un `/` en tête
216
+ */
217
+ function upstreamPath(url, mount) {
218
+ if (mount.stripPrefix !== true) return url;
219
+ return `/${url.slice(mount.prefix.length)}`;
220
+ }
221
+ /**
222
+ * Options de `http.request` communes aux deux relais : cible, pool, TLS.
223
+ * La vérification du certificat suit `secure` (refusé hors boucle locale
224
+ * au montage).
225
+ */
226
+ function requestOptions(target, mount, relay) {
227
+ const options = {
228
+ protocol: target.protocol,
229
+ hostname: target.hostname,
230
+ port: target.port
231
+ };
232
+ if (relay.agent !== void 0) options.agent = relay.agent;
233
+ if (target.protocol === "https:") options.rejectUnauthorized = mount.secure !== false;
234
+ return options;
235
+ }
236
+ /** Module client selon le scheme de la cible. */
237
+ function requestFn(target) {
238
+ return target.protocol === "https:" ? https.request : http.request;
239
+ }
240
+ /**
241
+ * Arme le délai d'ÉTABLISSEMENT de la connexion amont — distinct du délai
242
+ * d'inactivité : un amont qui n'accepte pas la connexion ne doit pas tenir le
243
+ * client pendant tout `timeoutMs`. Un socket réutilisé (pool) est déjà
244
+ * connecté : rien à armer.
245
+ *
246
+ * @param upstream - requête vers l'amont
247
+ * @param ms - délai
248
+ * @param onTimeout - appelé si la connexion n'est pas établie à temps
249
+ */
250
+ function armConnectTimeout(upstream, ms, onTimeout) {
251
+ upstream.once("socket", (socket) => {
252
+ if (!socket.connecting) return;
253
+ const timer = setTimeout(onTimeout, ms);
254
+ const clear = () => {
255
+ clearTimeout(timer);
256
+ socket.removeListener("connect", clear);
257
+ socket.removeListener("close", clear);
258
+ };
259
+ socket.once("connect", clear);
260
+ socket.once("close", clear);
261
+ });
262
+ }
263
+ /**
264
+ * Relaie une requête HTTP vers l'amont et renvoie sa réponse au client.
265
+ *
266
+ * Le corps est transmis avec un cadrage cohérent ({@link bodyFramingOf}). Un
267
+ * message qui porte déjà notre `Via` rend 508 sans contacter l'amont. Un
268
+ * client parti coupe l'échange amont ; un amont injoignable rend 502 ; une
269
+ * connexion non établie à temps ou un amont muet rendent 504 — à condition
270
+ * que rien n'ait encore été envoyé, sinon la réponse est interrompue (un
271
+ * statut ne se rattrape pas). Un amont coupé en plein corps interrompt la
272
+ * réponse au client au lieu de la laisser pendre.
273
+ *
274
+ * @returns une promesse résolue à la fermeture de la réponse — jamais rejetée
275
+ */
276
+ function forwardRequest(req, res, mount, target, from, relay) {
277
+ return new Promise((resolve) => {
278
+ const out = res;
279
+ let closed = false;
280
+ let failed = false;
281
+ const fail = (status, body) => {
282
+ if (failed || closed) return;
283
+ failed = true;
284
+ if (out.headersSent) {
285
+ out.destroy();
286
+ return;
287
+ }
288
+ out.writeHead(status, {
289
+ "content-type": "text/plain; charset=utf-8",
290
+ "content-length": Buffer.byteLength(body),
291
+ "cache-control": "no-store"
292
+ });
293
+ out.end(body);
294
+ };
295
+ let upstream = null;
296
+ out.once("close", () => {
297
+ closed = true;
298
+ if (!out.writableFinished) upstream?.destroy();
299
+ resolve();
300
+ });
301
+ if (viaLoops(req.headers.via, relay.pseudonym)) {
302
+ fail(508, LOOP_DETECTED);
303
+ return;
304
+ }
305
+ const framing = bodyFramingOf(req);
306
+ if (framing === "unsupported") {
307
+ fail(501, NOT_IMPLEMENTED);
308
+ return;
309
+ }
310
+ const options = requestOptions(target, mount, relay);
311
+ options.method = req.method ?? "GET";
312
+ options.path = upstreamPath(req.url ?? "/", mount);
313
+ options.headers = requestHeadersFor(req.headers, mount, target, from, relay.pseudonym, framing);
314
+ if (mount.timeoutMs !== void 0) options.timeout = mount.timeoutMs;
315
+ const request = requestFn(target)(options);
316
+ upstream = request;
317
+ let timedOut = false;
318
+ const giveUp = () => {
319
+ timedOut = true;
320
+ fail(504, GATEWAY_TIMEOUT);
321
+ request.destroy();
322
+ };
323
+ armConnectTimeout(request, relay.connectTimeoutMs, giveUp);
324
+ request.on("timeout", giveUp);
325
+ request.on("error", () => {
326
+ if (!timedOut) fail(502, BAD_GATEWAY);
327
+ });
328
+ const hop = viaEntry(from.httpVersion, relay.pseudonym);
329
+ request.on("response", (answer) => {
330
+ if (closed || failed) {
331
+ answer.resume();
332
+ return;
333
+ }
334
+ try {
335
+ out.writeHead(answer.statusCode ?? 502, responseHeadersFor(answer.headers, mount, target, hop));
336
+ } catch {
337
+ answer.resume();
338
+ fail(502, BAD_GATEWAY);
339
+ return;
340
+ }
341
+ answer.once("close", () => {
342
+ if (!answer.complete) out.destroy();
343
+ });
344
+ answer.pipe(out);
345
+ });
346
+ if (framing === "none") {
347
+ request.end();
348
+ return;
349
+ }
350
+ const body = req;
351
+ const onClientError = () => {
352
+ request.destroy();
353
+ };
354
+ body.once("error", onClientError);
355
+ request.once("close", () => {
356
+ body.removeListener("error", onClientError);
357
+ body.unpipe(request);
358
+ if (!body.readableEnded) body.resume();
359
+ });
360
+ body.pipe(request);
361
+ });
362
+ }
363
+ /**
364
+ * Sérialise une tête de réponse HTTP/1.1 depuis des en-têtes BRUTS (paires
365
+ * nom/valeur), sans réordonner ni fusionner, en écartant les noms donnés —
366
+ * une réponse `101` doit rendre au client exactement ce que l'amont a négocié.
367
+ *
368
+ * @param status - ligne de statut, sans CRLF
369
+ * @param rawHeaders - `IncomingMessage.rawHeaders`
370
+ * @param skip - noms à écarter (minuscules)
371
+ * @param extra - lignes ajoutées à la fin (`Nom: valeur`)
372
+ * @returns la tête complète, CRLF final compris
373
+ */
374
+ function rawHead(status, rawHeaders, skip = null, extra = []) {
375
+ let head = `${status}\r\n`;
376
+ for (let i = 0; i + 1 < rawHeaders.length; i += 2) {
377
+ const name = rawHeaders[i] ?? "";
378
+ if (skip?.has(name.toLowerCase())) continue;
379
+ head += `${name}: ${rawHeaders[i + 1]}\r\n`;
380
+ }
381
+ for (const line of extra) head += `${line}\r\n`;
382
+ return `${head}\r\n`;
383
+ }
384
+ /**
385
+ * Réponse brute minimale sur un socket d'upgrade, puis fermeture.
386
+ *
387
+ * Les en-têtes passent par la validation de `node:http` : un nom ou une valeur
388
+ * invalide — un saut de ligne surtout, qui forgerait un en-tête de plus — est
389
+ * OMIS, jamais écrit. `Connection` et `Content-Length` sont posés ici et ne se
390
+ * remplacent pas.
391
+ *
392
+ * @param socket - socket du client, encore en attente de sa réponse
393
+ * @param status - statut HTTP du refus
394
+ * @param reason - phrase de raison
395
+ * @param headers - en-têtes du refus (`WWW-Authenticate`, `Retry-After`…)
396
+ */
397
+ function refuseUpgrade(socket, status, reason, headers) {
398
+ if (!socket.writable) {
399
+ socket.destroy();
400
+ return;
401
+ }
402
+ let extra = "";
403
+ if (headers) for (const name of Object.keys(headers)) {
404
+ const lower = name.toLowerCase();
405
+ if (lower === "connection" || lower === "content-length") continue;
406
+ const value = String(headers[name]);
407
+ try {
408
+ http.validateHeaderName(name);
409
+ http.validateHeaderValue(name, value);
410
+ } catch {
411
+ continue;
412
+ }
413
+ extra += `${name}: ${value}\r\n`;
414
+ }
415
+ socket.end(`HTTP/1.1 ${status} ${reason}\r\n${extra}Connection: close\r\nContent-Length: 0\r\n\r\n`);
416
+ }
417
+ /** En-têtes d'une réponse d'amont REFUSANT l'upgrade, à ne pas recopier tels quels. */
418
+ const REFUSAL_SKIP = /* @__PURE__ */ new Set([
419
+ "transfer-encoding",
420
+ "content-length",
421
+ "connection",
422
+ "keep-alive"
423
+ ]);
424
+ /**
425
+ * Relaie un upgrade WebSocket DÉJÀ validé (méthode, `Upgrade`, clé, version,
426
+ * `Origin` — cf `ReverseProxy.handleUpgrade`) : rejoue le handshake vers
427
+ * l'amont, renvoie sa réponse `101` au client puis raccorde les deux sockets
428
+ * octet pour octet. Un refus de l'amont (`400`, `403`…) est rendu avec un
429
+ * cadrage refait (fin de corps = fermeture) ; un amont injoignable rend `502`,
430
+ * une connexion non établie à temps — ou un amont qui accepte puis se tait
431
+ * au-delà de `timeoutMs` — `504`, une boucle `508`. Le délai d'inactivité
432
+ * cesse au `101` : un tunnel établi peut rester muet. Une erreur d'un côté
433
+ * détruit l'autre.
434
+ */
435
+ function forwardUpgrade(req, socket, head, mount, target, from, relay) {
436
+ if (viaLoops(req.headers.via, relay.pseudonym)) {
437
+ refuseUpgrade(socket, 508, LOOP_DETECTED);
438
+ return;
439
+ }
440
+ const options = requestOptions(target, mount, relay);
441
+ options.agent = false;
442
+ options.method = "GET";
443
+ options.path = upstreamPath(req.url ?? "/", mount);
444
+ const headers = requestHeadersFor(req.headers, mount, target, from, relay.pseudonym, "none");
445
+ headers.connection = "Upgrade";
446
+ headers.upgrade = "websocket";
447
+ options.headers = headers;
448
+ if (mount.timeoutMs !== void 0) options.timeout = mount.timeoutMs;
449
+ if (head.length > 0) socket.unshift(head);
450
+ const upstream = requestFn(target)(options);
451
+ let state = "pending";
452
+ const onClientError = () => {
453
+ upstream.destroy();
454
+ };
455
+ socket.once("error", onClientError);
456
+ armConnectTimeout(upstream, relay.connectTimeoutMs, () => {
457
+ state = "refused";
458
+ refuseUpgrade(socket, 504, GATEWAY_TIMEOUT);
459
+ upstream.destroy();
460
+ });
461
+ upstream.on("timeout", () => {
462
+ if (state === "open") return;
463
+ if (state === "pending") refuseUpgrade(socket, 504, GATEWAY_TIMEOUT);
464
+ else socket.destroy();
465
+ state = "refused";
466
+ upstream.destroy();
467
+ });
468
+ upstream.on("upgrade", (answer, upSocket, upHead) => {
469
+ state = "open";
470
+ upSocket.setTimeout(0);
471
+ socket.removeListener("error", onClientError);
472
+ const destroyBoth = () => {
473
+ socket.destroy();
474
+ upSocket.destroy();
475
+ };
476
+ socket.on("error", destroyBoth);
477
+ upSocket.on("error", destroyBoth);
478
+ upSocket.setKeepAlive(true);
479
+ socket.setKeepAlive(true);
480
+ if (upHead.length > 0) upSocket.unshift(upHead);
481
+ socket.write(rawHead(`HTTP/1.1 101 ${answer.statusMessage ?? "Switching Protocols"}`, answer.rawHeaders));
482
+ upSocket.pipe(socket).pipe(upSocket);
483
+ });
484
+ upstream.on("response", (answer) => {
485
+ state = "refused";
486
+ socket.write(rawHead(`HTTP/1.1 ${answer.statusCode ?? 502} ${answer.statusMessage ?? ""}`, answer.rawHeaders, REFUSAL_SKIP, ["Connection: close"]));
487
+ answer.pipe(socket);
488
+ });
489
+ upstream.on("error", () => {
490
+ if (state === "refused") return;
491
+ if (state === "open") {
492
+ socket.destroy();
493
+ return;
494
+ }
495
+ state = "refused";
496
+ refuseUpgrade(socket, 502, BAD_GATEWAY);
497
+ });
498
+ upstream.end();
499
+ }
500
+ export { MAX_VIA_HOPS, bodyFramingOf, forwardRequest, forwardUpgrade, rawHead, refuseUpgrade, requestHeadersFor, responseHeadersFor, rewriteLocation, upstreamPath, viaLoops };
@@ -1,16 +1,18 @@
1
- //#region nodefony/src/proxy/generateProxyConfig.ts
1
+ // nodefony/src/proxy/generateProxyConfig.ts
2
2
  /** Valeurs par défaut d'un modèle d'introspection (complété par la commande). */
3
3
  const defaultIntrospection = {
4
4
  domains: [],
5
5
  backendHost: "127.0.0.1",
6
6
  httpPort: 5151,
7
7
  httpsPort: 5152,
8
+ h2cPort: null,
8
9
  staticRoots: [],
9
10
  mounts: [],
10
11
  listen: 80,
11
12
  reencrypt: false,
12
13
  maxBodyBytes: 0,
13
14
  keepaliveIntervalMs: 0,
15
+ readinessPath: "/readyz",
14
16
  tls: null
15
17
  };
16
18
  /**
@@ -123,8 +125,16 @@ function nginxServerBlock(intro, scheme, idleSeconds, tls) {
123
125
  */
124
126
  function generateHaproxyConfig(intro) {
125
127
  const backendPort = intro.reencrypt ? intro.httpsPort : intro.httpPort;
128
+ const h2cPort = intro.reencrypt ? null : intro.h2cPort ?? null;
126
129
  const idleSeconds = idleTimeoutSeconds(intro);
127
130
  const serverSsl = intro.reencrypt ? ` ssl ca-file /etc/haproxy/certs/ca.pem verify required verifyhost ${firstDomain(intro)} sni str(${firstDomain(intro)})` : "";
131
+ const readiness = intro.readinessPath ?? null;
132
+ const healthCheck = readiness === null ? "" : ` # Retire l'exemplaire dès que ${readiness} répond 503 (début de l'arrêt),
133
+ # pas seulement quand son port se ferme.
134
+ option httpchk GET ${readiness}
135
+ http-check expect status 200
136
+ `;
137
+ const h2cCheck = readiness === null ? "check" : `check port ${intro.httpPort} check-proto h1`;
128
138
  const staticNote = intro.staticRoots.length > 0 || intro.mounts.length > 0 ? " # NB : haproxy ne sert pas de fichiers — les statiques sont proxifiés au\n # backend. Pour les offloader, utiliser nginx (proxy:generate nginx) +\n # `statics.enabled: false` côté Nodefony.\n" : "";
129
139
  return `# Généré par \`nodefony proxy:generate haproxy\` — NE PAS éditer à la main.
130
140
  # Reverse-proxy dérivé de l'introspection Nodefony.
@@ -166,11 +176,10 @@ frontend fe_nodefony
166
176
  http-request set-header Forwarded "for=%[src];proto=https;host=%[req.hdr(host)]" if { ssl_fc }
167
177
  http-request set-header Forwarded "for=%[src];proto=http;host=%[req.hdr(host)]" unless { ssl_fc }
168
178
 
169
- default_backend be_nodefony
170
-
179
+ ${h2cPort === null ? " default_backend be_nodefony\n" : " # h2c (servers.h2c) : HTTP/2 en clair vers le backend, plusieurs flux par\n # connexion. WebSocket reste en HTTP/1.1 sur le port HTTP : le port h2c\n # n'annonce pas le CONNECT étendu (RFC 8441).\n use_backend be_nodefony if { hdr(upgrade) -i websocket }\n default_backend be_nodefony_h2c\n"}
171
180
  backend be_nodefony
172
- ${staticNote} server nodefony ${intro.backendHost}:${backendPort} check${serverSsl}
173
- `;
181
+ ${healthCheck}${staticNote} server nodefony ${intro.backendHost}:${backendPort} check${serverSsl}
182
+ ${h2cPort === null ? "" : `\nbackend be_nodefony_h2c\n${healthCheck} server nodefony_h2c ${intro.backendHost}:${h2cPort} ${h2cCheck} proto h2\n`}`;
174
183
  }
175
184
  /** Premier domaine non-IP (pour verifyhost/sni), défaut `localhost`. */
176
185
  function firstDomain(intro) {
@@ -180,5 +189,4 @@ function firstDomain(intro) {
180
189
  function ensureTrailingSlash(dir) {
181
190
  return dir.endsWith("/") ? dir : `${dir}/`;
182
191
  }
183
- //#endregion
184
192
  export { defaultIntrospection, generateHaproxyConfig, generateNginxConfig };