@nodefony/http 10.0.0-beta.6 → 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 (96) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorateParam.js +1 -1
  3. package/dist/index.js +3 -2
  4. package/dist/nodefony/config/config.js +25 -2
  5. package/dist/nodefony/grpc/GrpcCall.js +298 -0
  6. package/dist/nodefony/grpc/GrpcEndpoint.js +528 -0
  7. package/dist/nodefony/grpc/index.js +4 -0
  8. package/dist/nodefony/interfaces/IRpcCall.js +1 -0
  9. package/dist/nodefony/service/audit-logger.js +52 -6
  10. package/dist/nodefony/service/error-renderer.js +41 -9
  11. package/dist/nodefony/service/http-kernel.js +198 -19
  12. package/dist/nodefony/service/pretty-request-logger.js +5 -3
  13. package/dist/nodefony/service/request-logger.js +21 -6
  14. package/dist/nodefony/service/reverse-proxy.js +1 -1
  15. package/dist/nodefony/service/servers/clientError.js +26 -1
  16. package/dist/nodefony/service/servers/server-h2c.js +3 -3
  17. package/dist/nodefony/service/servers/server-http.js +3 -3
  18. package/dist/nodefony/service/servers/server-https.js +4 -4
  19. package/dist/nodefony/service/servers/server-websocket-secure.js +3 -3
  20. package/dist/nodefony/service/servers/server-websocket.js +3 -3
  21. package/dist/nodefony/service/servers/serverShutdown.js +14 -36
  22. package/dist/nodefony/service/sessions/sessions-service.js +6 -6
  23. package/dist/nodefony/service/upload/upload-service.js +2 -2
  24. package/dist/nodefony/src/context/Context.js +12 -1
  25. package/dist/nodefony/src/context/http/ByteStream.js +9 -4
  26. package/dist/nodefony/src/context/http/HttpContext.js +86 -8
  27. package/dist/nodefony/src/context/http/Request.js +96 -16
  28. package/dist/nodefony/src/context/http/Response.js +120 -17
  29. package/dist/nodefony/src/context/http/SseStream.js +4 -4
  30. package/dist/nodefony/src/context/http/StreamLifecycle.js +328 -19
  31. package/dist/nodefony/src/context/http/parser.js +29 -11
  32. package/dist/nodefony/src/context/http/webResponse.js +56 -0
  33. package/dist/nodefony/src/context/http2/Response.js +47 -8
  34. package/dist/nodefony/src/context/requestTrace.js +6 -2
  35. package/dist/nodefony/src/context/rpcOutcome.js +20 -0
  36. package/dist/nodefony/src/context/websocket/Response.js +11 -1
  37. package/dist/nodefony/src/context/websocket/WebsocketContext.js +15 -4
  38. package/dist/nodefony/src/cookies/cookie.js +3 -1
  39. package/dist/nodefony/src/errors/httpError.js +58 -25
  40. package/dist/nodefony/src/grpc/cors.js +59 -0
  41. package/dist/nodefony/src/grpc/limits.js +16 -0
  42. package/dist/nodefony/src/grpc/refusal.js +271 -0
  43. package/dist/nodefony/src/profiler/aggregate.js +2 -2
  44. package/dist/nodefony/src/proxy/forward.js +1 -1
  45. package/dist/nodefony/src/proxy/generateProxyConfig.js +91 -15
  46. package/dist/nodefony/src/servers/http2Goaway.js +76 -0
  47. package/dist/nodefony/src/servers/http2Limits.js +41 -15
  48. package/dist/nodefony/src/servers/http2StreamGrace.js +100 -0
  49. package/dist/nodefony/src/servers/receiveDeadline.js +177 -0
  50. package/dist/types/index.d.ts +5 -2
  51. package/dist/types/nodefony/config/config.d.ts +10 -0
  52. package/dist/types/nodefony/grpc/GrpcCall.d.ts +142 -0
  53. package/dist/types/nodefony/grpc/GrpcEndpoint.d.ts +98 -0
  54. package/dist/types/nodefony/grpc/index.d.ts +15 -0
  55. package/dist/types/nodefony/interfaces/IContext.d.ts +9 -0
  56. package/dist/types/nodefony/interfaces/IErrorRenderer.d.ts +14 -0
  57. package/dist/types/nodefony/interfaces/IRequest.d.ts +2 -1
  58. package/dist/types/nodefony/interfaces/IRequestTrace.d.ts +10 -0
  59. package/dist/types/nodefony/interfaces/IRouting.d.ts +19 -0
  60. package/dist/types/nodefony/interfaces/IRpcCall.d.ts +83 -0
  61. package/dist/types/nodefony/interfaces/IUpload.d.ts +51 -4
  62. package/dist/types/nodefony/interfaces/index.d.ts +2 -1
  63. package/dist/types/nodefony/service/audit-logger.d.ts +33 -3
  64. package/dist/types/nodefony/service/error-renderer.d.ts +22 -1
  65. package/dist/types/nodefony/service/http-kernel.d.ts +21 -1
  66. package/dist/types/nodefony/service/request-logger.d.ts +10 -0
  67. package/dist/types/nodefony/service/servers/serverShutdown.d.ts +2 -2
  68. package/dist/types/nodefony/src/context/Context.d.ts +9 -1
  69. package/dist/types/nodefony/src/context/http/ByteStream.d.ts +1 -1
  70. package/dist/types/nodefony/src/context/http/HttpContext.d.ts +56 -2
  71. package/dist/types/nodefony/src/context/http/Request.d.ts +38 -8
  72. package/dist/types/nodefony/src/context/http/Response.d.ts +61 -7
  73. package/dist/types/nodefony/src/context/http/SseStream.d.ts +1 -1
  74. package/dist/types/nodefony/src/context/http/StreamLifecycle.d.ts +53 -0
  75. package/dist/types/nodefony/src/context/http/parser.d.ts +17 -3
  76. package/dist/types/nodefony/src/context/http/webResponse.d.ts +31 -0
  77. package/dist/types/nodefony/src/context/http2/Response.d.ts +35 -2
  78. package/dist/types/nodefony/src/context/requestTrace.d.ts +3 -1
  79. package/dist/types/nodefony/src/context/rpcOutcome.d.ts +11 -0
  80. package/dist/types/nodefony/src/context/websocket/Response.d.ts +6 -1
  81. package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +8 -0
  82. package/dist/types/nodefony/src/errors/httpError.d.ts +29 -4
  83. package/dist/types/nodefony/src/grpc/cors.d.ts +32 -0
  84. package/dist/types/nodefony/src/grpc/limits.d.ts +14 -0
  85. package/dist/types/nodefony/src/grpc/refusal.d.ts +136 -0
  86. package/dist/types/nodefony/src/proxy/forward.d.ts +7 -0
  87. package/dist/types/nodefony/src/proxy/generateProxyConfig.d.ts +15 -2
  88. package/dist/types/nodefony/src/servers/http2Goaway.d.ts +41 -0
  89. package/dist/types/nodefony/src/servers/http2Limits.d.ts +27 -14
  90. package/dist/types/nodefony/src/servers/http2StreamGrace.d.ts +34 -0
  91. package/dist/types/nodefony/src/servers/receiveDeadline.d.ts +77 -0
  92. package/docs/byte-stream.md +52 -17
  93. package/docs/diagnostics-channel.md +45 -1
  94. package/docs/servers.md +57 -44
  95. package/docs/upload.md +43 -17
  96. package/package.json +26 -6
@@ -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 };
@@ -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;
@@ -597,4 +597,4 @@ function forwardUpgrade(req, socket, head, mount, target, from, relay) {
597
597
  });
598
598
  upstream.end();
599
599
  }
600
- export { MAX_VIA_HOPS, bodyFramingOf, forwardRequest, forwardUpgrade, rawHead, receivedMaxForwards, refuseMalformedHandshake, 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;
@@ -193,4 +269,4 @@ function firstDomain(intro) {
193
269
  function ensureTrailingSlash(dir) {
194
270
  return dir.endsWith("/") ? dir : `${dir}/`;
195
271
  }
196
- export { defaultIntrospection, generateHaproxyConfig, generateNginxConfig };
272
+ export { defaultIntrospection, generateHaproxyConfig, generateNginxConfig, upstreamH2cPort };
@@ -0,0 +1,76 @@
1
+ import { constants } from "node:http2";
2
+ // nodefony/src/servers/http2Goaway.ts
3
+ /** Plus long délai d'annonce (ms) : un pair qui ne répond pas au PING ne retient pas la fermeture. */
4
+ const ANNOUNCE_CAP_MS = 1e3;
5
+ /** Dernier identifiant de flux possible (RFC 9113 §6.8) : « aucun flux n'est encore écarté ». */
6
+ const LAST_POSSIBLE_STREAM = 2 ** 31 - 1;
7
+ /**
8
+ * Annonce à UNE session HTTP/2 qu'elle va fermer, SANS écarter un flux déjà en route.
9
+ *
10
+ * Un PING mesure l'aller-retour, puis GOAWAY à `2^31-1`, puis l'attente de cet
11
+ * aller-retour : après elle, tout flux ouvert par le client avant l'annonce est
12
+ * arrivé, et le GOAWAY définitif (`session.close()`, dernier flux traité) peut
13
+ * suivre. RFC 9113 §6.8 : « SHOULD send an initial GOAWAY frame with the last
14
+ * stream identifier set to 2^31-1 […] After allowing time for any in-flight
15
+ * stream creation (at least one round-trip time) ». Sans annonce, un flux croisé
16
+ * avec le GOAWAY est refusé alors qu'il aurait pu être servi.
17
+ *
18
+ * L'aller-retour se MESURE avant l'annonce : après un GOAWAY, un client Node ne
19
+ * répond plus aux PING (constaté sur Node 26.13).
20
+ *
21
+ * @param session - la session à annoncer, ni fermée ni détruite
22
+ * @param settle - appelé UNE fois : après l'attente, à la fermeture de la
23
+ * session, ou aussitôt si le PING ne part pas. Un pair muet ne l'appelle
24
+ * jamais — le délai maximal appartient à l'appelant.
25
+ */
26
+ function announceSessionClose(session, settle) {
27
+ let settled = false;
28
+ let wait = null;
29
+ const done = () => {
30
+ if (settled) return;
31
+ settled = true;
32
+ if (wait !== null) clearTimeout(wait);
33
+ session.removeListener("close", done);
34
+ settle();
35
+ };
36
+ session.once("close", done);
37
+ if (!session.ping((error, rttMs) => {
38
+ if (error !== null || settled || session.closed) return done();
39
+ session.goaway(constants.NGHTTP2_NO_ERROR, LAST_POSSIBLE_STREAM);
40
+ wait = setTimeout(done, Math.max(1, Math.ceil(rttMs)));
41
+ })) {
42
+ session.goaway(constants.NGHTTP2_NO_ERROR, LAST_POSSIBLE_STREAM);
43
+ done();
44
+ }
45
+ }
46
+ /**
47
+ * Ferme EN DOUCEUR une session HTTP/2 restée inactive au-delà du délai du serveur.
48
+ *
49
+ * RFC 9113 §9.1 : un serveur est « permitted to terminate idle connections »,
50
+ * et « SHOULD first send a GOAWAY ». La fermeture suit l'annonce en deux temps
51
+ * ({@link announceSessionClose}), puis `session.close()` : aucun nouveau flux,
52
+ * mais les flux en cours vont à leur terme — un flux long et muet (abonnement
53
+ * gRPC) n'est pas coupé, seuls les NOUVEAUX flux repartent sur une autre
54
+ * connexion, ce que tout client HTTP/2 fait sur GOAWAY.
55
+ *
56
+ * Le délai de la session est désarmé d'abord : Node (26.13) marque une session
57
+ * détruite AVANT de désarmer son minuteur, qui la retiendrait alors jusqu'à son
58
+ * échéance (nodejs/node#66885).
59
+ *
60
+ * @param session - la session inactive
61
+ * @param capMs - délai maximal d'attente de l'écho du PING (ms)
62
+ */
63
+ function closeIdleSession(session, capMs = ANNOUNCE_CAP_MS) {
64
+ if (session.closed || session.destroyed) return;
65
+ session.setTimeout(0);
66
+ let finished = false;
67
+ const finish = () => {
68
+ if (finished) return;
69
+ finished = true;
70
+ clearTimeout(timer);
71
+ if (!session.closed && !session.destroyed) session.close();
72
+ };
73
+ const timer = setTimeout(finish, capMs);
74
+ announceSessionClose(session, finish);
75
+ }
76
+ export { ANNOUNCE_CAP_MS, announceSessionClose, closeIdleSession };
@@ -1,3 +1,4 @@
1
+ import { closeIdleSession } from "./http2Goaway.js";
1
2
  // nodefony/src/servers/http2Limits.ts
2
3
  /**
3
4
  * Limites anti-DoS d'un serveur HTTP/2, lues dans la section `http2` du module :
@@ -18,29 +19,54 @@ function http2ServerLimits(limits) {
18
19
  return options;
19
20
  }
20
21
  /**
21
- * Borne l'inactivité des sessions d'un serveur HTTP/2 — TLS (5152) comme h2c —
22
- * et contourne un défaut de Node qui garde chaque session fermée en vie jusqu'à
23
- * l'échéance de ce délai.
22
+ * Une session HTTP/2, et non une socket HTTP/1.1 : sur 5152 (`allowHTTP1`),
23
+ * l'événement `timeout` du serveur reçoit les deux. `node:http2` n'exporte pas
24
+ * la classe de session — elle se reconnaît à sa capacité (`goaway`).
25
+ */
26
+ function isHttp2Session(target) {
27
+ return typeof target === "object" && target !== null && "goaway" in target;
28
+ }
29
+ /**
30
+ * Ferme les sessions d'un serveur HTTP/2 — TLS (5152) comme h2c — restées
31
+ * inactives au-delà de `timeoutMs`, et contourne un défaut de Node qui garde
32
+ * chaque session fermée en vie jusqu'à l'échéance de ce délai.
24
33
  *
25
- * Le défaut (Node 24, 26) : `closeSession` (`internal/http2/core`) marque la
26
- * session détruite PUIS appelle `session.setTimeout(0)` ; or `setStreamTimeout`
27
- * sort aussitôt sur un objet détruit. Le minuteur n'est jamais désarmé et retient
28
- * la session (~2,4 Ko) pendant `timeoutMs` : sous un flux de connexions neuves,
29
- * connexions/s × délai × 2,4 Ko. Signalé à Node : https://github.com/nodejs/node/issues/66885
34
+ * **La fermeture est À NOUS.** Node arme chaque session par
35
+ * `session.setTimeout(server.timeout, sessionOnTimeout)` — un écouteur `once` —
36
+ * et `sessionOnTimeout` ne détruit la session que si le serveur n'a AUCUN
37
+ * écouteur `timeout` ; s'il en a un, il lui passe la main, UNE fois par session
38
+ * (`internal/http2/core`, Node 26.13). Le serveur 5152 en a toujours un
39
+ * (`routeSocketTimeout`, pour ses sockets HTTP/1.1), et ce délai doit prévenir
40
+ * l'application (`onTimeout`) : sans fermeture ici, une session inactive vivait
41
+ * jusqu'à ce que le CLIENT la ferme (#763). À l'échéance, la session est fermée
42
+ * en douceur — GOAWAY annoncé puis définitif, les flux en cours vont à leur
43
+ * terme ({@link closeIdleSession}, RFC 9113 §9.1 et §6.8).
30
44
  *
31
- * Le contournement : désarmer à la réception du GOAWAY, AVANT la destruction —
32
- * c'est ainsi que ferment un proxy et un client Node, même par `destroy()`. Rien
33
- * n'est retiré à la protection : Node n'agit déjà plus à l'échéance sur une
34
- * session fermée (`sessionOnTimeout` sort sur `closed`). Une coupure TCP sans
35
- * GOAWAY garde la rétention, bornée au délai.
45
+ * **Le contournement** (Node 24, 26) : `closeSession` marque la session détruite
46
+ * PUIS appelle `session.setTimeout(0)`, qui sort aussitôt sur un objet détruit ;
47
+ * le minuteur retient alors la session (~2,4 Ko) pendant `timeoutMs`. Il est
48
+ * désarmé à la réception du GOAWAY, AVANT la destruction — c'est ainsi que
49
+ * ferment un proxy et un client Node, même par `destroy()` ; et
50
+ * `closeIdleSession` le désarme avant de fermer. Signalé à Node :
51
+ * https://github.com/nodejs/node/issues/66885 (corrigé par nodejs/node#66886,
52
+ * absent de la 26.13). Une coupure TCP sans GOAWAY garde la rétention, bornée
53
+ * au délai.
54
+ *
55
+ * Coût par requête : nul — un écouteur par serveur, et la fermeture n'agit qu'à
56
+ * l'échéance.
36
57
  *
37
58
  * @param server - le serveur HTTP/2.
38
59
  * @param timeoutMs - délai d'inactivité ; 0 ou absent le laisse désactivé.
39
- * @param onTimeout - appelé à l'échéance (événement `timeout` du serveur).
60
+ * @param onTimeout - appelé à l'échéance d'une session (événement `timeout` du
61
+ * serveur), après le début de sa fermeture.
40
62
  */
41
63
  function setHttp2SessionTimeout(server, timeoutMs, onTimeout) {
42
64
  if (!timeoutMs) return;
43
- server.setTimeout(timeoutMs, onTimeout);
65
+ server.setTimeout(timeoutMs, (target) => {
66
+ if (!isHttp2Session(target)) return;
67
+ closeIdleSession(target);
68
+ onTimeout();
69
+ });
44
70
  server.on("session", (session) => {
45
71
  session.once("goaway", () => {
46
72
  session.setTimeout(0);