@nodefony/http 10.0.0-beta.5 → 10.0.0-beta.7

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 (130) hide show
  1. package/README.md +24 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorateParam.js +1 -1
  4. package/dist/index.js +30 -26
  5. package/dist/nodefony/command/proxyGenerateCommand.js +2 -2
  6. package/dist/nodefony/config/config.js +29 -4
  7. package/dist/nodefony/grpc/GrpcCall.js +298 -0
  8. package/dist/nodefony/grpc/GrpcEndpoint.js +528 -0
  9. package/dist/nodefony/grpc/index.js +4 -0
  10. package/dist/nodefony/interfaces/IRpcCall.js +1 -0
  11. package/dist/nodefony/service/ProfilerAdminApi.js +5 -2
  12. package/dist/nodefony/service/audit-logger.js +52 -6
  13. package/dist/nodefony/service/error-renderer.js +47 -13
  14. package/dist/nodefony/service/http-kernel.js +306 -49
  15. package/dist/nodefony/service/pretty-request-logger.js +5 -3
  16. package/dist/nodefony/service/request-logger.js +21 -6
  17. package/dist/nodefony/service/reverse-proxy.js +8 -3
  18. package/dist/nodefony/service/servers/clientError.js +53 -4
  19. package/dist/nodefony/service/servers/server-h2c.js +6 -6
  20. package/dist/nodefony/service/servers/server-http.js +6 -4
  21. package/dist/nodefony/service/servers/server-https.js +14 -7
  22. package/dist/nodefony/service/servers/server-static.js +1 -6
  23. package/dist/nodefony/service/servers/server-websocket-secure.js +7 -11
  24. package/dist/nodefony/service/servers/server-websocket.js +7 -11
  25. package/dist/nodefony/service/servers/serverShutdown.js +43 -6
  26. package/dist/nodefony/service/sessions/sessions-service.js +7 -7
  27. package/dist/nodefony/service/upload/upload-service.js +2 -2
  28. package/dist/nodefony/src/assets/prebuiltUi.js +23 -5
  29. package/dist/nodefony/src/context/Context.js +12 -1
  30. package/dist/nodefony/src/context/http/ByteStream.js +364 -0
  31. package/dist/nodefony/src/context/http/HttpContext.js +139 -32
  32. package/dist/nodefony/src/context/http/Request.js +111 -22
  33. package/dist/nodefony/src/context/http/Response.js +459 -37
  34. package/dist/nodefony/src/context/http/SseStream.js +83 -148
  35. package/dist/nodefony/src/context/http/StreamLifecycle.js +582 -0
  36. package/dist/nodefony/src/context/http/parser.js +29 -11
  37. package/dist/nodefony/src/context/http/urlFastPath.js +80 -1
  38. package/dist/nodefony/src/context/http/webResponse.js +56 -0
  39. package/dist/nodefony/src/context/http2/Request.js +1 -1
  40. package/dist/nodefony/src/context/http2/Response.js +55 -12
  41. package/dist/nodefony/src/context/requestTrace.js +6 -2
  42. package/dist/nodefony/src/context/rpcOutcome.js +20 -0
  43. package/dist/nodefony/src/context/transport.js +20 -1
  44. package/dist/nodefony/src/context/websocket/Response.js +15 -3
  45. package/dist/nodefony/src/context/websocket/WebsocketContext.js +86 -18
  46. package/dist/nodefony/src/context/websocket/websocketUrl.js +7 -0
  47. package/dist/nodefony/src/context/websocket/wsBackpressure.js +15 -15
  48. package/dist/nodefony/src/context/websocket/wsFrameRate.js +71 -0
  49. package/dist/nodefony/src/context/websocket/wsLogContent.js +55 -17
  50. package/dist/nodefony/src/cookies/cookie.js +57 -14
  51. package/dist/nodefony/src/errors/httpError.js +58 -25
  52. package/dist/nodefony/src/grpc/cors.js +59 -0
  53. package/dist/nodefony/src/grpc/limits.js +16 -0
  54. package/dist/nodefony/src/grpc/refusal.js +271 -0
  55. package/dist/nodefony/src/profiler/Profiler.js +14 -1
  56. package/dist/nodefony/src/profiler/aggregate.js +2 -2
  57. package/dist/nodefony/src/proxy/forward.js +109 -9
  58. package/dist/nodefony/src/proxy/generateProxyConfig.js +97 -17
  59. package/dist/nodefony/src/proxy/rules.js +29 -14
  60. package/dist/nodefony/src/servers/http2Goaway.js +76 -0
  61. package/dist/nodefony/src/servers/http2Limits.js +41 -15
  62. package/dist/nodefony/src/servers/http2StreamGrace.js +100 -0
  63. package/dist/nodefony/src/servers/receiveDeadline.js +177 -0
  64. package/dist/nodefony/src/servers/upgradeDispatch.js +62 -4
  65. package/dist/types/index.d.ts +11 -2
  66. package/dist/types/nodefony/config/config.d.ts +14 -0
  67. package/dist/types/nodefony/grpc/GrpcCall.d.ts +142 -0
  68. package/dist/types/nodefony/grpc/GrpcEndpoint.d.ts +98 -0
  69. package/dist/types/nodefony/grpc/index.d.ts +15 -0
  70. package/dist/types/nodefony/interfaces/IContext.d.ts +9 -0
  71. package/dist/types/nodefony/interfaces/IErrorRenderer.d.ts +14 -0
  72. package/dist/types/nodefony/interfaces/IRequest.d.ts +2 -1
  73. package/dist/types/nodefony/interfaces/IRequestTrace.d.ts +10 -0
  74. package/dist/types/nodefony/interfaces/IRouting.d.ts +19 -0
  75. package/dist/types/nodefony/interfaces/IRpcCall.d.ts +83 -0
  76. package/dist/types/nodefony/interfaces/ISecurity.d.ts +27 -2
  77. package/dist/types/nodefony/interfaces/IUpload.d.ts +51 -4
  78. package/dist/types/nodefony/interfaces/index.d.ts +2 -1
  79. package/dist/types/nodefony/service/ProfilerAdminApi.d.ts +2 -1
  80. package/dist/types/nodefony/service/audit-logger.d.ts +33 -3
  81. package/dist/types/nodefony/service/error-renderer.d.ts +27 -5
  82. package/dist/types/nodefony/service/http-kernel.d.ts +36 -3
  83. package/dist/types/nodefony/service/request-logger.d.ts +10 -0
  84. package/dist/types/nodefony/service/servers/clientError.d.ts +3 -2
  85. package/dist/types/nodefony/service/servers/serverShutdown.d.ts +7 -5
  86. package/dist/types/nodefony/src/assets/prebuiltUi.d.ts +9 -4
  87. package/dist/types/nodefony/src/context/Context.d.ts +9 -1
  88. package/dist/types/nodefony/src/context/http/ByteStream.d.ts +109 -0
  89. package/dist/types/nodefony/src/context/http/HttpContext.d.ts +99 -3
  90. package/dist/types/nodefony/src/context/http/Request.d.ts +46 -9
  91. package/dist/types/nodefony/src/context/http/Response.d.ts +188 -8
  92. package/dist/types/nodefony/src/context/http/SseStream.d.ts +33 -8
  93. package/dist/types/nodefony/src/context/http/StreamLifecycle.d.ts +106 -0
  94. package/dist/types/nodefony/src/context/http/parser.d.ts +17 -3
  95. package/dist/types/nodefony/src/context/http/urlFastPath.d.ts +40 -0
  96. package/dist/types/nodefony/src/context/http/webResponse.d.ts +31 -0
  97. package/dist/types/nodefony/src/context/http2/Response.d.ts +40 -5
  98. package/dist/types/nodefony/src/context/requestTrace.d.ts +3 -1
  99. package/dist/types/nodefony/src/context/rpcOutcome.d.ts +11 -0
  100. package/dist/types/nodefony/src/context/transport.d.ts +17 -0
  101. package/dist/types/nodefony/src/context/websocket/Response.d.ts +6 -1
  102. package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +48 -0
  103. package/dist/types/nodefony/src/context/websocket/websocketUrl.d.ts +4 -0
  104. package/dist/types/nodefony/src/context/websocket/wsBackpressure.d.ts +12 -2
  105. package/dist/types/nodefony/src/context/websocket/wsFrameRate.d.ts +69 -0
  106. package/dist/types/nodefony/src/context/websocket/wsLogContent.d.ts +16 -8
  107. package/dist/types/nodefony/src/cookies/cookie.d.ts +20 -2
  108. package/dist/types/nodefony/src/errors/httpError.d.ts +29 -4
  109. package/dist/types/nodefony/src/grpc/cors.d.ts +32 -0
  110. package/dist/types/nodefony/src/grpc/limits.d.ts +14 -0
  111. package/dist/types/nodefony/src/grpc/refusal.d.ts +136 -0
  112. package/dist/types/nodefony/src/profiler/Profiler.d.ts +11 -1
  113. package/dist/types/nodefony/src/proxy/forward.d.ts +37 -2
  114. package/dist/types/nodefony/src/proxy/generateProxyConfig.d.ts +15 -2
  115. package/dist/types/nodefony/src/proxy/rules.d.ts +20 -0
  116. package/dist/types/nodefony/src/servers/http2Goaway.d.ts +41 -0
  117. package/dist/types/nodefony/src/servers/http2Limits.d.ts +27 -14
  118. package/dist/types/nodefony/src/servers/http2StreamGrace.d.ts +34 -0
  119. package/dist/types/nodefony/src/servers/receiveDeadline.d.ts +77 -0
  120. package/dist/types/nodefony/src/servers/upgradeDispatch.d.ts +41 -1
  121. package/docs/byte-stream.md +406 -0
  122. package/docs/cookies.md +13 -3
  123. package/docs/diagnostics-channel.md +45 -1
  124. package/docs/index.md +8 -0
  125. package/docs/reverse-proxy.md +15 -3
  126. package/docs/servers.md +67 -43
  127. package/docs/session.md +3 -3
  128. package/docs/sse.md +27 -20
  129. package/docs/upload.md +43 -17
  130. package/package.json +38 -18
@@ -0,0 +1,271 @@
1
+ // nodefony/src/grpc/refusal.ts
2
+ /**
3
+ * Refus d'un appel gRPC au format de son protocole (ADR-0026 D5 point 2, D13
4
+ * point 2). Sans Connect : une application sans service gRPC charge ce module
5
+ * avec le noyau, et n'y paie rien tant qu'aucune erreur n'est rendue.
6
+ *
7
+ * Le protocole se reconnaît aux en-têtes de la requête et se code en petit
8
+ * entier (`GRPC_WIRE_*`, 0 = aucun) : une valeur toujours du même type, lue
9
+ * sur le chemin d'erreur seulement.
10
+ */
11
+ /** Requête qui ne parle aucun protocole gRPC. */
12
+ const GRPC_WIRE_NONE = 0;
13
+ /** gRPC natif (`application/grpc`, `application/grpc+proto`…). */
14
+ const GRPC_WIRE_GRPC = 1;
15
+ /** gRPC-Web (`application/grpc-web`, `application/grpc-web-text`…). */
16
+ const GRPC_WIRE_WEB = 2;
17
+ /** Connect unaire (`connect-protocol-version`, ou GET `?connect=v1`). */
18
+ const GRPC_WIRE_CONNECT = 3;
19
+ /** Connect en flux (`application/connect+proto`, `application/connect+json`). */
20
+ const GRPC_WIRE_CONNECT_STREAM = 4;
21
+ /** `"application/".length` : le sous-type commence là. */
22
+ const SUBTYPE = 12;
23
+ const PLUS = 43;
24
+ const SEMICOLON = 59;
25
+ const SPACE = 32;
26
+ const HYPHEN = 45;
27
+ /** Le sous-type s'arrête ici : fin, suffixe `+codec`, paramètre, espace. */
28
+ function endsSubtype(code) {
29
+ return Number.isNaN(code) || code === PLUS || code === SEMICOLON || code === SPACE;
30
+ }
31
+ /**
32
+ * Reconnaît le protocole gRPC d'une requête à ses en-têtes : `content-type`
33
+ * d'abord, puis `connect-protocol-version`. Une API JSON ordinaire rend
34
+ * `GRPC_WIRE_NONE`.
35
+ *
36
+ * @param headers - les en-têtes de la requête.
37
+ * @param connectGet - `true` pour un GET qui porte `connect=v1` (Connect
38
+ * unaire en GET, sans en-tête propre).
39
+ * @returns le protocole reconnu.
40
+ */
41
+ function grpcWireOf(headers, connectGet = false) {
42
+ const type = headers["content-type"];
43
+ if (type?.startsWith("application/") === true) {
44
+ if (type.startsWith("grpc", SUBTYPE)) {
45
+ const next = type.charCodeAt(16);
46
+ if (endsSubtype(next)) return 1;
47
+ if (next === HYPHEN && type.startsWith("-web", 16)) {
48
+ const after = type.charCodeAt(20);
49
+ if (endsSubtype(after) || after === HYPHEN) return 2;
50
+ }
51
+ return 0;
52
+ }
53
+ if (type.startsWith("connect+", SUBTYPE)) return 4;
54
+ }
55
+ if (headers["connect-protocol-version"] !== void 0 || connectGet) return 3;
56
+ return 0;
57
+ }
58
+ /**
59
+ * Noms des codes gRPC (`google-rpc-code.proto`), en minuscules comme Connect
60
+ * les écrit, indexés par le code : un accès direct, aucune table de hachage.
61
+ */
62
+ const CODE_NAMES = Object.freeze([
63
+ "ok",
64
+ "cancelled",
65
+ "unknown",
66
+ "invalid_argument",
67
+ "deadline_exceeded",
68
+ "not_found",
69
+ "already_exists",
70
+ "permission_denied",
71
+ "resource_exhausted",
72
+ "failed_precondition",
73
+ "aborted",
74
+ "out_of_range",
75
+ "unimplemented",
76
+ "internal",
77
+ "unavailable",
78
+ "data_loss",
79
+ "unauthenticated"
80
+ ]);
81
+ /** Les codes en texte décimal, pour l'en-tête `grpc-status` : aucune conversion par refus. */
82
+ const CODE_TEXTS = Object.freeze(CODE_NAMES.map((_name, code) => String(code)));
83
+ /** 16 `UNAUTHENTICATED`. */
84
+ const GRPC_UNAUTHENTICATED = 16;
85
+ /** 7 `PERMISSION_DENIED`. */
86
+ const GRPC_PERMISSION_DENIED = 7;
87
+ /**
88
+ * Le nom d'un code gRPC (`unimplemented`, `unauthenticated`…) — le libellé
89
+ * générique d'un refus (ADR-0026 D5 point 4).
90
+ *
91
+ * @param code - le code gRPC, de 0 à 16.
92
+ * @returns son nom ; `unknown` hors de la plage.
93
+ */
94
+ function grpcCodeName(code) {
95
+ return CODE_NAMES[code] ?? "unknown";
96
+ }
97
+ /**
98
+ * Les mêmes noms tels que les écrivent `google/rpc/code.proto` et les
99
+ * conventions OpenTelemetry (`rpc.status_code`) : `OK`, `UNAVAILABLE`… Tirés
100
+ * une fois, au chargement, de {@link CODE_NAMES} — une seule liste.
101
+ */
102
+ const STATUS_NAMES = Object.freeze(CODE_NAMES.map((name) => name.toUpperCase()));
103
+ /**
104
+ * Le nom d'un code gRPC tel que le publient la ligne d'accès et le canal de
105
+ * traçage (`UNAVAILABLE`) — ADR-0026 D10 point 1.
106
+ *
107
+ * @param code - le code gRPC, de 0 à 16.
108
+ * @returns son nom ; `UNKNOWN` hors de la plage.
109
+ */
110
+ function grpcStatusName(code) {
111
+ return STATUS_NAMES[code] ?? "UNKNOWN";
112
+ }
113
+ /**
114
+ * Le code gRPC d'un refus de transport — rendu avant l'implémentation (chemin
115
+ * inconnu, méthode refusée, limite de débit, pare-feu) — en gRPC natif et en
116
+ * gRPC-Web (ADR-0026 D13 point 2). Distincte de la table des erreurs de
117
+ * l'application (D7) ; seule la règle d'identité de 401/403 est commune (D5).
118
+ *
119
+ * @param status - le statut HTTP que le noyau aurait rendu.
120
+ * @param identified - un appelant authentifié est connu.
121
+ * @returns le code gRPC.
122
+ */
123
+ function transportRefusalCode(status, identified) {
124
+ switch (status) {
125
+ case 400: return 13;
126
+ case 401:
127
+ case 403: return identified ? 7 : 16;
128
+ case 404:
129
+ case 405: return 12;
130
+ case 421:
131
+ case 429:
132
+ case 502:
133
+ case 503:
134
+ case 504: return 14;
135
+ default: return 2;
136
+ }
137
+ }
138
+ /**
139
+ * Le code gRPC d'une erreur levée par l'APPLICATION pendant un appel — une
140
+ * `HttpError` d'un service partagé avec des routes HTTP, une validation, un
141
+ * conflit (ADR-0026 D7, table « HTTP Mapping » de `google/rpc/code.proto`).
142
+ * Distincte de la table des refus de transport ({@link transportRefusalCode}) :
143
+ * un 404 levé par un service dit « entité introuvable » (5), un chemin inconnu
144
+ * dit « méthode absente » (12). Seule la règle d'identité de 401/403 est
145
+ * commune (D5).
146
+ *
147
+ * `code.proto` associe 409 à `ABORTED` et à `ALREADY_EXISTS` : `ABORTED`, le
148
+ * sens le plus large, est retenu ; `ALREADY_EXISTS` se lève en `ConnectError`.
149
+ *
150
+ * @param status - le statut HTTP que rendrait une route HTTP.
151
+ * @param identified - un appelant authentifié est connu.
152
+ * @returns le code gRPC.
153
+ */
154
+ function applicationErrorCode(status, identified) {
155
+ switch (status) {
156
+ case 400:
157
+ case 422: return 3;
158
+ case 401:
159
+ case 403: return identified ? 7 : 16;
160
+ case 404: return 5;
161
+ case 409: return 10;
162
+ case 412: return 9;
163
+ case 413:
164
+ case 429: return 8;
165
+ case 499: return 1;
166
+ case 501: return 12;
167
+ case 502:
168
+ case 503: return 14;
169
+ case 408:
170
+ case 504: return 4;
171
+ default: return status >= 500 && status <= 599 ? 13 : 2;
172
+ }
173
+ }
174
+ /**
175
+ * Le code d'un refus en protocole Connect : celui qu'un client DÉDUIT du
176
+ * statut HTTP (`connect-protocol.md`, « HTTP to Error Code »), puisque le
177
+ * statut voyage avec la réponse et doit s'accorder au code. 405 et 421 y
178
+ * valent donc `unknown` (ADR-0026 D13, précision du lot 3) ; 401/403 suivent
179
+ * la règle d'identité (D5).
180
+ *
181
+ * @param status - le statut HTTP du refus.
182
+ * @param identified - un appelant authentifié est connu.
183
+ * @returns le code gRPC.
184
+ */
185
+ function connectRefusalCode(status, identified) {
186
+ switch (status) {
187
+ case 400: return 13;
188
+ case 401:
189
+ case 403: return identified ? 7 : 16;
190
+ case 404: return 12;
191
+ case 429:
192
+ case 502:
193
+ case 503:
194
+ case 504: return 14;
195
+ default: return 2;
196
+ }
197
+ }
198
+ /** Drapeau d'un message de fin de flux Connect (`connect-protocol.md`). */
199
+ const CONNECT_END_STREAM = 2;
200
+ /**
201
+ * Pose le statut et les en-têtes d'un refus au format du protocole, et rend
202
+ * le corps à envoyer — vide en gRPC et gRPC-Web, dont le statut voyage dans
203
+ * les en-têtes (_Trailers-Only_ ; en HTTP/2, `Http2Response.writeHead` termine
204
+ * alors le flux sur la trame d'en-têtes).
205
+ *
206
+ * | Protocole | Statut HTTP | Corps |
207
+ * | --------------- | ------------------------------ | -------------------------------------- |
208
+ * | gRPC, gRPC-Web | 200 | aucun |
209
+ * | Connect unaire | celui du refus (401/403 : D5) | `{"code":"<nom>"}` |
210
+ * | Connect en flux | 200 | message de fin `{"error":{…}}` |
211
+ *
212
+ * Le message n'est jamais celui de l'erreur, qui nomme parfois la zone
213
+ * refusée : seul le nom du code part (D5 point 4).
214
+ *
215
+ * @param response - la réponse du contexte, pas encore envoyée.
216
+ * @param wire - le protocole de la requête, autre que `GRPC_WIRE_NONE`.
217
+ * @param status - le statut HTTP que le noyau aurait rendu.
218
+ * @param identified - un appelant authentifié est connu.
219
+ * @param contentType - le `content-type` de la requête, recopié en gRPC,
220
+ * gRPC-Web et Connect en flux (déjà reconnu par {@link grpcWireOf}).
221
+ * @returns le corps : texte vide, JSON, ou octets d'un message de fin.
222
+ */
223
+ function prepareGrpcRefusal(response, wire, status, identified, contentType) {
224
+ if (wire === 3) {
225
+ const code = connectRefusalCode(status, identified);
226
+ const connectStatus = code === 16 ? 401 : code === 7 ? 403 : status;
227
+ response.setStatusCode(connectStatus);
228
+ response.setHeader("content-type", "application/json");
229
+ return `{"code":"${grpcCodeName(code)}"}`;
230
+ }
231
+ response.setStatusCode(200, "OK");
232
+ if (contentType !== void 0) response.setHeader("content-type", contentType);
233
+ if (wire === 4) return connectEndStream(`{"error":{"code":"${grpcCodeName(connectRefusalCode(status, identified))}"}}`);
234
+ const code = transportRefusalCode(status, identified);
235
+ response.setHeader("grpc-status", CODE_TEXTS[code] ?? "2");
236
+ response.setHeader("grpc-message", grpcCodeName(code));
237
+ return "";
238
+ }
239
+ /**
240
+ * Un message de fin de flux Connect : drapeau, longueur (4 octets, gros
241
+ * boutiste), puis le JSON. Le JSON n'est fait que de noms de codes ASCII :
242
+ * un octet par caractère, aucun encodeur.
243
+ */
244
+ function connectEndStream(json) {
245
+ const length = json.length;
246
+ const frame = new Uint8Array(5 + length);
247
+ frame[0] = CONNECT_END_STREAM;
248
+ new DataView(frame.buffer).setUint32(1, length);
249
+ for (let i = 0; i < length; i++) frame[5 + i] = json.charCodeAt(i);
250
+ return frame;
251
+ }
252
+ /** Au moins une route gRPC est déclarée dans le processus. */
253
+ let grpcRoutesDeclared = false;
254
+ /**
255
+ * Marque une route comme servie par un point d'entrée gRPC — au démarrage,
256
+ * par `@nodefony/framework/grpc`. Allume la garde qui refuse en 12 une requête
257
+ * gRPC native aboutie à une route qui n'est pas gRPC (ADR-0026 D13 point 3) :
258
+ * tant qu'aucune route n'est marquée, la garde coûte une lecture de booléen.
259
+ *
260
+ * @param route - la route montée pour une méthode du contrat.
261
+ * @experimental Surface gRPC, ADR-0026 D11.
262
+ */
263
+ function markGrpcRoute(route) {
264
+ route.grpc = true;
265
+ grpcRoutesDeclared = true;
266
+ }
267
+ /** `true` dès qu'une route gRPC est déclarée dans le processus. */
268
+ function hasGrpcRoutes() {
269
+ return grpcRoutesDeclared;
270
+ }
271
+ export { GRPC_PERMISSION_DENIED, GRPC_UNAUTHENTICATED, GRPC_WIRE_CONNECT, GRPC_WIRE_CONNECT_STREAM, GRPC_WIRE_GRPC, GRPC_WIRE_NONE, GRPC_WIRE_WEB, applicationErrorCode, connectRefusalCode, grpcCodeName, grpcStatusName, grpcWireOf, hasGrpcRoutes, markGrpcRoute, prepareGrpcRefusal, transportRefusalCode };
@@ -31,6 +31,14 @@ var Profiler = class {
31
31
  /** Dernier numéro d'ordre attribué (0 = aucun profil collecté). */
32
32
  _seq = 0;
33
33
  /**
34
+ * Démarrage du profileur, donc du processus (ms epoch, horloge du serveur) :
35
+ * aucune requête servie avant n'est connue. Rendu avec un profil absent, pour
36
+ * que le lecteur dise « le serveur a redémarré » plutôt que « évincé ».
37
+ */
38
+ startedAt = Date.now();
39
+ /** Dernier vidage des anneaux (ms epoch), `null` s'il n'y en a jamais eu. */
40
+ _clearedAt = null;
41
+ /**
34
42
  * @param cap - capacité de l'anneau de l'application.
35
43
  * @param sideCap - capacité de chacun des anneaux de l'outillage et des
36
44
  * assets relayés.
@@ -151,12 +159,17 @@ var Profiler = class {
151
159
  const { app, tooling, asset } = this._lanes;
152
160
  return app.size + tooling.size + asset.size;
153
161
  }
154
- /** Vide les anneaux. */
162
+ /** Vide les anneaux, et retient l'instant du vidage ({@link clearedAt}). */
155
163
  clear() {
156
164
  const { app, tooling, asset } = this._lanes;
157
165
  app.clear();
158
166
  tooling.clear();
159
167
  asset.clear();
168
+ this._clearedAt = Date.now();
169
+ }
170
+ /** Dernier vidage des anneaux (ms epoch, horloge du serveur), `null` si aucun. */
171
+ get clearedAt() {
172
+ return this._clearedAt;
160
173
  }
161
174
  /**
162
175
  * Profils de numéro supérieur à `from`, tous anneaux confondus, du plus
@@ -121,8 +121,8 @@ function actionSelfMs(entry) {
121
121
  }
122
122
  inner.sort((a, b) => a[0] - b[0]);
123
123
  let covered = 0;
124
- let curStart = -Infinity;
125
- let curEnd = -Infinity;
124
+ let curStart = -1e400;
125
+ let curEnd = -1e400;
126
126
  for (const [s, e] of inner) if (s > curEnd) {
127
127
  if (curEnd > curStart) covered += curEnd - curStart;
128
128
  curStart = s;
@@ -1,3 +1,4 @@
1
+ import { isUnsupportedTransferCoding } from "./rules.js";
1
2
  import http from "node:http";
2
3
  import https from "node:https";
3
4
  // nodefony/src/proxy/forward.ts
@@ -83,6 +84,71 @@ function viaLoops(via, pseudonym) {
83
84
  for (const hop of hops) if (hop.trim().split(/\s+/)[1] === pseudonym) return true;
84
85
  return false;
85
86
  }
87
+ /** `1*DIGIT` (RFC 9110 §7.6.2) — compilé une fois. */
88
+ const DIGITS = /^[0-9]+$/;
89
+ /**
90
+ * `Max-Forwards` reçu, tel qu'un intermédiaire doit le lire (RFC 9110 §7.6.2) :
91
+ * sur `OPTIONS` et `TRACE` seulement — la même section permet de l'ignorer
92
+ * pour toute autre méthode, et il part alors tel quel.
93
+ *
94
+ * @param method - méthode de la requête
95
+ * @param value - en-tête `Max-Forwards` reçu
96
+ * @returns `null` s'il n'y a rien à faire (autre méthode, en-tête absent) ;
97
+ * `-1` si la valeur n'est pas `1*DIGIT` — elle ne se décrémente pas, l'en-tête
98
+ * n'est donc pas transmis ; sinon la valeur reçue (`0` : le proxy répond
99
+ * lui-même, sans joindre l'amont).
100
+ */
101
+ function receivedMaxForwards(method, value) {
102
+ if (value === void 0 || method !== "OPTIONS" && method !== "TRACE") return null;
103
+ const raw = (Array.isArray(value) ? value[0] : value)?.trim() ?? "";
104
+ return DIGITS.test(raw) ? Number(raw) : -1;
105
+ }
106
+ /**
107
+ * En-têtes qu'une réponse à `TRACE` ne renvoie pas : ils portent des secrets
108
+ * (RFC 9110 §9.3.8 : le destinataire final DEVRAIT exclure les champs
109
+ * susceptibles de contenir des données sensibles).
110
+ */
111
+ const TRACE_SENSITIVE = /^(?:authorization|proxy-authorization|cookie)$|token|secret|session|api-?key|csrf/i;
112
+ /**
113
+ * Corps `message/http` d'une réponse à `TRACE` : la requête reçue, en-têtes
114
+ * sensibles et pseudo-en-têtes HTTP/2 retirés (RFC 9110 §9.3.8).
115
+ */
116
+ function traceEcho(req) {
117
+ let echo = `TRACE ${req.url ?? "/"} HTTP/${req.httpVersion}\r\n`;
118
+ const authority = req.headers[":authority"];
119
+ if (req.headers.host === void 0 && typeof authority === "string") echo += `host: ${authority}\r\n`;
120
+ for (const name of Object.keys(req.headers)) {
121
+ if (name.charCodeAt(0) === 58 || TRACE_SENSITIVE.test(name)) continue;
122
+ echo += `${name}: ${flat(req.headers[name])}\r\n`;
123
+ }
124
+ return `${echo}\r\n`;
125
+ }
126
+ /**
127
+ * Réponse du proxy en DESTINATAIRE FINAL, quand `Max-Forwards` est épuisé
128
+ * (RFC 9110 §7.6.2) : `TRACE` renvoie la requête reçue (§9.3.8), `OPTIONS`
129
+ * rend 200 avec `Allow` si le montage restreint ses méthodes, et
130
+ * `Content-Length: 0` (§9.3.7). L'amont n'est jamais joint.
131
+ */
132
+ function answerAsFinalRecipient(req, out, mount) {
133
+ req.resume();
134
+ if (req.method === "TRACE") {
135
+ const body = traceEcho(req);
136
+ out.writeHead(200, {
137
+ "content-type": "message/http",
138
+ "content-length": Buffer.byteLength(body),
139
+ "cache-control": "no-store"
140
+ });
141
+ out.end(body);
142
+ return;
143
+ }
144
+ const headers = {
145
+ "content-length": 0,
146
+ "cache-control": "no-store"
147
+ };
148
+ if (mount.methods !== void 0) headers.allow = mount.methods.join(", ");
149
+ out.writeHead(200, headers);
150
+ out.end();
151
+ }
86
152
  /** Entrée `Via` de ce saut : `<version> <pseudonyme>` (RFC 9110 §7.6.3). */
87
153
  function viaEntry(httpVersion, pseudonym) {
88
154
  return `${httpVersion.startsWith("2") ? "2" : httpVersion || "1.1"} ${pseudonym}`;
@@ -98,10 +164,7 @@ function viaEntry(httpVersion, pseudonym) {
98
164
  */
99
165
  function bodyFramingOf(req) {
100
166
  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
- }
167
+ if (coding !== void 0) return isUnsupportedTransferCoding(coding) ? "unsupported" : "chunked";
105
168
  if (req.headers["content-length"] !== void 0) return "length";
106
169
  const stream = req.stream;
107
170
  if (stream !== void 0 && !stream.endAfterHeaders) return "chunked";
@@ -264,8 +327,9 @@ function armConnectTimeout(upstream, ms, onTimeout) {
264
327
  * Relaie une requête HTTP vers l'amont et renvoie sa réponse au client.
265
328
  *
266
329
  * 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
330
+ * message qui porte déjà notre `Via` rend 508 sans contacter l'amont. Sur
331
+ * `OPTIONS` et `TRACE`, `Max-Forwards` est décrémenté ; à `0`, le proxy répond
332
+ * lui-même en destinataire final (RFC 9110 §7.6.2). Un client parti coupe l'échange amont ; un amont injoignable rend 502 ; une
269
333
  * connexion non établie à temps ou un amont muet rendent 504 — à condition
270
334
  * que rien n'ait encore été envoyé, sinon la réponse est interrompue (un
271
335
  * statut ne se rattrape pas). Un amont coupé en plein corps interrompt la
@@ -307,10 +371,21 @@ function forwardRequest(req, res, mount, target, from, relay) {
307
371
  fail(501, NOT_IMPLEMENTED);
308
372
  return;
309
373
  }
374
+ const method = req.method ?? "GET";
375
+ const hops = receivedMaxForwards(method, req.headers["max-forwards"]);
376
+ if (hops === 0) {
377
+ answerAsFinalRecipient(req, out, mount);
378
+ return;
379
+ }
310
380
  const options = requestOptions(target, mount, relay);
311
- options.method = req.method ?? "GET";
381
+ options.method = method;
312
382
  options.path = upstreamPath(req.url ?? "/", mount);
313
- options.headers = requestHeadersFor(req.headers, mount, target, from, relay.pseudonym, framing);
383
+ const headers = requestHeadersFor(req.headers, mount, target, from, relay.pseudonym, framing);
384
+ if (hops !== null) {
385
+ if (hops > 0) headers["max-forwards"] = String(Math.min(hops - 1, 10));
386
+ else delete headers["max-forwards"];
387
+ }
388
+ options.headers = headers;
314
389
  if (mount.timeoutMs !== void 0) options.timeout = mount.timeoutMs;
315
390
  const request = requestFn(target)(options);
316
391
  upstream = request;
@@ -414,6 +489,31 @@ function refuseUpgrade(socket, status, reason, headers) {
414
489
  }
415
490
  socket.end(`HTTP/1.1 ${status} ${reason}\r\n${extra}Connection: close\r\nContent-Length: 0\r\n\r\n`);
416
491
  }
492
+ /** En-têtes du refus d'une version WebSocket inconnue — figés, partagés. */
493
+ const WS_VERSION_REFUSAL_HEADERS = Object.freeze({
494
+ Upgrade: "websocket",
495
+ "Sec-WebSocket-Version": "13"
496
+ });
497
+ /**
498
+ * Refuse un handshake WebSocket non conforme — la réponse qui convient à la
499
+ * raison rendue par `websocketHandshakeProblem`, seule implémentation pour le
500
+ * serveur WebSocket ET le proxy inverse.
501
+ *
502
+ * Une version inconnue reçoit `426` avec `Sec-WebSocket-Version: 13` : le
503
+ * client apprend vers quelle version se rabattre (RFC 6455 §4.2.2 point 6,
504
+ * §4.4), et le 426 porte l'`Upgrade` qu'exige RFC 9110 §15.5.22. Tout autre
505
+ * défaut de forme reçoit `400`.
506
+ *
507
+ * @param socket - socket du client, encore en attente de sa réponse
508
+ * @param problem - la raison rendue par `websocketHandshakeProblem`
509
+ */
510
+ function refuseMalformedHandshake(socket, problem) {
511
+ if (problem === "Sec-WebSocket-Version ≠ 13") {
512
+ refuseUpgrade(socket, 426, "Upgrade Required", WS_VERSION_REFUSAL_HEADERS);
513
+ return;
514
+ }
515
+ refuseUpgrade(socket, 400, "Bad Request");
516
+ }
417
517
  /** En-têtes d'une réponse d'amont REFUSANT l'upgrade, à ne pas recopier tels quels. */
418
518
  const REFUSAL_SKIP = /* @__PURE__ */ new Set([
419
519
  "transfer-encoding",
@@ -497,4 +597,4 @@ function forwardUpgrade(req, socket, head, mount, target, from, relay) {
497
597
  });
498
598
  upstream.end();
499
599
  }
500
- export { MAX_VIA_HOPS, bodyFramingOf, forwardRequest, forwardUpgrade, rawHead, refuseUpgrade, requestHeadersFor, responseHeadersFor, rewriteLocation, upstreamPath, viaLoops };
600
+ export { HOP_BY_HOP, MAX_VIA_HOPS, bodyFramingOf, forwardRequest, forwardUpgrade, rawHead, receivedMaxForwards, refuseMalformedHandshake, refuseUpgrade, requestHeadersFor, responseHeadersFor, rewriteLocation, upstreamPath, viaLoops };
@@ -30,6 +30,30 @@ function idleTimeoutSeconds(intro) {
30
30
  if (intro.keepaliveIntervalMs <= 0) return 3600;
31
31
  return Math.max(300, Math.ceil(intro.keepaliveIntervalMs * 4 / 1e3));
32
32
  }
33
+ /**
34
+ * Port h2c que le proxy doit joindre, ou `null` — règle UNIQUE des deux
35
+ * générateurs.
36
+ *
37
+ * Seulement vers un backend en clair : le re-chiffrement vise le port TLS, qui
38
+ * négocie déjà HTTP/2 par ALPN, et mélanger les deux liens ferait voyager une
39
+ * partie du trafic en clair alors que l'utilisateur a demandé le chiffrement.
40
+ *
41
+ * @param intro - modèle d'introspection Nodefony.
42
+ * @returns le port h2c à joindre, ou `null` s'il n'y a pas de lien h2c.
43
+ */
44
+ function upstreamH2cPort(intro) {
45
+ return intro.reencrypt ? null : intro.h2cPort ?? null;
46
+ }
47
+ /**
48
+ * Le `content-type` d'un appel gRPC natif, en expression régulière nginx :
49
+ * `application/grpc`, suivi de rien, d'un sous-type (`+proto`, `+json`) ou de
50
+ * paramètres (`;`).
51
+ *
52
+ * gRPC-Web (`application/grpc-web…`) n'y entre PAS, et c'est voulu : il est
53
+ * conçu pour HTTP/1.1, le chemin ordinaire le sert déjà, et l'envoyer sur le
54
+ * lien h2c retirerait la traduction que le serveur fait pour le navigateur.
55
+ */
56
+ const NGINX_GRPC_CONTENT_TYPE = "^application/grpc($|[+;])";
33
57
  /** `server_name` nginx / hôte de comparaison — IP et `0.0.0.0` exclus. */
34
58
  function serverNames(domains) {
35
59
  const names = domains.filter((d) => d && d !== "0.0.0.0" && !isIpLiteral(d));
@@ -39,15 +63,23 @@ function serverNames(domains) {
39
63
  function isIpLiteral(host) {
40
64
  return /^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":");
41
65
  }
42
- /** En-têtes forwarded nginx (pattern EDGE : on ÉCRASE X-Forwarded-For). */
43
- const NGINX_FORWARD_HEADERS_TPL = ` # EDGE (face client) : on ÉCRASE X-Forwarded-For avec la SEULE IP vue par
66
+ /**
67
+ * En-têtes d'identité du client (pattern EDGE : on ÉCRASE X-Forwarded-For),
68
+ * communs au relais HTTP (`proxy_set_header`) et au relais gRPC
69
+ * (`grpc_set_header`) — `__SET__` reçoit la directive. Un seul texte : les deux
70
+ * chemins annoncent au serveur le même client, sinon `trustProxy` lirait deux
71
+ * vérités selon le protocole.
72
+ */
73
+ const NGINX_CLIENT_HEADERS_TPL = ` # EDGE (face client) : on ÉCRASE X-Forwarded-For avec la SEULE IP vue par
44
74
  # nginx → toute valeur forgée par le client est jetée (RFC 7239 §8.1).
45
- proxy_set_header Host $host;
46
- proxy_set_header X-Real-IP $remote_addr;
47
- proxy_set_header X-Forwarded-For $remote_addr;
48
- proxy_set_header X-Forwarded-Proto $scheme;
49
- proxy_set_header X-Forwarded-Host $host;
50
- proxy_set_header X-Forwarded-Port $server_port;
75
+ __SET__ Host $host;
76
+ __SET__ X-Real-IP $remote_addr;
77
+ __SET__ X-Forwarded-For $remote_addr;
78
+ __SET__ X-Forwarded-Proto $scheme;
79
+ __SET__ X-Forwarded-Host $host;
80
+ __SET__ X-Forwarded-Port $server_port;`;
81
+ /** En-têtes forwarded nginx (pattern EDGE : on ÉCRASE X-Forwarded-For). */
82
+ const NGINX_FORWARD_HEADERS_TPL = `${NGINX_CLIENT_HEADERS_TPL.replaceAll("__SET__ ", "proxy_set_header ")}
51
83
  # WebSocket (Nodefony co-héberge HTTP + WS sur le même port).
52
84
  proxy_set_header Upgrade $http_upgrade;
53
85
  proxy_set_header Connection $connection_upgrade;
@@ -61,6 +93,39 @@ function nginxForwardHeaders(idleSeconds) {
61
93
  return NGINX_FORWARD_HEADERS_TPL.replaceAll("__IDLE__", String(idleSeconds));
62
94
  }
63
95
  /**
96
+ * Le relais gRPC d'un bloc `server` : aiguillage par `content-type`, puis la
97
+ * location nommée qui parle HTTP/2 au port h2c.
98
+ *
99
+ * nginx ne parle HTTP/2 à un amont que par `grpc_pass` (`proxy_pass` s'arrête à
100
+ * HTTP/1.1) ; or gRPC n'existe qu'en HTTP/2, avec son statut dans les trailers.
101
+ * Une `location` ne choisit que sur l'URI, et l'URI d'un appel gRPC
102
+ * (`/paquet.Service/Méthode`) ne se distingue d'aucune autre : c'est donc le
103
+ * `content-type` qui aiguille, au niveau du `server`, par un `if` qui ne fait
104
+ * que `return` — la seule forme qui ne se mêle pas aux `try_files` des
105
+ * locations. Le `418` est un code INTERNE (jamais rendu au client) que
106
+ * `error_page` convertit en saut vers `@grpc`.
107
+ *
108
+ * @param idleSeconds - inactivité tolérée, dérivée du heartbeat WebSocket.
109
+ * @returns les lignes à poser dans le bloc `server`.
110
+ */
111
+ function nginxGrpcRelay(idleSeconds) {
112
+ return [
113
+ "",
114
+ " # gRPC (servers.h2c) : HTTP/2 jusqu'au backend, seul chemin où ses trailers",
115
+ " # (grpc-status) survivent. gRPC-Web reste sur le chemin HTTP/1.1 ordinaire.",
116
+ ` if ($content_type ~* "${NGINX_GRPC_CONTENT_TYPE}") { return 418; }`,
117
+ " error_page 418 = @grpc;",
118
+ "",
119
+ " location @grpc {",
120
+ " grpc_pass grpc://nodefony_h2c;",
121
+ NGINX_CLIENT_HEADERS_TPL.replaceAll("__SET__ ", "grpc_set_header "),
122
+ ` grpc_read_timeout ${idleSeconds}s;`,
123
+ ` grpc_send_timeout ${idleSeconds}s;`,
124
+ " client_max_body_size 0;",
125
+ " }"
126
+ ];
127
+ }
128
+ /**
64
129
  * Génère une configuration nginx complète (reverse-proxy + offload statiques).
65
130
  *
66
131
  * @param intro - modèle d'introspection Nodefony.
@@ -71,10 +136,15 @@ function generateNginxConfig(intro) {
71
136
  const backendPort = intro.reencrypt ? intro.httpsPort : intro.httpPort;
72
137
  const idleSeconds = idleTimeoutSeconds(intro);
73
138
  const lines = [];
74
- lines.push("# Généré par `nodefony proxy:generate nginx` — NE PAS éditer à la main.", "# Reverse-proxy dérivé de l'introspection Nodefony (domaines, statiques, ports).", "worker_processes auto;", "events { worker_connections 1024; }", "", "http {", " include /etc/nginx/mime.types;", " default_type application/octet-stream;", "", " server_tokens off;", "", " sendfile on;", " tcp_nopush on;", " tcp_nodelay on;", "", " gzip on;", " gzip_vary on;", " gzip_min_length 1024;", " gzip_proxied any;", " gzip_types text/plain text/css text/xml application/javascript application/json application/xml image/svg+xml application/manifest+json;", "", " # Upgrade WebSocket — HTTP et WS co-habitent sur le même port Nodefony.", " map $http_upgrade $connection_upgrade { default upgrade; '' close; }", "", ` upstream nodefony { server ${intro.backendHost}:${backendPort}; keepalive 32; }`, "");
139
+ lines.push("# Généré par `nodefony proxy:generate nginx` — NE PAS éditer à la main.", "# Reverse-proxy dérivé de l'introspection Nodefony (domaines, statiques, ports).", "worker_processes auto;", "events { worker_connections 1024; }", "", "http {", " include /etc/nginx/mime.types;", " default_type application/octet-stream;", "", " server_tokens off;", "", " sendfile on;", " tcp_nopush on;", " tcp_nodelay on;", "", " gzip on;", " gzip_vary on;", " gzip_min_length 1024;", " gzip_proxied any;", " gzip_types text/plain text/css text/xml application/javascript application/json application/xml image/svg+xml application/manifest+json;", "", " # Upgrade WebSocket — HTTP et WS co-habitent sur le même port Nodefony.", " map $http_upgrade $connection_upgrade { default upgrade; '' close; }", "", ` upstream nodefony { server ${intro.backendHost}:${backendPort}; keepalive 32; }`);
140
+ const h2cPort = upstreamH2cPort(intro);
141
+ if (h2cPort === null) lines.push(intro.reencrypt && intro.h2cPort ? " # gRPC : non relayé — le re-chiffrement vise le port TLS ; h2c reste un lien en clair." : " # gRPC : non relayé — activer `servers.h2c` côté Nodefony puis régénérer.");
142
+ else lines.push(` upstream nodefony_h2c { server ${intro.backendHost}:${h2cPort}; keepalive 32; }`);
143
+ lines.push("");
75
144
  if (intro.maxBodyBytes > 0) lines.push(` # Aligné sur \`http.maxBodySize\` (${intro.maxBodyBytes} octets) — sans quoi`, " # nginx rendrait 413 à 1 Mo, son défaut, sans que le serveur le sache.", ` client_max_body_size ${intro.maxBodyBytes};`, "");
76
- lines.push(...nginxServerBlock(intro, scheme, idleSeconds, null));
77
- if (intro.tls) lines.push("", ...nginxServerBlock(intro, scheme, idleSeconds, intro.tls));
145
+ const grpc = h2cPort !== null;
146
+ lines.push(...nginxServerBlock(intro, scheme, idleSeconds, null, grpc));
147
+ if (intro.tls) lines.push("", ...nginxServerBlock(intro, scheme, idleSeconds, intro.tls, grpc));
78
148
  lines.push("}", "");
79
149
  return lines.join("\n");
80
150
  }
@@ -90,12 +160,18 @@ function generateNginxConfig(intro) {
90
160
  * @param scheme - `http` ou `https` vers le BACKEND (re-chiffrement).
91
161
  * @param idleSeconds - inactivité tolérée, dérivée du heartbeat WebSocket.
92
162
  * @param tls - terminaison TLS de CE bloc, ou `null` pour une écoute en clair.
163
+ * @param grpc - relayer gRPC vers l'amont `nodefony_h2c` (h2c actif).
93
164
  * @returns les lignes du bloc `server`.
94
165
  */
95
- function nginxServerBlock(intro, scheme, idleSeconds, tls) {
166
+ function nginxServerBlock(intro, scheme, idleSeconds, tls, grpc) {
96
167
  const lines = [" server {"];
97
168
  if (tls) lines.push(` listen ${tls.listen} ssl;`, " http2 on;", ` server_name ${serverNames(intro.domains)};`, "", " # Terminaison TLS au frontal. Ces chemins sont ceux d'un MONTAGE, à", " # pourvoir au déploiement (volume compose, secret k8s) : une clé privée", " # n'entre pas dans une image, où la couche reste lisible même effacée.", ` ssl_certificate ${tls.certPath};`, ` ssl_certificate_key ${tls.keyPath};`, " ssl_protocols TLSv1.2 TLSv1.3;", " ssl_session_cache shared:SSL:10m;", " ssl_session_timeout 1h;", "", " error_page 497 =301 https://$http_host$request_uri;");
98
- else lines.push(` listen ${intro.listen};`, ` server_name ${serverNames(intro.domains)};`);
169
+ else {
170
+ lines.push(` listen ${intro.listen};`);
171
+ if (grpc) lines.push(" http2 on;");
172
+ lines.push(` server_name ${serverNames(intro.domains)};`);
173
+ }
174
+ if (grpc) lines.push(...nginxGrpcRelay(idleSeconds));
99
175
  if (intro.reencrypt) lines.push(" # Re-encrypt : valider le cert backend (cf docker/certs).", " # proxy_ssl_trusted_certificate /etc/nginx/certs/ca.pem;", " # proxy_ssl_verify on; proxy_ssl_name nodefony.com;");
100
176
  for (const m of intro.mounts) {
101
177
  const prefix = ensureTrailingSlash(m.prefix);
@@ -125,7 +201,7 @@ function nginxServerBlock(intro, scheme, idleSeconds, tls) {
125
201
  */
126
202
  function generateHaproxyConfig(intro) {
127
203
  const backendPort = intro.reencrypt ? intro.httpsPort : intro.httpPort;
128
- const h2cPort = intro.reencrypt ? null : intro.h2cPort ?? null;
204
+ const h2cPort = upstreamH2cPort(intro);
129
205
  const idleSeconds = idleTimeoutSeconds(intro);
130
206
  const serverSsl = intro.reencrypt ? ` ssl ca-file /etc/haproxy/certs/ca.pem verify required verifyhost ${firstDomain(intro)} sni str(${firstDomain(intro)})` : "";
131
207
  const readiness = intro.readinessPath ?? null;
@@ -152,6 +228,12 @@ defaults
152
228
  # \`timeout server\` ne s'applique plus au tunnel. Sans cette ligne, la valeur
153
229
  # implicite coupe des sockets que le heartbeat gardait pourtant vivantes.
154
230
  timeout tunnel ${idleSeconds}s
231
+ # Forwarded (RFC 7239) posé par haproxy lui-même (>= 2.8) : il met entre
232
+ # guillemets ce qui n'est pas un jeton — une IPv6 (\`for="[2001:db8::1]"\`),
233
+ # un \`host\` qui porte un port. Un \`set-header\` écrit à la main laissait
234
+ # \`for=2001:db8::1\`, valeur invalide (§6). \`proto\` suit \`ssl_fc\` : le
235
+ # scheme vu par le CLIENT. Vaut pour tous les backends.
236
+ option forwarded proto host for
155
237
 
156
238
  frontend fe_nodefony
157
239
  bind *:${intro.listen}
@@ -173,8 +255,6 @@ frontend fe_nodefony
173
255
  http-request set-header X-Forwarded-Proto http unless { ssl_fc }
174
256
  http-request set-header X-Forwarded-Host %[req.hdr(host)]
175
257
  http-request set-header X-Real-IP %[src]
176
- http-request set-header Forwarded "for=%[src];proto=https;host=%[req.hdr(host)]" if { ssl_fc }
177
- http-request set-header Forwarded "for=%[src];proto=http;host=%[req.hdr(host)]" unless { ssl_fc }
178
258
 
179
259
  ${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"}
180
260
  backend be_nodefony
@@ -189,4 +269,4 @@ function firstDomain(intro) {
189
269
  function ensureTrailingSlash(dir) {
190
270
  return dir.endsWith("/") ? dir : `${dir}/`;
191
271
  }
192
- export { defaultIntrospection, generateHaproxyConfig, generateNginxConfig };
272
+ export { defaultIntrospection, generateHaproxyConfig, generateNginxConfig, upstreamH2cPort };