@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
@@ -1,6 +1,7 @@
1
+ import { handshakeRefusalStatus } from "../src/context/websocket/WebsocketContext.js";
1
2
  import { computeDurationMs, severityFromStatus } from "./audit-logger.js";
2
3
  import { logColor } from "nodefony";
3
- //#region nodefony/service/pretty-request-logger.ts
4
+ // nodefony/service/pretty-request-logger.ts
4
5
  /**
5
6
  * Pretty single-line logger for dev (P3.2) — the biggest gain for humans.
6
7
  *
@@ -34,12 +35,13 @@ var PrettyRequestLogger = class {
34
35
  }
35
36
  renderWebsocket(context, error, acceptedProtocol) {
36
37
  const ctx = context;
37
- const status = ctx.response?.statusCode ?? (error ? 500 : null);
38
+ const refused = error ? handshakeRefusalStatus(context, error) : null;
39
+ const status = refused ?? ctx.response?.statusCode ?? (error ? 500 : null);
38
40
  const duration = computeDurationMs(ctx.phases);
39
41
  const proto = acceptedProtocol ? `[${acceptedProtocol}]` : "";
40
42
  return {
41
43
  text: `${logColor.cyan("WS ")} ${colorizeStatus(status)} ${ctx.url} ${logColor.blackBright(proto)} ${logColor.blackBright(formatDuration(duration))} ${logColor.blackBright(ctx.remoteAddress ?? "-")} ` + logColor.magenta(`[${shortId(ctx.requestId)}]`) + (error ? ` ${logColor.red(error.message)}` : ""),
42
- severity: error ? "ERROR" : "INFO",
44
+ severity: refused !== null ? severityFromStatus(refused) : error ? "ERROR" : "INFO",
43
45
  msgid: "req"
44
46
  };
45
47
  }
@@ -61,5 +63,4 @@ function formatDuration(ms) {
61
63
  function shortId(id) {
62
64
  return id.length > 8 ? id.slice(0, 8) : id;
63
65
  }
64
- //#endregion
65
66
  export { PrettyRequestLogger as default };
@@ -1,5 +1,15 @@
1
+ import { handshakeRefusalStatus } from "../src/context/websocket/WebsocketContext.js";
2
+ import { severityFromStatus } from "./audit-logger.js";
1
3
  import { logColor } from "nodefony";
2
- //#region nodefony/service/request-logger.ts
4
+ // nodefony/service/request-logger.ts
5
+ /**
6
+ * Statut de la réponse : le contrat ne type `response` qu'en `object` ; une
7
+ * annotation (vérifiée, pas une assertion) le lit — le reste passe par le contrat, que le
8
+ * compilateur surveille.
9
+ */
10
+ const statusOf = (context) => {
11
+ return context.response?.statusCode;
12
+ };
3
13
  /**
4
14
  * Default Nodefony per-request logger — preserves the legacy colored format.
5
15
  *
@@ -13,14 +23,13 @@ import { logColor } from "nodefony";
13
23
  */
14
24
  var DefaultRequestLogger = class {
15
25
  renderHttp(context, error) {
16
- const ctx = context;
17
- const txt = `${logColor.cyan("URL")} : ${ctx.url} ${logColor.cyan("FROM")} : ${ctx.remoteAddress} ${logColor.cyan("ORIGIN")} : ${ctx.originUrl?.host} ${logColor.cyan("ID")} : ${ctx.requestId}`;
18
- const err = error ?? ctx.error;
26
+ const txt = `${logColor.cyan("URL")} : ${context.url} ${logColor.cyan("FROM")} : ${context.remoteAddress} ${logColor.cyan("ORIGIN")} : ${context.originUrl?.host} ${logColor.cyan("ID")} : ${context.requestId}`;
27
+ const err = error ?? context.error;
19
28
  if (err) {
20
- const errCode = err.code ?? ctx.response?.statusCode ?? 500;
21
- const msgid = `${ctx.type} ${logColor.magenta(errCode)} ${logColor.red(ctx.method ?? "")}`;
29
+ const errCode = err.code ?? statusOf(context) ?? 500;
30
+ const msgid = `${context.serverType} ${logColor.magenta(errCode)} ${logColor.red(context.method ?? "")}`;
22
31
  return {
23
- text: ctx.kernel?.environment === "production" ? `${txt} ${err}` : `${txt}\n ${err}`,
32
+ text: context.kernel?.environment === "production" ? `${txt} ${err}` : `${txt}\n ${err}`,
24
33
  severity: "ERROR",
25
34
  msgid
26
35
  };
@@ -28,27 +37,26 @@ var DefaultRequestLogger = class {
28
37
  return {
29
38
  text: txt,
30
39
  severity: "INFO",
31
- msgid: `${ctx.type} ${logColor.magenta(ctx.response?.statusCode ?? "")} ${ctx.method}`
40
+ msgid: `${context.serverType} ${logColor.magenta(statusOf(context) ?? "")} ${context.method}`
32
41
  };
33
42
  }
34
43
  renderWebsocket(context, error, acceptedProtocol) {
35
- const ctx = context;
36
44
  if (error) {
37
- const errCode = error.code ?? ctx.response?.statusCode ?? 500;
38
- const msgid = `${ctx.type} ${logColor.magenta(errCode)} ${logColor.red(ctx.method ?? "")}`;
45
+ const refused = handshakeRefusalStatus(context, error);
46
+ const errCode = refused ?? error.code ?? statusOf(context) ?? 500;
47
+ const msgid = `${context.serverType} ${logColor.magenta(errCode)} ${logColor.red(context.method ?? "")}`;
39
48
  return {
40
- text: `${logColor.cyan("URL")} : ${ctx.url} ${logColor.cyan("FROM")} : ${ctx.remoteAddress} ${logColor.cyan("ORIGIN")} : ${ctx.originUrl?.host} ${logColor.cyan("ID")} : ${ctx.requestId}\n ` + error.toString(),
41
- severity: "ERROR",
49
+ text: `${logColor.cyan("URL")} : ${context.url} ${logColor.cyan("FROM")} : ${context.remoteAddress} ${logColor.cyan("ORIGIN")} : ${context.originUrl?.host} ${logColor.cyan("ID")} : ${context.requestId}\n ` + error.toString(),
50
+ severity: refused !== null ? severityFromStatus(refused) : "ERROR",
42
51
  msgid
43
52
  };
44
53
  }
45
- const msgid = `${ctx.type} ${logColor.magenta(ctx.response?.statusCode ?? "")} ${ctx.method}`;
54
+ const msgid = `${context.serverType} ${logColor.magenta(statusOf(context) ?? "")} ${context.method}`;
46
55
  return {
47
- text: `${logColor.cyan("URL")} : ${ctx.url} ${logColor.cyan("Accept-Protocol")} : ${acceptedProtocol || "*"} ${logColor.cyan("FROM")} : ${ctx.remoteAddress} ${logColor.cyan("ORIGIN")} : ${ctx.originUrl?.host} ${logColor.cyan("ID")} : ${ctx.requestId}`,
56
+ text: `${logColor.cyan("URL")} : ${context.url} ${logColor.cyan("Accept-Protocol")} : ${acceptedProtocol || "*"} ${logColor.cyan("FROM")} : ${context.remoteAddress} ${logColor.cyan("ORIGIN")} : ${context.originUrl?.host} ${logColor.cyan("ID")} : ${context.requestId}`,
48
57
  severity: "INFO",
49
58
  msgid
50
59
  };
51
60
  }
52
61
  };
53
- //#endregion
54
62
  export { DefaultRequestLogger as default };
@@ -0,0 +1,336 @@
1
+ import { isAmbiguousPath, normalizePrefix, parseProxyOrigin, proxyMountProblems, websocketHandshakeProblem } from "../src/proxy/rules.js";
2
+ import { requestAuthority } from "../src/context/transport.js";
3
+ import { forwardRequest, forwardUpgrade, refuseUpgrade } from "../src/proxy/forward.js";
4
+ import { Service } from "nodefony";
5
+ import http from "node:http";
6
+ import { randomBytes } from "node:crypto";
7
+ import https from "node:https";
8
+ import { TLSSocket } from "node:tls";
9
+ // nodefony/service/reverse-proxy.ts
10
+ /** Replis si la section `proxy` manque (module construit hors configuration). */
11
+ const DEFAULT_TIMEOUT_MS = 3e4;
12
+ const DEFAULT_CONNECT_TIMEOUT_MS = 5e3;
13
+ const DEFAULT_MAX_SOCKETS = 256;
14
+ /**
15
+ * Proxy inverse de Nodefony : relaie un préfixe d'URL vers un autre serveur
16
+ * HTTP — requêtes ET, sur demande, upgrades WebSocket — sur la MÊME origine
17
+ * que la page. Le client ne voit qu'un serveur, un certificat ; l'amont peut
18
+ * rester sur la boucle locale.
19
+ *
20
+ * Deux sources de montages, mêmes règles (`src/proxy/rules.ts`) : la section
21
+ * `proxy.mounts` de la configuration, lue à la construction, et
22
+ * {@link ReverseProxy.mount}, appelé par un module (`@nodefony/frontend` y
23
+ * relaie `/_vite/<famille>/` vers ses serveurs Vite de développement). Un
24
+ * module se résout ce service par NOM (`container.get("reverse-proxy")`) :
25
+ * il n'a pas à importer `@nodefony/http`.
26
+ *
27
+ * Consulté AVANT le routage : une route attrape-tout de l'application ne peut
28
+ * pas avaler un préfixe relayé. Sans montage, le pipeline ne paie qu'une
29
+ * lecture de champ (`mounts === null`) — c'est le cas de toute application
30
+ * qui ne s'en sert pas.
31
+ *
32
+ * Règles communes à tout montage : en-têtes de connexion jamais transmis
33
+ * (RFC 9110 §7.6.1), `X-Forwarded-For/-Proto/-Host` et `Via` posés par le
34
+ * proxy, Host hors `trustedHosts` jamais relayé (même barrière, même 421 que
35
+ * toute requête), amont injoignable → 502, amont muet → 504, boucle → 508.
36
+ */
37
+ var ReverseProxy = class extends Service {
38
+ module;
39
+ /**
40
+ * Montages actifs, du préfixe le PLUS LONG au plus court : le premier qui
41
+ * couvre une URL est le plus spécifique. `null` tant qu'il n'y en a aucun.
42
+ */
43
+ mounts = null;
44
+ /** Délai d'inactivité appliqué aux montages qui n'en fixent pas (`proxy.timeoutMs`). */
45
+ defaultTimeoutMs;
46
+ /** Délai d'établissement de la connexion amont (`proxy.connectTimeoutMs`). */
47
+ connectTimeoutMs;
48
+ /** Connexions simultanées par amont (`proxy.maxSockets`) : pool ET tunnels WebSocket. */
49
+ maxSockets;
50
+ /**
51
+ * Arrêt engagé ({@link closeAll}) : un relais encore en vol ne recrée plus
52
+ * de pool — il passerait après la fermeture et garderait ses sockets.
53
+ */
54
+ #closing = false;
55
+ /**
56
+ * Nom de CE processus dans `Via` (RFC 9110 §7.6.3) : tiré au hasard au
57
+ * démarrage, il ne dit rien de la machine et distingue deux instances —
58
+ * un message qui le porte déjà est une boucle (508).
59
+ */
60
+ pseudonym = `nodefony-${randomBytes(4).toString("hex")}`;
61
+ constructor(module) {
62
+ const container = module.container ?? void 0;
63
+ const event = container?.get("notificationsCenter");
64
+ super("reverse-proxy", container, event, {});
65
+ this.module = module;
66
+ const section = module.options.proxy;
67
+ this.defaultTimeoutMs = section?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
68
+ this.connectTimeoutMs = section?.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
69
+ this.maxSockets = section?.maxSockets ?? DEFAULT_MAX_SOCKETS;
70
+ this.kernel?.prependOnceListener("onTerminate", () => this.closeAll());
71
+ for (const [prefix, mount] of Object.entries(section?.mounts ?? {})) this.#add(prefix, mount, "config");
72
+ }
73
+ /**
74
+ * Relaie un préfixe d'URL vers un amont. Idempotent pour un même module :
75
+ * un même préfixe est remplacé. Un module qui recouvre un préfixe de la
76
+ * CONFIGURATION le remplace aussi, mais le dit — l'auteur de l'application
77
+ * doit pouvoir lire pourquoi son montage ne sert plus.
78
+ *
79
+ * @param prefix - préfixe d'URL (normalisé : `/` en tête et en fin)
80
+ * @param options - cible et réglages du montage
81
+ * @throws Error qui nomme chaque refus (préfixe `/` ou réservé, cible qui
82
+ * n'est pas une origine http(s) nue, `secure: false` hors boucle locale…)
83
+ */
84
+ mount(prefix, options) {
85
+ this.#add(prefix, options, "module");
86
+ }
87
+ #add(prefix, options, source) {
88
+ const problems = proxyMountProblems(prefix, options, source);
89
+ if (problems.length > 0) throw new Error(`reverse-proxy : montage « ${prefix} » refusé — ` + problems.map((p) => `${p.field} : ${p.message}`).join(" ; "));
90
+ const p = normalizePrefix(prefix);
91
+ const record = {
92
+ ...options,
93
+ prefix: p,
94
+ source,
95
+ lastOrigin: void 0,
96
+ lastUrl: void 0,
97
+ agent: null,
98
+ tunnels: 0
99
+ };
100
+ if (options.methods) record.methods = options.methods.map((m) => m.toUpperCase());
101
+ if (options.stripHeaders) record.stripHeaders = options.stripHeaders.map((h) => h.toLowerCase());
102
+ record.timeoutMs = options.timeoutMs ?? this.defaultTimeoutMs;
103
+ const mounts = this.mounts ??= [];
104
+ const i = mounts.findIndex((m) => m.prefix === p);
105
+ if (i >= 0) {
106
+ const previous = mounts[i];
107
+ if (previous?.source === "config" && source === "module") this.log(`le montage « ${p} » de la configuration est remplacé par un module`, "WARNING");
108
+ previous?.agent?.destroy();
109
+ mounts[i] = record;
110
+ } else {
111
+ mounts.push(record);
112
+ mounts.sort((a, b) => b.prefix.length - a.prefix.length);
113
+ }
114
+ this.log(`mount ${p} (${source})`, "DEBUG");
115
+ }
116
+ /**
117
+ * Retire un montage. Le dernier retiré rend `mounts` à `null` : le pipeline
118
+ * cesse alors de le consulter.
119
+ *
120
+ * @param prefix - préfixe tel que monté (normalisé de la même façon)
121
+ */
122
+ unmount(prefix) {
123
+ if (this.mounts === null) return;
124
+ const p = normalizePrefix(prefix);
125
+ const kept = [];
126
+ for (const m of this.mounts) if (m.prefix === p) m.agent?.destroy();
127
+ else kept.push(m);
128
+ this.mounts = kept.length > 0 ? kept : null;
129
+ }
130
+ /**
131
+ * Ferme tous les pools (arrêt du noyau). Les montages restent déclarés ; un
132
+ * relais encore en vol part ensuite sur une connexion à usage unique.
133
+ */
134
+ closeAll() {
135
+ this.#closing = true;
136
+ for (const m of this.mounts ?? []) {
137
+ m.agent?.destroy();
138
+ m.agent = null;
139
+ }
140
+ }
141
+ /**
142
+ * Montage le plus spécifique qui couvre une URL, s'il y en a un.
143
+ *
144
+ * @param url - cible brute de la requête (chemin + requête)
145
+ * @returns le montage au préfixe le plus long qui couvre l'URL
146
+ */
147
+ match(url) {
148
+ const mounts = this.mounts;
149
+ if (mounts === null || url === void 0) return void 0;
150
+ for (const m of mounts) if (url.startsWith(m.prefix)) return m;
151
+ }
152
+ /**
153
+ * Origine courante d'un montage, analysée — mise en cache tant que la cible
154
+ * rend la même chaîne.
155
+ *
156
+ * @returns l'URL de l'amont, ou `undefined` si rien n'est relayable
157
+ */
158
+ targetOf(mount) {
159
+ const origin = typeof mount.target === "string" ? mount.target : mount.target();
160
+ if (origin === void 0) return void 0;
161
+ if (origin === mount.lastOrigin) return mount.lastUrl;
162
+ const url = parseProxyOrigin(origin) ?? void 0;
163
+ if (url === void 0) this.log(`cible « ${origin} » invalide pour ${mount.prefix} — non relayé`, "WARNING");
164
+ mount.lastOrigin = origin;
165
+ mount.lastUrl = url;
166
+ return url;
167
+ }
168
+ /**
169
+ * Pool de connexions d'un montage, créé au premier relais et recréé si la
170
+ * cible change de scheme.
171
+ *
172
+ * Un `Agent` DÉDIÉ, jamais l'agent global : sous `NODE_USE_ENV_PROXY`, cas
173
+ * courant d'un poste d'entreprise, l'agent global envoie tout vers
174
+ * `HTTP_PROXY` — un amont sur la boucle locale serait demandé au proxy de
175
+ * l'entreprise. Un agent construit ici n'a pas de `proxyEnv` : il va droit
176
+ * à la cible. Keep-alive borné (`maxSockets`), `lifo` : le socket le plus
177
+ * récent, le moins susceptible d'avoir été fermé par l'amont.
178
+ */
179
+ agentFor(mount, target) {
180
+ if (this.#closing) return false;
181
+ const wantsTls = target.protocol === "https:";
182
+ const agent = mount.agent;
183
+ if (agent !== null && agent instanceof https.Agent === wantsTls) return agent;
184
+ agent?.destroy();
185
+ const options = {
186
+ keepAlive: true,
187
+ maxSockets: this.maxSockets,
188
+ maxFreeSockets: Math.min(32, this.maxSockets),
189
+ scheduling: "lifo"
190
+ };
191
+ mount.agent = wantsTls ? new https.Agent(options) : new http.Agent(options);
192
+ return mount.agent;
193
+ }
194
+ /** Réglages de relais d'un montage. */
195
+ relayFor(mount, target) {
196
+ return {
197
+ pseudonym: this.pseudonym,
198
+ connectTimeoutMs: this.connectTimeoutMs,
199
+ agent: this.agentFor(mount, target)
200
+ };
201
+ }
202
+ /** Règles du noyau HTTP (résolues par nom), `undefined` hors serveur. */
203
+ rules() {
204
+ return this.container?.get("HttpKernel") ?? void 0;
205
+ }
206
+ /** Ce que le proxy annonce à l'amont sur le client. */
207
+ fromOf(req, scheme, host) {
208
+ const address = req.socket.remoteAddress;
209
+ return {
210
+ remoteAddress: address,
211
+ scheme,
212
+ host,
213
+ trustedPeer: this.rules()?.getTrustProxyChecker().isTrusted(address) === true,
214
+ httpVersion: req.httpVersion
215
+ };
216
+ }
217
+ /**
218
+ * Relaie une requête HTTP si un montage la couvre — méthode comprise. Cible
219
+ * pas encore connue → `503` + `Retry-After`, sans aller jusqu'au routage.
220
+ *
221
+ * L'appelant a déjà tranché la barrière d'hôte : c'est le pipeline HTTP, qui
222
+ * la calcule pour toute requête (`Context.validDomain`). Un chemin ambigu
223
+ * sous un préfixe monté rend 400, sans contacter l'amont.
224
+ *
225
+ * ⚠️ Un préfixe monté est servi AVANT le pare-feu, la CSRF et les en-têtes
226
+ * de sécurité applicatifs : c'est une surface publique pour qui atteint
227
+ * Nodefony. L'amont porte sa propre politique d'accès.
228
+ *
229
+ * @param req - requête du client
230
+ * @param res - réponse au client
231
+ * @param scheme - scheme par lequel le client est arrivé
232
+ * @returns la promesse de fin d'échange, ou `undefined` si la requête n'est
233
+ * pas relayée (hors préfixe ou méthode non relayée : elle suit alors son
234
+ * chemin normal)
235
+ */
236
+ forward(req, res, scheme) {
237
+ const mount = this.match(req.url);
238
+ if (mount === void 0) return void 0;
239
+ const method = req.method ?? "GET";
240
+ if (mount.methods && !mount.methods.includes(method)) return void 0;
241
+ const target = this.targetOf(mount);
242
+ if (target === void 0) return answerPlain(res, 503, "Service Unavailable", "1");
243
+ if (isAmbiguousPath(req.url ?? "/")) return answerPlain(res, 400, "Bad Request");
244
+ const host = requestAuthority(req.headers);
245
+ return forwardRequest(req, res, mount, target, this.fromOf(req, scheme, host), this.relayFor(mount, target));
246
+ }
247
+ /**
248
+ * Relaie un upgrade WebSocket si un montage `websocket` couvre son URL.
249
+ * Appelé par le répartiteur d'`upgrade` des serveurs WebSocket, AVANT le
250
+ * serveur WebSocket de Nodefony.
251
+ *
252
+ * Contrôles, dans l'ordre, chacun avec la réponse que rendrait le serveur
253
+ * WebSocket de Nodefony : cible pas encore connue → 503 ; `Host` hors `trustedHosts` (si `domainCheck`) →
254
+ * 421 ; chemin ambigu ou handshake non conforme (RFC 6455 §4.2.1 — seul
255
+ * `Upgrade: websocket` est relayé, jamais un tunnel `h2c`) → 400 ;
256
+ * `Origin` refusée par la MÊME règle que le serveur WebSocket (anti-CSWSH,
257
+ * RFC 6455 §10.2) → 403 ; `maxSockets` tunnels déjà ouverts vers l'amont →
258
+ * 503 ; quotas par IP du noyau (débit de handshakes, connexions
259
+ * simultanées) → 429. Le socket est alors consommé.
260
+ *
261
+ * @returns `true` si le proxy a pris le socket en charge
262
+ */
263
+ handleUpgrade(req, socket, head) {
264
+ const mount = this.match(req.url);
265
+ if (mount?.websocket !== true) return false;
266
+ const target = this.targetOf(mount);
267
+ if (target === void 0) {
268
+ refuseUpgrade(socket, 503, "Service Unavailable");
269
+ return true;
270
+ }
271
+ const host = req.headers.host;
272
+ const hostname = hostnameOf(host);
273
+ const rules = this.rules();
274
+ if (this.kernel?.options.domainCheck && (hostname === null || rules?.isTrustedHostname(hostname) !== true)) {
275
+ refuseUpgrade(socket, 421, "Misdirected Request");
276
+ return true;
277
+ }
278
+ if (hostname === null || isAmbiguousPath(req.url ?? "/") || websocketHandshakeProblem(req.method, req.headers) !== null) {
279
+ refuseUpgrade(socket, 400, "Bad Request");
280
+ return true;
281
+ }
282
+ const secure = req.socket instanceof TLSSocket;
283
+ if (rules !== void 0 && !rules.isWebsocketOriginAllowed(req.headers.origin, hostname, secure)) {
284
+ refuseUpgrade(socket, 403, "Forbidden");
285
+ return true;
286
+ }
287
+ if (mount.tunnels >= this.maxSockets) {
288
+ refuseUpgrade(socket, 503, "Service Unavailable");
289
+ return true;
290
+ }
291
+ if (rules?.websocketQuotaRefusal(req, socket) != null) {
292
+ refuseUpgrade(socket, 429, "Too Many Requests");
293
+ return true;
294
+ }
295
+ mount.tunnels++;
296
+ socket.once("close", () => {
297
+ mount.tunnels--;
298
+ });
299
+ forwardUpgrade(req, socket, head, mount, target, this.fromOf(req, secure ? "https" : "http", host), this.relayFor(mount, target));
300
+ return true;
301
+ }
302
+ };
303
+ /**
304
+ * Nom d'hôte d'une autorité `Host`, sans port (`[::1]` garde ses crochets,
305
+ * comme `URL.hostname`).
306
+ *
307
+ * @returns le nom, ou `null` si l'autorité est absente ou invalide
308
+ */
309
+ /**
310
+ * Répond au client sans contacter l'amont : texte brut, jamais mis en cache.
311
+ *
312
+ * @param res - réponse au client (HTTP/1.1 ou HTTP/2, API de compatibilité)
313
+ * @param status - statut rendu
314
+ * @param body - corps texte
315
+ * @param retryAfter - valeur de `Retry-After` (secondes), si le refus est passager
316
+ * @returns une promesse déjà résolue — l'échange est terminé
317
+ */
318
+ function answerPlain(res, status, body, retryAfter) {
319
+ const out = res;
320
+ out.writeHead(status, {
321
+ "content-type": "text/plain; charset=utf-8",
322
+ "cache-control": "no-store",
323
+ ...retryAfter === void 0 ? {} : { "retry-after": retryAfter }
324
+ });
325
+ out.end(body);
326
+ return Promise.resolve();
327
+ }
328
+ function hostnameOf(host) {
329
+ if (!host) return null;
330
+ try {
331
+ return new URL(`http://${host}`).hostname;
332
+ } catch {
333
+ return null;
334
+ }
335
+ }
336
+ export { ReverseProxy as default };
@@ -1,4 +1,5 @@
1
- //#region nodefony/service/servers/clientError.ts
1
+ import { isPeerGoneError } from "../../src/context/websocket/Response.js";
2
+ // nodefony/service/servers/clientError.ts
2
3
  /**
3
4
  * Gère l'event `clientError` d'un `http.Server` / `https.Server`.
4
5
  *
@@ -16,5 +17,23 @@ function handleClientError(error, socket) {
16
17
  const statusLine = error.code === "HPE_HEADER_OVERFLOW" ? "431 Request Header Fields Too Large" : "400 Bad Request";
17
18
  socket.end(`HTTP/1.1 ${statusLine}\r\nConnection: close\r\n\r\n`);
18
19
  }
19
- //#endregion
20
- export { handleClientError };
20
+ /**
21
+ * Gère l'event `sessionError` d'un serveur HTTP/2 (h2c ou TLS) : un pair PARTI
22
+ * se tait, toute autre erreur est rapportée.
23
+ *
24
+ * Une session réinitialisée par le pair n'a rien à diagnostiquer. C'est la
25
+ * signature d'une sonde de port — `nodefony status`, `doctor`, le superviseur
26
+ * de développement, la sonde `tcpSocket` d'un orchestrateur — qui se connecte
27
+ * puis ferme sans lire le `SETTINGS` que le serveur envoie dès l'ouverture : le
28
+ * système ferme alors par un RST plutôt qu'un FIN, et Node lève `ECONNRESET`.
29
+ * La rapporter imprimait une pile entière par sonde. La règle « client parti »
30
+ * est celle des frames WebSocket ({@link isPeerGoneError}), pas une copie.
31
+ *
32
+ * @param error - erreur de session émise par `node:http2`
33
+ * @param report - rapporte une erreur réelle, à la sévérité choisie par le serveur
34
+ */
35
+ function handleSessionError(error, report) {
36
+ if (isPeerGoneError(error)) return;
37
+ report(error);
38
+ }
39
+ export { handleClientError, handleSessionError };
@@ -0,0 +1,126 @@
1
+ import { asServersConfig, configuredServerPort, isH2cEnabled } from "../../src/servers/kernelServers.js";
2
+ import __decorate from "../../../_virtual/_@oxc-project_runtime@0.155.0/helpers/esm/decorate.js";
3
+ import { handleSessionError } from "./clientError.js";
4
+ import { createDrainTerminator } from "./serverShutdown.js";
5
+ import { bindWithFallback, buildBindPlan, h2cPortConflict } from "../../src/servers/portBinder.js";
6
+ import __decorateParam from "../../../_virtual/_@oxc-project_runtime@0.155.0/helpers/esm/decorateParam.js";
7
+ import { http2ServerLimits, setHttp2SessionTimeout } from "../../src/servers/http2Limits.js";
8
+ import { BootConfigurationError, Inject, Service, nodefonyError } from "nodefony";
9
+ import http2 from "node:http2";
10
+ // nodefony/service/servers/server-h2c.ts
11
+ /**
12
+ * Serveur HTTP/2 en clair (h2c, connaissance préalable — RFC 9113 §3.3), sur un
13
+ * port DÉDIÉ (`servers.h2c`, 5153 par défaut), pour un proxy inverse qui parle
14
+ * HTTP/2 à ses amonts.
15
+ *
16
+ * Il partage le chemin HTTP/2 du 5152 — même `onHttpRequest`, mêmes limites
17
+ * anti-DoS (`http2ServerLimits`), même drain par GOAWAY — sans TLS : `serverType`
18
+ * vaut `"http"`, le schéma vient du transport ou d'un proxy de confiance, jamais
19
+ * du pseudo-en-tête `:scheme`. Le protocole du saut (`"h2c"`) se constate sur la
20
+ * requête (`transport.ts#alpnProtocolOf`).
21
+ *
22
+ * Hors de son périmètre, délibérément : HTTP/1.1 (refusé par la préface HTTP/2 —
23
+ * sondes et clients HTTP/1.1 restent sur le port HTTP), WebSocket (aucun CONNECT
24
+ * étendu, RFC 8441 : un proxy route `Upgrade: websocket` vers le port HTTP) et la
25
+ * bascule `Upgrade: h2c` (retirée par RFC 9113 §3.1).
26
+ */
27
+ let ServerH2c = class ServerH2c extends Service {
28
+ httpKernel;
29
+ module;
30
+ server = null;
31
+ httpTerminator = null;
32
+ port;
33
+ protocol = "2.0";
34
+ ready = false;
35
+ type = "http";
36
+ domain;
37
+ scheme = "http";
38
+ address = null;
39
+ family = null;
40
+ active = false;
41
+ infos = null;
42
+ constructor(module, httpKernel) {
43
+ super("server-h2c", module.container ?? void 0, module.notificationsCenter, module.config.http);
44
+ this.httpKernel = httpKernel;
45
+ this.module = module;
46
+ this.active = isH2cEnabled(asServersConfig(module.kernel?.options.servers)?.h2c);
47
+ this.port = configuredServerPort(asServersConfig(module.kernel?.options.servers), "h2c") ?? 0;
48
+ this.domain = this.module.kernel?.domain ?? null;
49
+ }
50
+ /**
51
+ * Crée et met en écoute le serveur h2c.
52
+ *
53
+ * @returns le serveur à l'écoute
54
+ * @throws BootConfigurationError si `servers.h2c.port` est celui du serveur
55
+ * HTTP ou HTTPS — un port partagé n'est pas un conflit à contourner
56
+ */
57
+ createServer() {
58
+ return new Promise((resolve, reject) => {
59
+ try {
60
+ if (!this.active) return reject(/* @__PURE__ */ new Error(`Server h2c is not allowed in config file`));
61
+ const domain = this.domain;
62
+ if (domain === null) return reject(/* @__PURE__ */ new Error(`Server h2c : aucun domaine d'écoute (kernel absent)`));
63
+ const servers = asServersConfig(this.module.kernel?.options.servers);
64
+ const conflict = h2cPortConflict(servers);
65
+ if (conflict !== null) return reject(new BootConfigurationError(`servers.h2c.port (${this.port}) est aussi celui de servers.${conflict}.port : HTTP/1.1 et h2c ne partagent pas un port. Choisir un port distinct pour servers.h2c.port.`));
66
+ if (!servers?.http) this.log("servers.h2c actif sans servers.http : les sondes de santé et les WebSocket (HTTP/1.1) n'ont plus de port où arriver.", "WARNING");
67
+ const server = http2.createServer(http2ServerLimits(this.module.config.http2));
68
+ this.server = server;
69
+ this.httpTerminator = createDrainTerminator(server, this.options.shutdownTimeout);
70
+ setHttp2SessionTimeout(server, this.options.timeout, () => {
71
+ this.fire("onTimeout", this);
72
+ });
73
+ server.on("request", (request, response) => {
74
+ this.httpKernel.onHttpRequest(request, response, this.type);
75
+ });
76
+ server.on("sessionError", (error) => {
77
+ handleSessionError(error, (e) => this.log(e, "DEBUG", "H2C sessionError"));
78
+ });
79
+ server.on("streamError", (error) => {
80
+ this.log(error, "ERROR", "H2C streamError");
81
+ });
82
+ this.module.fire("onCreateServer", this.type, this);
83
+ bindWithFallback(server, domain, buildBindPlan("h2c", servers, this.module.kernel?.environment)).then(({ address, shiftedFrom }) => {
84
+ this.infos = address;
85
+ this.port = address.port;
86
+ this.address = address.address;
87
+ this.family = familyOf(address);
88
+ if (shiftedFrom !== null) this.log(`Port ${shiftedFrom} déjà occupé → h2c écoute sur ${this.port}. Figer le port : servers.h2c.port ; échouer au lieu de glisser : servers.portPolicy = "strict".`, "WARNING");
89
+ this.ready = true;
90
+ this.module.fire("onServersReady", this.type, this);
91
+ server.on("error", (error) => {
92
+ this.log(new nodefonyError(error), "CRITIC");
93
+ });
94
+ resolve(server);
95
+ }).catch((e) => {
96
+ const error = e instanceof Error ? e : new Error(String(e));
97
+ this.log(new nodefonyError(error), "CRITIC");
98
+ server.close();
99
+ reject(error);
100
+ });
101
+ this.kernel?.once("onTerminate", async () => {
102
+ if (!this.server) return;
103
+ try {
104
+ await this.httpTerminator?.terminate();
105
+ this.log(`h2c SHUTDOWN PORT : ${this.port}`, "INFO");
106
+ } catch (e) {
107
+ this.log(e, "ERROR", "TERMINATE");
108
+ }
109
+ });
110
+ } catch (e) {
111
+ this.log(e, "CRITIC");
112
+ return reject(e instanceof Error ? e : new Error(String(e)));
113
+ }
114
+ });
115
+ }
116
+ showBanner() {
117
+ if (this.infos) this.log(`Server Listen on h2c://${this.infos.address}:${this.infos.port} Family: ${this.infos.family} Protocol : ${this.protocol}`);
118
+ }
119
+ };
120
+ ServerH2c = __decorate([__decorateParam(1, Inject("HttpKernel"))], ServerH2c);
121
+ /** Famille d'une adresse d'écoute, lue sans conversion (`AddressInfo.family` est une chaîne). */
122
+ function familyOf(address) {
123
+ return address.family === "IPv6" ? "IPv6" : "IPv4";
124
+ }
125
+ var server_h2c_default = ServerH2c;
126
+ export { server_h2c_default as default };
@@ -1,14 +1,12 @@
1
1
  import { asServersConfig, configuredServerPort } from "../../src/servers/kernelServers.js";
2
- import __decorateMetadata from "../../../_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js";
3
- import __decorate from "../../../_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorate.js";
4
- import http_kernel_default from "../http-kernel.js";
2
+ import __decorate from "../../../_virtual/_@oxc-project_runtime@0.155.0/helpers/esm/decorate.js";
5
3
  import { handleClientError } from "./clientError.js";
6
4
  import { createDrainTerminator } from "./serverShutdown.js";
7
5
  import { bindWithFallback, buildBindPlan } from "../../src/servers/portBinder.js";
8
- import __decorateParam from "../../../_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateParam.js";
9
- import { Module, Service, extend, inject, nodefonyError } from "nodefony";
6
+ import __decorateParam from "../../../_virtual/_@oxc-project_runtime@0.155.0/helpers/esm/decorateParam.js";
7
+ import { Inject, Service, extend, nodefonyError } from "nodefony";
10
8
  import http from "node:http";
11
- //#region nodefony/service/servers/server-http.ts
9
+ // nodefony/service/servers/server-http.ts
12
10
  let ServerHttp = class ServerHttp extends Service {
13
11
  httpKernel;
14
12
  module;
@@ -129,7 +127,6 @@ let ServerHttp = class ServerHttp extends Service {
129
127
  if (this.infos) this.log(`Server Listen on ${this.scheme}://${this.infos.address}:${this.infos.port} Family: ${this.infos.family} Protocol : ${this.protocol}`);
130
128
  }
131
129
  };
132
- ServerHttp = __decorate([__decorateParam(1, inject("HttpKernel")), __decorateMetadata("design:paramtypes", [typeof Module === "undefined" ? Object : Module, typeof http_kernel_default === "undefined" ? Object : http_kernel_default])], ServerHttp);
130
+ ServerHttp = __decorate([__decorateParam(1, Inject("HttpKernel"))], ServerHttp);
133
131
  var server_http_default = ServerHttp;
134
- //#endregion
135
132
  export { server_http_default as default };