@nodefony/http 10.0.0-beta.1 → 10.0.0-beta.3

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 (84) hide show
  1. package/README.md +5 -3
  2. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  4. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateParam.js +1 -1
  5. package/dist/index.js +11 -5
  6. package/dist/nodefony/command/certificatesCommand.js +0 -1
  7. package/dist/nodefony/config/config.js +48 -7
  8. package/dist/nodefony/config/defineModuleConfig.js +1 -1
  9. package/dist/nodefony/interfaces/IReverseProxy.js +1 -0
  10. package/dist/nodefony/service/HttpAdminApi.js +0 -1
  11. package/dist/nodefony/service/audit-logger.js +2 -3
  12. package/dist/nodefony/service/certificates.js +214 -158
  13. package/dist/nodefony/service/error-renderer.js +1 -1
  14. package/dist/nodefony/service/http-kernel.js +221 -47
  15. package/dist/nodefony/service/reverse-proxy.js +336 -0
  16. package/dist/nodefony/service/servers/server-http.js +3 -3
  17. package/dist/nodefony/service/servers/server-https.js +3 -3
  18. package/dist/nodefony/service/servers/server-static.js +4 -7
  19. package/dist/nodefony/service/servers/server-websocket-secure.js +12 -4
  20. package/dist/nodefony/service/servers/server-websocket.js +12 -4
  21. package/dist/nodefony/service/servers/wsHeartbeat.js +31 -15
  22. package/dist/nodefony/service/sessions/sessions-service.js +30 -9
  23. package/dist/nodefony/service/trace.js +2 -1
  24. package/dist/nodefony/service/upload/upload-service.js +3 -3
  25. package/dist/nodefony/service/x509.js +324 -0
  26. package/dist/nodefony/src/context/Context.js +28 -1
  27. package/dist/nodefony/src/context/domainMatcher.js +20 -2
  28. package/dist/nodefony/src/context/forwarded.js +2 -2
  29. package/dist/nodefony/src/context/http/HttpContext.js +91 -40
  30. package/dist/nodefony/src/context/http/Request.js +9 -4
  31. package/dist/nodefony/src/context/http/Response.js +46 -7
  32. package/dist/nodefony/src/context/http/SseStream.js +350 -0
  33. package/dist/nodefony/src/context/http/urlFastPath.js +35 -10
  34. package/dist/nodefony/src/context/responseEnded.js +27 -0
  35. package/dist/nodefony/src/context/websocket/Response.js +9 -0
  36. package/dist/nodefony/src/context/websocket/WebsocketContext.js +2 -3
  37. package/dist/nodefony/src/context/websocket/websocketUrl.js +21 -0
  38. package/dist/nodefony/src/profiler/Profiler.js +6 -10
  39. package/dist/nodefony/src/proxy/forward.js +476 -0
  40. package/dist/nodefony/src/proxy/rules.js +182 -0
  41. package/dist/nodefony/src/servers/portBinder.js +57 -2
  42. package/dist/nodefony/src/servers/upgradeDispatch.js +49 -0
  43. package/dist/nodefony/src/session/session.js +28 -12
  44. package/dist/types/index.d.ts +5 -0
  45. package/dist/types/nodefony/config/config.d.ts +17 -0
  46. package/dist/types/nodefony/interfaces/IContext.d.ts +2 -0
  47. package/dist/types/nodefony/interfaces/IErrorRenderer.d.ts +1 -1
  48. package/dist/types/nodefony/interfaces/IReverseProxy.d.ts +67 -0
  49. package/dist/types/nodefony/interfaces/IRouting.d.ts +16 -0
  50. package/dist/types/nodefony/interfaces/ISecurity.d.ts +6 -0
  51. package/dist/types/nodefony/interfaces/index.d.ts +1 -0
  52. package/dist/types/nodefony/service/audit-logger.d.ts +2 -1
  53. package/dist/types/nodefony/service/certificates.d.ts +126 -50
  54. package/dist/types/nodefony/service/error-renderer.d.ts +1 -1
  55. package/dist/types/nodefony/service/http-kernel.d.ts +111 -5
  56. package/dist/types/nodefony/service/reverse-proxy.d.ts +156 -0
  57. package/dist/types/nodefony/service/servers/server-websocket-secure.d.ts +1 -0
  58. package/dist/types/nodefony/service/servers/server-websocket.d.ts +1 -0
  59. package/dist/types/nodefony/service/servers/wsHeartbeat.d.ts +26 -0
  60. package/dist/types/nodefony/service/sessions/sessions-service.d.ts +19 -1
  61. package/dist/types/nodefony/service/x509.d.ts +86 -0
  62. package/dist/types/nodefony/src/context/Context.d.ts +22 -2
  63. package/dist/types/nodefony/src/context/domainMatcher.d.ts +11 -1
  64. package/dist/types/nodefony/src/context/http/HttpContext.d.ts +47 -0
  65. package/dist/types/nodefony/src/context/http/Response.d.ts +3 -0
  66. package/dist/types/nodefony/src/context/http/SseStream.d.ts +155 -0
  67. package/dist/types/nodefony/src/context/responseEnded.d.ts +20 -0
  68. package/dist/types/nodefony/src/context/websocket/websocketUrl.d.ts +16 -0
  69. package/dist/types/nodefony/src/profiler/Profiler.d.ts +1 -0
  70. package/dist/types/nodefony/src/proxy/forward.d.ts +156 -0
  71. package/dist/types/nodefony/src/proxy/rules.d.ts +85 -0
  72. package/dist/types/nodefony/src/servers/portBinder.d.ts +10 -46
  73. package/dist/types/nodefony/src/servers/upgradeDispatch.d.ts +39 -0
  74. package/dist/types/nodefony/src/session/session.d.ts +2 -0
  75. package/docs/cookies.md +13 -13
  76. package/docs/index.md +9 -3
  77. package/docs/observabilite.md +18 -18
  78. package/docs/rate-limit.md +30 -30
  79. package/docs/reverse-proxy.md +522 -0
  80. package/docs/servers.md +115 -78
  81. package/docs/session.md +61 -61
  82. package/docs/sse.md +283 -0
  83. package/docs/upload.md +37 -37
  84. package/package.json +10 -13
@@ -0,0 +1,476 @@
1
+ import http from "node:http";
2
+ import https from "node:https";
3
+ //#region 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
+ /** Réponse brute minimale sur un socket d'upgrade, puis fermeture. */
385
+ function refuseUpgrade(socket, status, reason) {
386
+ if (!socket.writable) {
387
+ socket.destroy();
388
+ return;
389
+ }
390
+ socket.end(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
391
+ }
392
+ /** En-têtes d'une réponse d'amont REFUSANT l'upgrade, à ne pas recopier tels quels. */
393
+ const REFUSAL_SKIP = /* @__PURE__ */ new Set([
394
+ "transfer-encoding",
395
+ "content-length",
396
+ "connection",
397
+ "keep-alive"
398
+ ]);
399
+ /**
400
+ * Relaie un upgrade WebSocket DÉJÀ validé (méthode, `Upgrade`, clé, version,
401
+ * `Origin` — cf `ReverseProxy.handleUpgrade`) : rejoue le handshake vers
402
+ * l'amont, renvoie sa réponse `101` au client puis raccorde les deux sockets
403
+ * octet pour octet. Un refus de l'amont (`400`, `403`…) est rendu avec un
404
+ * cadrage refait (fin de corps = fermeture) ; un amont injoignable rend `502`,
405
+ * une connexion non établie à temps — ou un amont qui accepte puis se tait
406
+ * au-delà de `timeoutMs` — `504`, une boucle `508`. Le délai d'inactivité
407
+ * cesse au `101` : un tunnel établi peut rester muet. Une erreur d'un côté
408
+ * détruit l'autre.
409
+ */
410
+ function forwardUpgrade(req, socket, head, mount, target, from, relay) {
411
+ if (viaLoops(req.headers.via, relay.pseudonym)) {
412
+ refuseUpgrade(socket, 508, LOOP_DETECTED);
413
+ return;
414
+ }
415
+ const options = requestOptions(target, mount, relay);
416
+ options.agent = false;
417
+ options.method = "GET";
418
+ options.path = upstreamPath(req.url ?? "/", mount);
419
+ const headers = requestHeadersFor(req.headers, mount, target, from, relay.pseudonym, "none");
420
+ headers.connection = "Upgrade";
421
+ headers.upgrade = "websocket";
422
+ options.headers = headers;
423
+ if (mount.timeoutMs !== void 0) options.timeout = mount.timeoutMs;
424
+ if (head.length > 0) socket.unshift(head);
425
+ const upstream = requestFn(target)(options);
426
+ let state = "pending";
427
+ const onClientError = () => {
428
+ upstream.destroy();
429
+ };
430
+ socket.once("error", onClientError);
431
+ armConnectTimeout(upstream, relay.connectTimeoutMs, () => {
432
+ state = "refused";
433
+ refuseUpgrade(socket, 504, GATEWAY_TIMEOUT);
434
+ upstream.destroy();
435
+ });
436
+ upstream.on("timeout", () => {
437
+ if (state === "open") return;
438
+ if (state === "pending") refuseUpgrade(socket, 504, GATEWAY_TIMEOUT);
439
+ else socket.destroy();
440
+ state = "refused";
441
+ upstream.destroy();
442
+ });
443
+ upstream.on("upgrade", (answer, upSocket, upHead) => {
444
+ state = "open";
445
+ upSocket.setTimeout(0);
446
+ socket.removeListener("error", onClientError);
447
+ const destroyBoth = () => {
448
+ socket.destroy();
449
+ upSocket.destroy();
450
+ };
451
+ socket.on("error", destroyBoth);
452
+ upSocket.on("error", destroyBoth);
453
+ upSocket.setKeepAlive(true);
454
+ socket.setKeepAlive(true);
455
+ if (upHead.length > 0) upSocket.unshift(upHead);
456
+ socket.write(rawHead(`HTTP/1.1 101 ${answer.statusMessage ?? "Switching Protocols"}`, answer.rawHeaders));
457
+ upSocket.pipe(socket).pipe(upSocket);
458
+ });
459
+ upstream.on("response", (answer) => {
460
+ state = "refused";
461
+ socket.write(rawHead(`HTTP/1.1 ${answer.statusCode ?? 502} ${answer.statusMessage ?? ""}`, answer.rawHeaders, REFUSAL_SKIP, ["Connection: close"]));
462
+ answer.pipe(socket);
463
+ });
464
+ upstream.on("error", () => {
465
+ if (state === "refused") return;
466
+ if (state === "open") {
467
+ socket.destroy();
468
+ return;
469
+ }
470
+ state = "refused";
471
+ refuseUpgrade(socket, 502, BAD_GATEWAY);
472
+ });
473
+ upstream.end();
474
+ }
475
+ //#endregion
476
+ export { MAX_VIA_HOPS, bodyFramingOf, forwardRequest, forwardUpgrade, rawHead, refuseUpgrade, requestHeadersFor, responseHeadersFor, rewriteLocation, upstreamPath, viaLoops };
@@ -0,0 +1,182 @@
1
+ import { isLoopbackHostname } from "nodefony";
2
+ //#region nodefony/src/proxy/rules.ts
3
+ /**
4
+ * Règles d'un montage du proxy inverse — UNIQUE source, lue par le schéma de
5
+ * configuration (`proxy.mounts`) ET par `ReverseProxy.mount()` : un montage
6
+ * écrit dans la config et un montage posé par un module obéissent aux mêmes
7
+ * refus, avec les mêmes mots.
8
+ *
9
+ * Pur : n'importe que le cœur, importable depuis `config/config.ts` sans
10
+ * cycle.
11
+ */
12
+ /**
13
+ * Préfixes que le framework se réserve : un montage n'y est JAMAIS accepté.
14
+ * `/nodefony/` porte la console d'administration et les API de Studio — les
15
+ * relayer ailleurs détournerait une surface protégée par le pare-feu.
16
+ */
17
+ const RESERVED_PROXY_PREFIXES = ["/nodefony/"];
18
+ /**
19
+ * Préfixes réservés aux montages POSÉS PAR UN MODULE : la configuration ne
20
+ * peut pas les prendre. `/_vite/` appartient à `@nodefony/frontend`, qui y
21
+ * relaie ses serveurs Vite de développement.
22
+ */
23
+ const MODULE_PROXY_PREFIXES = ["/_vite/"];
24
+ /** Jeton de méthode HTTP (RFC 9110 §9.1 → `token`, §5.6.2). */
25
+ const METHOD_TOKEN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
26
+ /** Nom de champ d'en-tête (RFC 9110 §5.1 → `token`). */
27
+ const HEADER_TOKEN = METHOD_TOKEN;
28
+ /**
29
+ * Normalise un préfixe d'URL : `/` en tête et en fin, sans `/` doublé. Règle
30
+ * UNIQUE des montages statiques et du proxy inverse — deux écritures du même
31
+ * préfixe doivent désigner la même entrée.
32
+ *
33
+ * @param prefix - préfixe tel qu'écrit (`"svc"`, `"/svc"`, `"/svc/"`)
34
+ * @returns le préfixe normalisé (`"/svc/"`)
35
+ */
36
+ function normalizePrefix(prefix) {
37
+ let p = prefix.trim();
38
+ if (!p.startsWith("/")) p = `/${p}`;
39
+ if (!p.endsWith("/")) p = `${p}/`;
40
+ return p.replace(/\/{2,}/g, "/");
41
+ }
42
+ /**
43
+ * Analyse une origine d'amont. Seule une origine NUE est acceptée : un chemin,
44
+ * une requête, un fragment ou des identifiants dans la cible déplaceraient en
45
+ * silence toutes les URL relayées, ou feraient voyager un secret dans la
46
+ * configuration.
47
+ *
48
+ * @param origin - `scheme://hôte[:port]`
49
+ * @returns l'URL analysée, ou `null` si ce n'est pas une origine http(s) nue
50
+ */
51
+ function parseProxyOrigin(origin) {
52
+ let url;
53
+ try {
54
+ url = new URL(origin);
55
+ } catch {
56
+ return null;
57
+ }
58
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
59
+ if (!/^https?:\/\/[^/?#]+\/?$/i.test(origin.trim())) return null;
60
+ if (url.search !== "" || url.hash !== "") return null;
61
+ if (url.username !== "" || url.password !== "") return null;
62
+ return url;
63
+ }
64
+ /**
65
+ * Examine un montage. Rend TOUS les refus, pas le premier : une configuration
66
+ * se corrige en une passe.
67
+ *
68
+ * @param prefix - préfixe tel qu'écrit
69
+ * @param mount - réglages du montage
70
+ * @param source - `"config"` (déclaré dans `proxy.mounts`) ou `"module"`
71
+ * (posé par un module) — seuls les préfixes réservés aux modules diffèrent
72
+ * @returns la liste des refus, vide si le montage est sain
73
+ */
74
+ function proxyMountProblems(prefix, mount, source) {
75
+ const problems = [];
76
+ const raw = prefix.trim();
77
+ const p = normalizePrefix(raw);
78
+ if (p === "/") problems.push({
79
+ field: "prefix",
80
+ message: "le préfixe « / » relaierait TOUTE l'application, routes et pare-feu compris — monter un préfixe dédié (`/svc/`)"
81
+ });
82
+ else if (/[?#\s\\%]/.test(raw) || p.split("/").some((s) => s === "." || s === "..")) problems.push({
83
+ field: "prefix",
84
+ message: `préfixe « ${raw} » invalide — un chemin littéral, sans « ? », « # », « % », « \\ », espace ni segment « . » / « .. »`
85
+ });
86
+ else if (RESERVED_PROXY_PREFIXES.some((r) => p.startsWith(r))) problems.push({
87
+ field: "prefix",
88
+ message: `le préfixe « ${p} » est réservé au framework (console d'administration)`
89
+ });
90
+ else if (source === "config" && MODULE_PROXY_PREFIXES.some((r) => p.startsWith(r))) problems.push({
91
+ field: "prefix",
92
+ message: `le préfixe « ${p} » est réservé à @nodefony/frontend (serveurs Vite de développement), qui le monte lui-même`
93
+ });
94
+ let target = null;
95
+ if (typeof mount.target === "string") {
96
+ target = parseProxyOrigin(mount.target);
97
+ if (target === null) problems.push({
98
+ field: "target",
99
+ message: `cible « ${mount.target} » invalide — attendu une origine http(s) nue (\`scheme://hôte[:port]\`), sans chemin, requête ni identifiants`
100
+ });
101
+ } else if (typeof mount.target !== "function") problems.push({
102
+ field: "target",
103
+ message: "cible absente — une origine `scheme://hôte[:port]`"
104
+ });
105
+ if (mount.secure === false && target !== null) {
106
+ if (target.protocol !== "https:") problems.push({
107
+ field: "secure",
108
+ message: "`secure: false` ne concerne qu'une cible https: — le retirer"
109
+ });
110
+ else if (!isLoopbackHostname(target.hostname)) problems.push({
111
+ field: "secure",
112
+ message: `\`secure: false\` refusé vers « ${target.hostname} » : sans vérification de certificat, n'importe qui sur le chemin réseau se fait passer pour l'amont. Réservé à la boucle locale — sinon, fournir l'autorité de certification de l'amont au processus (\`http.certificates.extraCaFiles\`, ou NODE_EXTRA_CA_CERTS)`
113
+ });
114
+ }
115
+ if (mount.methods !== void 0) {
116
+ if (mount.methods.length === 0) problems.push({
117
+ field: "methods",
118
+ message: "liste de méthodes vide : rien ne serait relayé — retirer `methods` pour toutes"
119
+ });
120
+ for (const m of mount.methods) if (!METHOD_TOKEN.test(m)) problems.push({
121
+ field: "methods",
122
+ message: `méthode « ${m} » invalide (RFC 9110 §9.1)`
123
+ });
124
+ }
125
+ for (const h of mount.stripHeaders ?? []) if (!HEADER_TOKEN.test(h)) problems.push({
126
+ field: "stripHeaders",
127
+ message: `nom d'en-tête « ${h} » invalide`
128
+ });
129
+ else if (h.toLowerCase() === "host") problems.push({
130
+ field: "stripHeaders",
131
+ message: "« host » ne se retire pas : il est toujours posé — voir `preserveHost`"
132
+ });
133
+ if (mount.timeoutMs !== void 0 && (!Number.isInteger(mount.timeoutMs) || mount.timeoutMs <= 0)) problems.push({
134
+ field: "timeoutMs",
135
+ message: `délai « ${mount.timeoutMs} » invalide — un entier de millisecondes > 0`
136
+ });
137
+ return problems;
138
+ }
139
+ /** Encodages qui changent le SENS d'un chemin selon qui le décode. */
140
+ const AMBIGUOUS_ENCODING = /%(?:2e|2f|5c|00|25)/i;
141
+ /**
142
+ * Le chemin d'une requête relayée est-il ambigu ? Un chemin que le proxy et
143
+ * l'amont pourraient lire différemment — segment `.`/`..`, barre inverse,
144
+ * octet nul, ou leur forme encodée (`%2e`, `%2f`, `%5c`, `%00`, `%25`) — est
145
+ * REFUSÉ (400), jamais normalisé en silence : c'est le défaut de Traefik, et
146
+ * la parade aux contournements de préfixe (`/svc/..%2fadmin`).
147
+ *
148
+ * @param url - cible brute de la requête (chemin + requête éventuelle)
149
+ * @returns `true` si le chemin doit être refusé
150
+ */
151
+ function isAmbiguousPath(url) {
152
+ const q = url.indexOf("?");
153
+ const path = q === -1 ? url : url.slice(0, q);
154
+ if (path.includes("\\") || path.includes("\0")) return true;
155
+ if (AMBIGUOUS_ENCODING.test(path)) return true;
156
+ for (const segment of path.split("/")) if (segment === "." || segment === "..") return true;
157
+ return false;
158
+ }
159
+ /** Clé de handshake : 16 octets en base64 (RFC 6455 §4.1, §4.2.1). */
160
+ const WS_KEY = /^[A-Za-z0-9+/]{22}==$/;
161
+ /**
162
+ * Un upgrade est-il un handshake WebSocket conforme (RFC 6455 §4.2.1) ? Seul
163
+ * un tel handshake est relayé : un `Upgrade` quelconque (`h2c`…) raccordé
164
+ * octet pour octet ouvrirait un tunnel que rien n'inspecte.
165
+ *
166
+ * @param method - méthode de la requête
167
+ * @param headers - en-têtes reçus
168
+ * @returns la raison du refus, ou `null` si le handshake est conforme
169
+ */
170
+ function websocketHandshakeProblem(method, headers) {
171
+ if (method !== "GET") return "méthode autre que GET";
172
+ const upgrade = headers.upgrade;
173
+ if (typeof upgrade !== "string" || upgrade.trim().toLowerCase() !== "websocket") return "Upgrade autre que websocket";
174
+ const connection = headers.connection;
175
+ if (!(Array.isArray(connection) ? connection.join(",") : connection ?? "").toLowerCase().split(",").map((t) => t.trim()).includes("upgrade")) return "Connection sans upgrade";
176
+ if (headers["sec-websocket-version"] !== "13") return "Sec-WebSocket-Version ≠ 13";
177
+ const key = headers["sec-websocket-key"];
178
+ if (typeof key !== "string" || !WS_KEY.test(key.trim())) return "Sec-WebSocket-Key invalide";
179
+ return null;
180
+ }
181
+ //#endregion
182
+ export { MODULE_PROXY_PREFIXES, RESERVED_PROXY_PREFIXES, isAmbiguousPath, normalizePrefix, parseProxyOrigin, proxyMountProblems, websocketHandshakeProblem };