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

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 (81) hide show
  1. package/README.md +24 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorateParam.js +1 -1
  4. package/dist/index.js +29 -26
  5. package/dist/nodefony/command/proxyGenerateCommand.js +2 -2
  6. package/dist/nodefony/config/config.js +4 -2
  7. package/dist/nodefony/service/ProfilerAdminApi.js +5 -2
  8. package/dist/nodefony/service/error-renderer.js +6 -4
  9. package/dist/nodefony/service/http-kernel.js +112 -34
  10. package/dist/nodefony/service/pretty-request-logger.js +1 -1
  11. package/dist/nodefony/service/request-logger.js +1 -1
  12. package/dist/nodefony/service/reverse-proxy.js +7 -2
  13. package/dist/nodefony/service/servers/clientError.js +28 -4
  14. package/dist/nodefony/service/servers/server-h2c.js +5 -5
  15. package/dist/nodefony/service/servers/server-http.js +5 -3
  16. package/dist/nodefony/service/servers/server-https.js +12 -5
  17. package/dist/nodefony/service/servers/server-static.js +1 -6
  18. package/dist/nodefony/service/servers/server-websocket-secure.js +6 -10
  19. package/dist/nodefony/service/servers/server-websocket.js +6 -10
  20. package/dist/nodefony/service/servers/serverShutdown.js +63 -4
  21. package/dist/nodefony/service/sessions/sessions-service.js +3 -3
  22. package/dist/nodefony/service/upload/upload-service.js +2 -2
  23. package/dist/nodefony/src/assets/prebuiltUi.js +23 -5
  24. package/dist/nodefony/src/context/http/ByteStream.js +359 -0
  25. package/dist/nodefony/src/context/http/HttpContext.js +55 -26
  26. package/dist/nodefony/src/context/http/Request.js +15 -6
  27. package/dist/nodefony/src/context/http/Response.js +354 -35
  28. package/dist/nodefony/src/context/http/SseStream.js +81 -146
  29. package/dist/nodefony/src/context/http/StreamLifecycle.js +273 -0
  30. package/dist/nodefony/src/context/http/urlFastPath.js +80 -1
  31. package/dist/nodefony/src/context/http2/Request.js +1 -1
  32. package/dist/nodefony/src/context/http2/Response.js +8 -4
  33. package/dist/nodefony/src/context/transport.js +20 -1
  34. package/dist/nodefony/src/context/websocket/Response.js +4 -2
  35. package/dist/nodefony/src/context/websocket/WebsocketContext.js +71 -14
  36. package/dist/nodefony/src/context/websocket/websocketUrl.js +7 -0
  37. package/dist/nodefony/src/context/websocket/wsBackpressure.js +15 -15
  38. package/dist/nodefony/src/context/websocket/wsFrameRate.js +71 -0
  39. package/dist/nodefony/src/context/websocket/wsLogContent.js +55 -17
  40. package/dist/nodefony/src/cookies/cookie.js +54 -13
  41. package/dist/nodefony/src/profiler/Profiler.js +14 -1
  42. package/dist/nodefony/src/proxy/forward.js +109 -9
  43. package/dist/nodefony/src/proxy/generateProxyConfig.js +6 -2
  44. package/dist/nodefony/src/proxy/rules.js +29 -14
  45. package/dist/nodefony/src/servers/upgradeDispatch.js +62 -4
  46. package/dist/types/index.d.ts +6 -0
  47. package/dist/types/nodefony/config/config.d.ts +4 -0
  48. package/dist/types/nodefony/interfaces/ISecurity.d.ts +27 -2
  49. package/dist/types/nodefony/service/ProfilerAdminApi.d.ts +2 -1
  50. package/dist/types/nodefony/service/error-renderer.d.ts +5 -4
  51. package/dist/types/nodefony/service/http-kernel.d.ts +15 -2
  52. package/dist/types/nodefony/service/servers/clientError.d.ts +3 -2
  53. package/dist/types/nodefony/service/servers/serverShutdown.d.ts +5 -3
  54. package/dist/types/nodefony/src/assets/prebuiltUi.d.ts +9 -4
  55. package/dist/types/nodefony/src/context/http/ByteStream.d.ts +109 -0
  56. package/dist/types/nodefony/src/context/http/HttpContext.d.ts +45 -3
  57. package/dist/types/nodefony/src/context/http/Request.d.ts +8 -1
  58. package/dist/types/nodefony/src/context/http/Response.d.ts +134 -8
  59. package/dist/types/nodefony/src/context/http/SseStream.d.ts +32 -7
  60. package/dist/types/nodefony/src/context/http/StreamLifecycle.d.ts +53 -0
  61. package/dist/types/nodefony/src/context/http/urlFastPath.d.ts +40 -0
  62. package/dist/types/nodefony/src/context/http2/Response.d.ts +5 -3
  63. package/dist/types/nodefony/src/context/transport.d.ts +17 -0
  64. package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +40 -0
  65. package/dist/types/nodefony/src/context/websocket/websocketUrl.d.ts +4 -0
  66. package/dist/types/nodefony/src/context/websocket/wsBackpressure.d.ts +12 -2
  67. package/dist/types/nodefony/src/context/websocket/wsFrameRate.d.ts +69 -0
  68. package/dist/types/nodefony/src/context/websocket/wsLogContent.d.ts +16 -8
  69. package/dist/types/nodefony/src/cookies/cookie.d.ts +20 -2
  70. package/dist/types/nodefony/src/profiler/Profiler.d.ts +11 -1
  71. package/dist/types/nodefony/src/proxy/forward.d.ts +30 -2
  72. package/dist/types/nodefony/src/proxy/rules.d.ts +20 -0
  73. package/dist/types/nodefony/src/servers/upgradeDispatch.d.ts +41 -1
  74. package/docs/byte-stream.md +371 -0
  75. package/docs/cookies.md +13 -3
  76. package/docs/index.md +8 -0
  77. package/docs/reverse-proxy.md +15 -3
  78. package/docs/servers.md +26 -15
  79. package/docs/session.md +3 -3
  80. package/docs/sse.md +27 -20
  81. package/package.json +16 -16
package/README.md CHANGED
@@ -43,6 +43,30 @@ Le serveur le pose dans le contexte asynchrone de la requête : `RequestContext.
43
43
  depuis n'importe quel code, et les services `@injectable({ scope: "request" })` y vivent. Détail :
44
44
  [Injection & portées](https://github.com/nodefony/nodefony-core/blob/main/docs/architecture/injection-portees.md).
45
45
 
46
+ ## Réponses en flux
47
+
48
+ Deux formats de réponse qui ne finissent pas d'un coup, en HTTP/1.1 comme en HTTP/2, sur le même
49
+ cycle de vie (réponse prise, session sauvée avant les en-têtes, départ du client vu sur la réponse,
50
+ plafond par IP, révocation, coupure d'un client qui ne lit plus) :
51
+
52
+ | Besoin | Contrôleur | Contexte | Page |
53
+ | ---------------------------------------------------------- | ------------------------ | ------------------------- | -------------------------------------- |
54
+ | Pousser des **événements texte** lus par `EventSource` | `this.renderSse()` | `context.openSse()` | [Flux SSE](./docs/sse.md) |
55
+ | Servir des **octets** : processus, relais `fetch`, archive | `this.renderStream(src)` | `context.openStream(src)` | [Flux d'octets](./docs/byte-stream.md) |
56
+
57
+ ```ts
58
+ const tar = spawn("tar", ["-cz", "-C", "var", "logs"], {
59
+ stdio: ["ignore", "pipe", "ignore"],
60
+ });
61
+ try {
62
+ const { reason, bytes } = await this.renderStream(tar.stdout, {
63
+ headers: { "content-type": "application/gzip" },
64
+ });
65
+ } finally {
66
+ tar.kill("SIGKILL");
67
+ }
68
+ ```
69
+
46
70
  ## Rate-limit général par IP
47
71
 
48
72
  Plafond de trafic **par IP cliente** sur toutes les routes HTTP — protège la capacité du serveur
@@ -1,4 +1,4 @@
1
- // \0@oxc-project+runtime@0.155.0/helpers/esm/decorate.js
1
+ // \0@oxc-project+runtime@0.157.0/helpers/esm/decorate.js
2
2
  function __decorate(decorators, target, key, desc) {
3
3
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
4
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -1,4 +1,4 @@
1
- // \0@oxc-project+runtime@0.155.0/helpers/esm/decorateParam.js
1
+ // \0@oxc-project+runtime@0.157.0/helpers/esm/decorateParam.js
2
2
  function __decorateParam(paramIndex, decorator) {
3
3
  return function(target, key) {
4
4
  decorator(target, key, paramIndex);
package/dist/index.js CHANGED
@@ -1,31 +1,8 @@
1
- import { normalizePrefix, parseProxyOrigin, proxyMountProblems } from "./nodefony/src/proxy/rules.js";
2
1
  import config, { httpConfigSchema, httpOverlaySchema } from "./nodefony/config/config.js";
3
2
  import { defineHttpConfig, httpConfigJsonSchema } from "./nodefony/config/defineModuleConfig.js";
4
3
  import { createHttpAdminApi } from "./nodefony/service/HttpAdminApi.js";
5
- import { ACTION_SELF_PHASE, NO_ROUTE, N_PLUS_ONE_MIN_REPEATS, TOOLING_URL_PREFIX, aggregateProfiles, createProfileAggregator, isFailedProfile, sqlShape } from "./nodefony/src/profiler/aggregate.js";
6
4
  import { createProfilerAdminApi } from "./nodefony/service/ProfilerAdminApi.js";
7
5
  import { Profiler } from "./nodefony/src/profiler/Profiler.js";
8
- import HttpError from "./nodefony/src/errors/httpError.js";
9
- import { MemoryRateLimitStore } from "./nodefony/src/rateLimit/MemoryRateLimitStore.js";
10
- import { compileDomainPattern, compileDomainPatterns, compileTrustedHosts, isDomainAllowed } from "./nodefony/src/context/domainMatcher.js";
11
- import { REQUEST_TRACE_CHANNEL } from "./nodefony/src/context/requestTrace.js";
12
- import Cookie from "./nodefony/src/cookies/cookie.js";
13
- import Context from "./nodefony/src/context/Context.js";
14
- import { decideSend, readBackpressureOptions } from "./nodefony/src/context/websocket/wsBackpressure.js";
15
- import WebsocketResponse from "./nodefony/src/context/websocket/Response.js";
16
- import { FrameProfile } from "./nodefony/src/profiler/FrameProfile.js";
17
- import WebsocketContext from "./nodefony/src/context/websocket/WebsocketContext.js";
18
- import HttpRequest from "./nodefony/src/context/http/Request.js";
19
- import HttpResponse from "./nodefony/src/context/http/Response.js";
20
- import Http2Request from "./nodefony/src/context/http2/Request.js";
21
- import Http2Response from "./nodefony/src/context/http2/Response.js";
22
- import { SSE_HEARTBEAT_MS, SseStream, acceptsEventStream, openSseStream } from "./nodefony/src/context/http/SseStream.js";
23
- import HttpContext from "./nodefony/src/context/http/HttpContext.js";
24
- import DefaultErrorRenderer, { schemaMismatchOf, toValidationFields } from "./nodefony/service/error-renderer.js";
25
- import JsonAuditLogger from "./nodefony/service/audit-logger.js";
26
- import DefaultRequestLogger from "./nodefony/service/request-logger.js";
27
- import PrettyRequestLogger from "./nodefony/service/pretty-request-logger.js";
28
- import __decorate from "./_virtual/_@oxc-project_runtime@0.155.0/helpers/esm/decorate.js";
29
6
  import http_kernel_default from "./nodefony/service/http-kernel.js";
30
7
  import server_http_default from "./nodefony/service/servers/server-http.js";
31
8
  import server_https_default from "./nodefony/service/servers/server-https.js";
@@ -38,13 +15,39 @@ import Network from "./nodefony/command/networkCommand.js";
38
15
  import Certificates from "./nodefony/command/certificatesCommand.js";
39
16
  import ProxyGenerate from "./nodefony/command/proxyGenerateCommand.js";
40
17
  import AssetsPublish from "./nodefony/command/assetsPublishCommand.js";
41
- import Session from "./nodefony/src/session/session.js";
42
- import { SESSION_COLUMN_ALIASES, SESSION_DEFAULT_ORDER, SESSION_DEFAULT_ORDER_SQL, SESSION_SORTABLE_FIELDS } from "./nodefony/src/session/storage/sessionSort.js";
43
18
  import sessions_service_default from "./nodefony/service/sessions/sessions-service.js";
44
19
  import Certificate, { trustOutboundAuthorities } from "./nodefony/service/certificates.js";
20
+ import Context from "./nodefony/src/context/Context.js";
21
+ import Session from "./nodefony/src/session/session.js";
22
+ import HttpError from "./nodefony/src/errors/httpError.js";
23
+ import HttpResponse, { isContentlessStatus } from "./nodefony/src/context/http/Response.js";
24
+ import Http2Response from "./nodefony/src/context/http2/Response.js";
25
+ import WebsocketResponse from "./nodefony/src/context/websocket/Response.js";
26
+ import Cookie from "./nodefony/src/cookies/cookie.js";
45
27
  import upload_service_default from "./nodefony/service/upload/upload-service.js";
28
+ import DefaultErrorRenderer, { schemaMismatchOf, toValidationFields } from "./nodefony/service/error-renderer.js";
29
+ import DefaultRequestLogger from "./nodefony/service/request-logger.js";
30
+ import JsonAuditLogger from "./nodefony/service/audit-logger.js";
31
+ import PrettyRequestLogger from "./nodefony/service/pretty-request-logger.js";
32
+ import HttpContext from "./nodefony/src/context/http/HttpContext.js";
33
+ import WebsocketContext from "./nodefony/src/context/websocket/WebsocketContext.js";
34
+ import HttpRequest from "./nodefony/src/context/http/Request.js";
35
+ import Http2Request from "./nodefony/src/context/http2/Request.js";
36
+ import __decorate from "./_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorate.js";
37
+ import { parseRequestTarget } from "./nodefony/src/context/http/urlFastPath.js";
38
+ import { decideSend, readBackpressureOptions } from "./nodefony/src/context/websocket/wsBackpressure.js";
39
+ import { FRAME_RATE_WINDOW_MS, admitFrame, frameRateRejectedTotal, readFrameRateBound, readFrameRateCloseAfter } from "./nodefony/src/context/websocket/wsFrameRate.js";
46
40
  import { isWebsocketContext } from "./nodefony/src/context/websocket/isWebsocketContext.js";
41
+ import { REQUEST_TRACE_CHANNEL } from "./nodefony/src/context/requestTrace.js";
42
+ import { compileDomainPattern, compileDomainPatterns, compileTrustedHosts, isDomainAllowed } from "./nodefony/src/context/domainMatcher.js";
43
+ import { MemoryRateLimitStore } from "./nodefony/src/rateLimit/MemoryRateLimitStore.js";
44
+ import { SSE_HEARTBEAT_MS, SseStream, acceptsEventStream, openSseStream } from "./nodefony/src/context/http/SseStream.js";
45
+ import { openByteStream } from "./nodefony/src/context/http/ByteStream.js";
46
+ import { ACTION_SELF_PHASE, NO_ROUTE, N_PLUS_ONE_MIN_REPEATS, TOOLING_URL_PREFIX, aggregateProfiles, createProfileAggregator, isFailedProfile, sqlShape } from "./nodefony/src/profiler/aggregate.js";
47
+ import { FrameProfile } from "./nodefony/src/profiler/FrameProfile.js";
48
+ import { normalizePrefix, parseProxyOrigin, proxyMountProblems } from "./nodefony/src/proxy/rules.js";
47
49
  import { PrebuiltUi, resolveUiDelivery } from "./nodefony/src/assets/prebuiltUi.js";
50
+ import { SESSION_COLUMN_ALIASES, SESSION_DEFAULT_ORDER, SESSION_DEFAULT_ORDER_SQL, SESSION_SORTABLE_FIELDS } from "./nodefony/src/session/storage/sessionSort.js";
48
51
  import { Module, services } from "nodefony";
49
52
  // index.ts
50
53
  let Http = class Http extends Module {
@@ -116,4 +119,4 @@ Http = __decorate([services([
116
119
  upload_service_default
117
120
  ])], Http);
118
121
  var http_default = Http;
119
- export { ACTION_SELF_PHASE, Context, Cookie, DefaultErrorRenderer, DefaultRequestLogger, FrameProfile, Http2Request, Http2Response, HttpContext, HttpError, http_kernel_default as HttpKernel, HttpRequest, HttpResponse, HttpResponse as Response, JsonAuditLogger, MemoryRateLimitStore, NO_ROUTE, N_PLUS_ONE_MIN_REPEATS, PrebuiltUi, PrettyRequestLogger, Profiler, REQUEST_TRACE_CHANNEL, ReverseProxy, SESSION_COLUMN_ALIASES, SESSION_DEFAULT_ORDER, SESSION_DEFAULT_ORDER_SQL, SESSION_SORTABLE_FIELDS, SSE_HEARTBEAT_MS, Session, sessions_service_default as SessionsService, SseStream, TOOLING_URL_PREFIX, WebsocketContext, WebsocketResponse, WebsocketResponse as wsResponse, acceptsEventStream, aggregateProfiles, compileDomainPattern, compileDomainPatterns, compileTrustedHosts, createProfileAggregator, decideSend, http_default as default, defineHttpConfig, httpConfigJsonSchema, httpConfigSchema, isDomainAllowed, isFailedProfile, isWebsocketContext, normalizePrefix, openSseStream, parseProxyOrigin, proxyMountProblems, readBackpressureOptions, resolveUiDelivery, schemaMismatchOf, sqlShape, toValidationFields };
122
+ export { ACTION_SELF_PHASE, Context, Cookie, DefaultErrorRenderer, DefaultRequestLogger, FRAME_RATE_WINDOW_MS, FrameProfile, Http2Request, Http2Response, HttpContext, HttpError, http_kernel_default as HttpKernel, HttpRequest, HttpResponse, HttpResponse as Response, JsonAuditLogger, MemoryRateLimitStore, NO_ROUTE, N_PLUS_ONE_MIN_REPEATS, PrebuiltUi, PrettyRequestLogger, Profiler, REQUEST_TRACE_CHANNEL, ReverseProxy, SESSION_COLUMN_ALIASES, SESSION_DEFAULT_ORDER, SESSION_DEFAULT_ORDER_SQL, SESSION_SORTABLE_FIELDS, SSE_HEARTBEAT_MS, Session, sessions_service_default as SessionsService, SseStream, TOOLING_URL_PREFIX, WebsocketContext, WebsocketResponse, WebsocketResponse as wsResponse, acceptsEventStream, admitFrame, aggregateProfiles, compileDomainPattern, compileDomainPatterns, compileTrustedHosts, createProfileAggregator, decideSend, http_default as default, defineHttpConfig, frameRateRejectedTotal, httpConfigJsonSchema, httpConfigSchema, isContentlessStatus, isDomainAllowed, isFailedProfile, isWebsocketContext, normalizePrefix, openByteStream, openSseStream, parseProxyOrigin, parseRequestTarget, proxyMountProblems, readBackpressureOptions, readFrameRateBound, readFrameRateCloseAfter, resolveUiDelivery, schemaMismatchOf, sqlShape, toValidationFields };
@@ -1,7 +1,7 @@
1
- import { resolveTrustedHostNames } from "../src/context/domainMatcher.js";
2
- import { asServersConfig, configuredServerPort } from "../src/servers/kernelServers.js";
3
1
  import { defaultIntrospection, generateHaproxyConfig, generateNginxConfig } from "../src/proxy/generateProxyConfig.js";
4
2
  import { planAssetPublish } from "../src/assets/collectAssets.js";
3
+ import { asServersConfig, configuredServerPort } from "../src/servers/kernelServers.js";
4
+ import { resolveTrustedHostNames } from "../src/context/domainMatcher.js";
5
5
  import { Command } from "nodefony";
6
6
  import fsp from "node:fs/promises";
7
7
  import { isAbsolute, resolve } from "node:path";
@@ -101,7 +101,7 @@ const securityHeadersSchema = z.strictObject({
101
101
  }),
102
102
  frameOptions: z.string().nullable().default("DENY").meta({
103
103
  runtimeMutable: true,
104
- description: "X-Frame-Options — anti-clickjacking. `DENY` (recommandé) | `SAMEORIGIN`. Superposé par CSP `frame-ancestors`. `null` = désactivé. Éditable à chaud (recompute HttpKernel)."
104
+ description: "Anti-clickjacking — pilote `X-Frame-Options` ET la directive CSP `frame-ancestors` (posée par @nodefony/security) : `DENY` (défaut, 'none') | `SAMEORIGIN` ('self') | `null` (aucun des deux). Encadrement par une origine tierce : `frame-ancestors <origine>` dans `security.headers.csp`, qui l’emporte. Éditable à chaud."
105
105
  }),
106
106
  strictTransportSecurity: strictTransportSecuritySchema.nullable().default(() => strictTransportSecuritySchema.parse({})).describe("HSTS — voir sous-schéma. `null` = header désactivé.")
107
107
  }).describe("En-têtes de sécurité HTTP — defaults OWASP secure-by-default, posés en amont du pipeline (couvre aussi les statics).");
@@ -224,7 +224,9 @@ const websocketSchema = z.looseObject({
224
224
  allowSynchronousEvents: z.boolean().default(true).describe("Émet les events ('message', 'ping'…) de plusieurs frames lues dans un même chunk réseau de façon synchrone (ws@8.18+). true = meilleur débit ; false = un event par tick d'event loop (latence plus régulière, plus équitable entre connexions)."),
225
225
  maxBackpressure: z.number().int().nonnegative().default(4194304).describe("Seuil (octets) du buffer d'envoi par connexion (`ws.bufferedAmount`) au-delà duquel `backpressurePolicy` s'applique. Borne la RAM d'envoi face à un client LENT À RECEVOIR (anti-OOM) ; broadcast() amplifie (un seul lent peut plomber la diffusion). 0 = désactivé. Défaut 4 MiB."),
226
226
  backpressureCloseAfterDrops: z.number().int().nonnegative().default(1e3).describe("SOLDE de frames refusées au-delà duquel la connexion est FERMÉE (1013), en politique 'drop' : +1 par frame refusée, −1 par frame envoyée. POURQUOI un solde et pas un second seuil d'octets : une fois qu'on jette, plus rien n'alimente la file — elle plafonne au seuil de drop et n'atteint jamais un seuil supérieur, qui serait donc inatteignable (mesuré). Et pas une série remise à zéro au premier envoi : la file d'un client bloqué oscille autour du seuil, chaque envoi effacerait la série et il ne serait jamais fermé. Le solde, lui, distingue le pic passager du client mort. 0 = ne jamais fermer. Défaut 1000."),
227
- backpressurePolicy: z.enum(["drop", "close"]).default("drop").describe("Action quand `ws.bufferedAmount` dépasse `maxBackpressure`. 'drop' = sauter la frame pour ce client (il reste connecté, dégradable — idéal télémétrie/broadcast, défaut). 'close' = fermer le client (RFC 6455 close 1013 « Try Again Later »). La fusion (coalesce) relève de la couche canal realtime, pas du transport.")
227
+ backpressurePolicy: z.enum(["drop", "close"]).default("drop").describe("Action quand `ws.bufferedAmount` dépasse `maxBackpressure`. 'drop' = sauter la frame pour ce client (il reste connecté, dégradable — idéal télémétrie/broadcast, défaut). 'close' = fermer le client (RFC 6455 close 1013 « Try Again Later »). La fusion (coalesce) relève de la couche canal realtime, pas du transport."),
228
+ maxFramesPerSecond: z.number().int().nonnegative().default(0).describe("Trames REÇUES admises par seconde et par connexion (unité : une trame, texte ou binaire — la taille est bornée à part par `maxPayload`). Au-delà, la trame est écartée AVANT tout décodage et comptée (`frameRateRejectedTotal` sur la sonde du hub temps réel) ; la connexion reste ouverte. 0 = désactivée (défaut de toute la série 10). Repère : 50 trames/s d'audio passent avec marge sous une borne de 200."),
229
+ frameRateCloseAfterRejects: z.number().int().nonnegative().default(1e3).describe("SOLDE de trames écartées au-delà duquel la connexion est FERMÉE (1008, violation de politique) : +1 par trame écartée, −1 par trame admise — même règle que `backpressureCloseAfterDrops`. Un pic passager ne ferme pas ; seul un flux soutenu au-delà du DOUBLE de `maxFramesPerSecond` fait monter le solde. Sans effet tant que `maxFramesPerSecond` vaut 0. 0 = ne jamais fermer. Défaut 1000.")
228
230
  }).describe("Serveur WebSocket (`ws@8`). Les options ci-dessus (+ loose : `verifyClient`, `handleProtocols`, `path`, `WebSocket`…) sont transmises à `ws`. GÉRÉ par Nodefony et donc NON exposé : `server`/`host`/`port`/`noServer` (forcé à `noServer` : l'upgrade du serveur HTTP passe d'abord par le proxy inverse, puis par `ws`), `clientTracking` (forcé true — requis par broadcast() + heartbeat), `backlog` (celui du serveur HTTP). `keepalive*`/`closeTimeout` sont des knobs Nodefony, pas des options `ws`.");
229
231
  const staticOptionsSchema = z.looseObject({
230
232
  cacheControl: z.boolean().optional().describe("Active le header Cache-Control."),
@@ -13,7 +13,8 @@ import { aggregateProfiles } from "../src/profiler/aggregate.js";
13
13
  * `?connection=<requestId>` : les seules frames de cette socket WebSocket
14
14
  * - `GET /nodefony/profiler/api/summary` → les profils de l'anneau agrégés par
15
15
  * route (percentiles, phases, formes SQL, N+1, erreurs — `aggregateProfiles`)
16
- * - `GET /nodefony/profiler/api/{id}` → profil complet (phases) d'un requestId
16
+ * - `GET /nodefony/profiler/api/{id}` → profil complet (phases) d'un requestId ;
17
+ * absent → 404 `{ startedAt, clearedAt }` : depuis quand l'anneau sait quelque chose
17
18
  * - `DELETE /nodefony/profiler/api/recent` → vide le ring buffer
18
19
  *
19
20
  * La debug bar (toute page, dev) lit `X-Request-Id` de SON appel AJAX puis
@@ -68,7 +69,9 @@ function createProfilerAdminApi(profiler) {
68
69
  status: 404,
69
70
  body: {
70
71
  error: "Profile not found",
71
- requestId: params.id
72
+ requestId: params.id,
73
+ startedAt: profiler.startedAt,
74
+ clearedAt: profiler.clearedAt
72
75
  }
73
76
  };
74
77
  return entry;
@@ -208,10 +208,11 @@ const PG_INVALID_INPUT = /^invalid input syntax for type (\w+): "(.*)"$/su;
208
208
  /**
209
209
  * Reconnaît un identifiant MAL FORMÉ refusé par la base, et rend la valeur fautive.
210
210
  *
211
- * Deux moteurs le disent : MongoDB par une `CastError` de Mongoose (la clé est
212
- * un ObjectId), PostgreSQL par `22P02` (la clé est un `uuid` ou un entier).
213
- * SQLite et MySQL rangent la clé en texte : ils ne lèvent rien, la lecture rend
214
- * simplement « rien », d'où leur 404 — c'est la cohérence avec eux qui est visée.
211
+ * Trois sources le disent : MongoDB par une `CastError` de Mongoose (la clé est
212
+ * un ObjectId), PostgreSQL par `22P02` (la clé est un `uuid` ou un entier), et
213
+ * le kit de colonnes portable par une `ColumnValueError` de genre `uuid` ou
214
+ * `integer` — il refuse sur les trois moteurs la clé que PostgreSQL refuserait. Une clé rangée en
215
+ * texte NATIF ne lève rien, la lecture rend « rien » : le même 404.
215
216
  *
216
217
  * Même descente dans `cause` que {@link isUniqueViolation} (Drizzle enveloppe).
217
218
  *
@@ -223,6 +224,7 @@ function malformedIdentifierOf(error) {
223
224
  for (let depth = 0; depth < 5 && current instanceof Error; depth += 1) {
224
225
  const candidate = current;
225
226
  if (candidate.name === "CastError" && (candidate.kind === "ObjectId" || candidate.kind === "UUID") && (typeof candidate.value === "string" || typeof candidate.value === "number")) return String(candidate.value);
227
+ if (candidate.name === "ColumnValueError" && (candidate.kind === "uuid" || candidate.kind === "integer") && typeof candidate.value === "string") return candidate.value;
226
228
  if (String(candidate.code) === "22P02") {
227
229
  const match = PG_INVALID_INPUT.exec(candidate.message);
228
230
  if (match) {
@@ -1,34 +1,41 @@
1
- import { isProfiledUrl } from "../src/profiler/aggregate.js";
2
1
  import HttpError from "../src/errors/httpError.js";
2
+ import { isUnsupportedTransferCoding } from "../src/proxy/rules.js";
3
+ import { hasConflictingAuthority } from "../src/context/transport.js";
3
4
  import { buildTrustProxy } from "../src/context/trustProxy.js";
4
5
  import { resolveForwarded } from "../src/context/forwarded.js";
5
6
  import { MemoryRateLimitStore } from "../src/rateLimit/MemoryRateLimitStore.js";
6
7
  import { WsConnectionCounter } from "../src/rateLimit/WsConnectionCounter.js";
7
8
  import { compileDomainPatterns, compileTrustedHosts, isDomainAllowed } from "../src/context/domainMatcher.js";
9
+ import { isProfiledUrl } from "../src/profiler/aggregate.js";
8
10
  import { responseEnded } from "../src/context/responseEnded.js";
9
11
  import { unsentResponseWarning } from "../src/context/unsentResponse.js";
10
12
  import { closeRequestTrace, hasRequestTraceSubscribers, openRequestTrace, runTraced } from "../src/context/requestTrace.js";
11
- import { parseWebsocketUrl } from "../src/context/websocket/websocketUrl.js";
12
13
  import WebsocketContext, { toHandshakeStatus } from "../src/context/websocket/WebsocketContext.js";
13
14
  import { trackPong } from "./servers/wsHeartbeat.js";
15
+ import { parseWebsocketUrl } from "../src/context/websocket/websocketUrl.js";
14
16
  import HttpContext from "../src/context/http/HttpContext.js";
15
17
  import DefaultErrorRenderer from "./error-renderer.js";
16
- import JsonAuditLogger from "./audit-logger.js";
17
18
  import DefaultRequestLogger from "./request-logger.js";
18
19
  import PrettyRequestLogger from "./pretty-request-logger.js";
20
+ import JsonAuditLogger from "./audit-logger.js";
19
21
  import { resolveTraceparent } from "./trace.js";
20
22
  import { asServersConfig, configuredServerPort } from "../src/servers/kernelServers.js";
21
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.155.0/helpers/esm/decorate.js";
22
- import { GcScheduler, IDENTITY_CHANGED_EVENT, RequestContext, RevocationWatch, Service, finallyMaybe, injectable, isPromise, logColor, nodefonyError, servedUrl, thenMaybe, writeRuntimeState } from "nodefony";
23
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorate.js";
24
+ import { BootConfigurationError, GcScheduler, IDENTITY_CHANGED_EVENT, RequestContext, RevocationWatch, Service, finallyMaybe, injectable, isPromise, logColor, nodefonyError, servedUrl, thenMaybe, writeRuntimeState } from "nodefony";
23
25
  import cluster from "node:cluster";
24
26
  import http from "node:http";
25
27
  // nodefony/service/http-kernel.ts
26
- const HEALTH_HEADERS = {
27
- "content-type": "application/json",
28
- "cache-control": "no-store"
29
- };
28
+ const HEALTH_HEADERS = [
29
+ "content-type",
30
+ "application/json",
31
+ "cache-control",
32
+ "no-store"
33
+ ];
30
34
  const HEALTH_BODY_OK = "{\"status\":\"ok\"}";
31
35
  const HEALTH_BODY_UNREADY = "{\"status\":\"unready\"}";
36
+ /** Corps du 429 du limiteur de débit — figé, rien à sérialiser par refus. */
37
+ const TOO_MANY_REQUESTS_BODY = "{\"code\":429,\"message\":\"Too Many Requests\"}";
38
+ const TOO_MANY_REQUESTS_LENGTH = Buffer.byteLength(TOO_MANY_REQUESTS_BODY);
32
39
  const perfProbe = process.env.NF_PERF_PROBE === "1" ? {
33
40
  count: 0,
34
41
  enterScopeNs: 0,
@@ -107,6 +114,20 @@ let HttpKernel = class HttpKernel extends Service {
107
114
  secContentTypeOptions = null;
108
115
  secFrameOptions = null;
109
116
  secHsts = null;
117
+ /**
118
+ * En-têtes du transport, tableau plat `[nom, valeur, …]` aux noms en
119
+ * minuscules, par famille de serveur (HSTS sur TLS seulement). Figés et
120
+ * VALIDÉS au calcul : chaque réponse les recopie sans les revalider.
121
+ */
122
+ #transportHeaders = {
123
+ http: [],
124
+ https: []
125
+ };
126
+ /** Réponse d'une sonde de santé : le transport, puis type et cache. */
127
+ #healthHeaders = {
128
+ http: [],
129
+ https: []
130
+ };
110
131
  sessionService;
111
132
  router;
112
133
  firewall;
@@ -157,12 +178,49 @@ let HttpKernel = class HttpKernel extends Service {
157
178
  if (hsts.preload) v += "; preload";
158
179
  this.secHsts = v;
159
180
  } else this.secHsts = null;
181
+ const headerServer = this.options.headerServer;
182
+ const http1 = [];
183
+ if (headerServer != null && headerServer !== "") http1.push("server", headerServer);
184
+ if (this.secContentTypeOptions !== null) http1.push("x-content-type-options", this.secContentTypeOptions);
185
+ if (this.secFrameOptions !== null) http1.push("x-frame-options", this.secFrameOptions);
186
+ const tls = this.secHsts === null ? http1 : [
187
+ ...http1,
188
+ "strict-transport-security",
189
+ this.secHsts
190
+ ];
191
+ for (let i = 0; i + 1 < tls.length; i += 2) {
192
+ const name = tls[i] ?? "";
193
+ try {
194
+ http.validateHeaderValue(name, tls[i + 1] ?? "");
195
+ } catch (error) {
196
+ throw new BootConfigurationError(`[@nodefony/http] en-tête « ${name} » refusé : ${error instanceof Error ? error.message : String(error)}`);
197
+ }
198
+ }
199
+ this.#transportHeaders = {
200
+ http: http1,
201
+ https: tls
202
+ };
203
+ this.#healthHeaders = {
204
+ http: [...http1, ...HEALTH_HEADERS],
205
+ https: [...tls, ...HEALTH_HEADERS]
206
+ };
207
+ }
208
+ /**
209
+ * En-têtes du transport d'une réponse, figés au démarrage — ce que
210
+ * `HttpResponse` place en tête de sa réserve.
211
+ *
212
+ * @param type - famille du serveur qui a reçu la requête.
213
+ * @returns la liste plate `[nom, valeur, …]`, noms en minuscules.
214
+ */
215
+ transportHeaders(type) {
216
+ return type === "https" ? this.#transportHeaders.https : this.#transportHeaders.http;
160
217
  }
161
218
  /**
162
219
  * Ce que ce kernel ÉMET réellement comme en-têtes de sécurité transport.
163
220
  *
164
221
  * Ces trois en-têtes (HSTS, X-Frame-Options, X-Content-Type-Options) sont posés
165
- * ici, à l'entrée brute, pour couvrir aussi les statiques et les pages d'erreur
222
+ * par `@nodefony/http` sur TOUTE réponse (en tête de la réserve de `HttpResponse`),
223
+ * statiques et pages d'erreur comprises
166
224
  * — pas par `@nodefony/security`, dont les clés homonymes sont inertes. Toute
167
225
  * console d'administration doit donc lire CE point, sinon elle affiche une
168
226
  * intention de configuration au lieu de l'état appliqué.
@@ -189,6 +247,7 @@ let HttpKernel = class HttpKernel extends Service {
189
247
  */
190
248
  onConfigChanged() {
191
249
  this.computeSecurityHeaderCaches();
250
+ this.firewall?.onTransportHeadersChanged?.();
192
251
  this._trustProxyChecker = null;
193
252
  this._wsOriginPolicy = Object.create(null);
194
253
  this.trustedHosts = this.options.trustedHosts;
@@ -296,9 +355,9 @@ let HttpKernel = class HttpKernel extends Service {
296
355
  * @param response - réponse serveur brute (HTTP/1 ou H2 compat)
297
356
  * @param ok - `true` → 200, `false` → 503 (readiness pendant boot/shutdown)
298
357
  */
299
- #respondHealth(response, ok) {
358
+ #respondHealth(response, ok, type) {
300
359
  const res = response;
301
- res.writeHead(ok ? 200 : 503, HEALTH_HEADERS);
360
+ res.writeHead(ok ? 200 : 503, type === "https" ? this.#healthHeaders.https : this.#healthHeaders.http);
302
361
  res.end(ok ? HEALTH_BODY_OK : HEALTH_BODY_UNREADY);
303
362
  }
304
363
  async init() {
@@ -608,7 +667,7 @@ let HttpKernel = class HttpKernel extends Service {
608
667
  if (!context.response.isHeaderSent()) {
609
668
  if (context.isRedirect) {
610
669
  context.isRedirect = false;
611
- context.response.response?.removeHeader("Location");
670
+ context.response.removeHeader("Location");
612
671
  }
613
672
  try {
614
673
  await context.render(result.body);
@@ -678,19 +737,14 @@ let HttpKernel = class HttpKernel extends Service {
678
737
  * pipeline.
679
738
  */
680
739
  dispatchHttpRequest(request, response, type) {
681
- const headerServer = this.options.headerServer;
682
- if (headerServer != null && headerServer !== "") response.setHeader("Server", headerServer);
683
- if (this.secContentTypeOptions !== null) response.setHeader("X-Content-Type-Options", this.secContentTypeOptions);
684
- if (this.secFrameOptions !== null) response.setHeader("X-Frame-Options", this.secFrameOptions);
685
- if (this.secHsts !== null && type === "https") response.setHeader("Strict-Transport-Security", this.secHsts);
686
740
  if (this.#healthPaths !== null) {
687
741
  const url = request.url;
688
742
  if (url === this.#healthPaths.liveness) {
689
- this.#respondHealth(response, true);
743
+ this.#respondHealth(response, true, type);
690
744
  return;
691
745
  }
692
746
  if (url === this.#healthPaths.readiness) {
693
- this.#respondHealth(response, !this.#terminating && this.kernel?.servable === true);
747
+ this.#respondHealth(response, !this.#terminating && this.kernel?.servable === true, type);
694
748
  return;
695
749
  }
696
750
  }
@@ -704,13 +758,19 @@ let HttpKernel = class HttpKernel extends Service {
704
758
  if (verdict.limited) {
705
759
  response.setHeader("Retry-After", verdict.retryAfterS);
706
760
  const res = response;
707
- res.writeHead(429);
708
- res.end();
761
+ response.setHeader("Content-Type", "application/json; charset=utf-8");
762
+ response.setHeader("Content-Length", TOO_MANY_REQUESTS_LENGTH);
763
+ res.writeHead(429, type === "https" ? this.#transportHeaders.https : this.#transportHeaders.http);
764
+ res.end(TOO_MANY_REQUESTS_BODY);
709
765
  return;
710
766
  }
711
767
  }
712
768
  }
713
- if (this.listenerCount("onServerRequest")) return this.fireAsync("onServerRequest", request, response, type).then(() => this.handle(request, response, type));
769
+ if (this.listenerCount("onServerRequest")) {
770
+ const transport = type === "https" ? this.#transportHeaders.https : this.#transportHeaders.http;
771
+ for (let i = 0; i + 1 < transport.length; i += 2) response.setHeader(transport[i] ?? "", transport[i + 1] ?? "");
772
+ return this.fireAsync("onServerRequest", request, response, type).then(() => this.handle(request, response, type));
773
+ }
714
774
  return this.handle(request, response, type);
715
775
  }
716
776
  async initServers() {
@@ -946,7 +1006,7 @@ let HttpKernel = class HttpKernel extends Service {
946
1006
  if (!isPromise(served)) return served;
947
1007
  return served.catch((e) => this.failHttp(e, created, scope));
948
1008
  } catch (e) {
949
- return this.failHttp(e, context, scope);
1009
+ return this.failHttp(e, context, scope, response);
950
1010
  }
951
1011
  }
952
1012
  /**
@@ -986,9 +1046,19 @@ let HttpKernel = class HttpKernel extends Service {
986
1046
  /**
987
1047
  * Chemin d'erreur de {@link handleHttp} : libère le scope d'un contexte qui
988
1048
  * n'a pas pu se construire, puis rend l'erreur au client (`onError`).
1049
+ *
1050
+ * Sans contexte, `onError` n'a rien sur quoi écrire : la réponse BRUTE
1051
+ * reçoit alors un 500 nu. Sinon HTTP/1.1 coupait la connexion sans un octet
1052
+ * et HTTP/2 laissait le flux pendu.
989
1053
  */
990
- failHttp(e, context, scope) {
991
- if (context === null) this.container?.leaveScope(scope);
1054
+ failHttp(e, context, scope, response) {
1055
+ if (context === null) {
1056
+ this.container?.leaveScope(scope);
1057
+ if (response && !response.headersSent) {
1058
+ response.statusCode = 500;
1059
+ response.end();
1060
+ }
1061
+ }
992
1062
  return this.onError(e, context);
993
1063
  }
994
1064
  /**
@@ -1020,6 +1090,7 @@ let HttpKernel = class HttpKernel extends Service {
1020
1090
  */
1021
1091
  routeHttpRequest(httpContext, request, response) {
1022
1092
  if (this.reverseProxy !== null && this.reverseProxy.mounts !== null && (httpContext.validDomain || !this.domainCheck)) {
1093
+ if (this.reverseProxy.match(request.url) !== void 0) httpContext.response.commitHeaders();
1023
1094
  const relayed = this.reverseProxy.forward(request, response, httpContext.scheme);
1024
1095
  if (relayed !== void 0) {
1025
1096
  httpContext.relayAccessLog = this.reverseProxy.match(request.url)?.accessLog ?? "all";
@@ -1029,10 +1100,14 @@ let HttpKernel = class HttpKernel extends Service {
1029
1100
  httpContext.phaseStart("resolve");
1030
1101
  httpContext.resolver = this.router ? this.router.resolve(httpContext) : null;
1031
1102
  httpContext.phaseEnd("resolve");
1032
- this.firewall?.applySecurityHeaders(httpContext);
1103
+ if (this.firewall) httpContext.response.headerPolicy = this.firewall;
1033
1104
  if (this.serverStatic && httpContext.resolver?.resolve !== true && !httpContext.resolver?.exception && (asServersConfig(this.kernel?.options.servers)?.statics || this.kernel?.options.statics || this.serverStatic.hasMounts())) {
1034
- response.removeHeader("Content-Type");
1035
- return this.serverStatic.handle(request, response).catch(() => void 0).then(() => this.serveHttpRequest(httpContext));
1105
+ httpContext.response.removeHeader("Content-Type");
1106
+ httpContext.response.commitBeforeNativeWrite();
1107
+ return this.serverStatic.handle(request, response).catch(() => void 0).then(() => {
1108
+ httpContext.response.disarmNativeWrite();
1109
+ return this.serveHttpRequest(httpContext);
1110
+ });
1036
1111
  }
1037
1112
  return this.serveHttpRequest(httpContext);
1038
1113
  }
@@ -1041,6 +1116,9 @@ let HttpKernel = class HttpKernel extends Service {
1041
1116
  * ({@link onRequestEnd}) et l'action. Chacun n'est attendu que s'il attend.
1042
1117
  */
1043
1118
  serveHttpRequest(httpContext) {
1119
+ const headers = httpContext.request.headers;
1120
+ if (isUnsupportedTransferCoding(headers["transfer-encoding"])) return Promise.reject(new HttpError("Transfer-Encoding not implemented", 501, httpContext));
1121
+ if (hasConflictingAuthority(headers)) return Promise.reject(new HttpError("Host differs from :authority", 400, httpContext));
1044
1122
  const streamBody = httpContext.resolver?.resolve === true && httpContext.resolver.route?.bodyStream === true;
1045
1123
  httpContext.phaseStart("parse");
1046
1124
  const parsed = finallyMaybe(() => streamBody ? void 0 : httpContext.request.initialize(), () => httpContext.phaseEnd("parse"));
@@ -1098,8 +1176,8 @@ let HttpKernel = class HttpKernel extends Service {
1098
1176
  if (this.listenerCount("afterAuth")) await this.fireAsync("afterAuth", context);
1099
1177
  } catch (authError) {
1100
1178
  if (context.resolver?.exception) {
1101
- context.response.response?.removeHeader("Upgrade");
1102
- context.response.response?.removeHeader("Connection");
1179
+ context.response.removeHeader("Upgrade");
1180
+ context.response.removeHeader("Connection");
1103
1181
  }
1104
1182
  await this.fireAsync("onAuthFailure", context, authError).catch((e) => this.log(e, "ERROR", "onAuthFailure"));
1105
1183
  throw authError;
@@ -1424,12 +1502,12 @@ let HttpKernel = class HttpKernel extends Service {
1424
1502
  return true;
1425
1503
  }
1426
1504
  /**
1427
- * Inscrit un flux SSE à identité révocable : il sera fermé dans la fenêtre de
1505
+ * Inscrit un flux (événements ou octets) à identité révocable : il sera fermé dans la fenêtre de
1428
1506
  * re-validation qui suit la mort de son identité (déconnexion, session
1429
1507
  * révoquée, jeton expiré ou annulé) — le même registre et la même règle que
1430
1508
  * les sockets WebSocket.
1431
1509
  *
1432
- * @internal — appelé par le flux lui-même.
1510
+ * @internal — appelé par le cycle de vie du flux (`StreamLifecycle`).
1433
1511
  */
1434
1512
  watchStream(stream) {
1435
1513
  (this.#streams ??= new RevocationWatch({
@@ -1443,7 +1521,7 @@ let HttpKernel = class HttpKernel extends Service {
1443
1521
  this.#streams?.unregister(stream);
1444
1522
  }
1445
1523
  /**
1446
- * Re-valide tous les flux SSE et ferme ceux dont l'identité est morte.
1524
+ * Re-valide tous les flux en cours et ferme ceux dont l'identité est morte.
1447
1525
  * Publique pour qu'un banc la déclenche sans attendre le tick.
1448
1526
  */
1449
1527
  revalidateStreams() {
@@ -1,5 +1,5 @@
1
- import { handshakeRefusalStatus } from "../src/context/websocket/WebsocketContext.js";
2
1
  import { severityFromStatus, statusOf } from "./audit-logger.js";
2
+ import { handshakeRefusalStatus } from "../src/context/websocket/WebsocketContext.js";
3
3
  import { accessStatusFamily, logColor } from "nodefony";
4
4
  // nodefony/service/pretty-request-logger.ts
5
5
  /**
@@ -1,5 +1,5 @@
1
- import { handshakeRefusalStatus } from "../src/context/websocket/WebsocketContext.js";
2
1
  import { severityFromStatus } from "./audit-logger.js";
2
+ import { handshakeRefusalStatus } from "../src/context/websocket/WebsocketContext.js";
3
3
  import { logColor } from "nodefony";
4
4
  // nodefony/service/request-logger.ts
5
5
  /**
@@ -1,6 +1,6 @@
1
+ import { forwardRequest, forwardUpgrade, refuseMalformedHandshake, refuseUpgrade } from "../src/proxy/forward.js";
1
2
  import { isAmbiguousPath, normalizePrefix, parseProxyOrigin, proxyMountProblems, websocketHandshakeProblem } from "../src/proxy/rules.js";
2
3
  import { requestAuthority } from "../src/context/transport.js";
3
- import { forwardRequest, forwardUpgrade, refuseUpgrade } from "../src/proxy/forward.js";
4
4
  import { Service } from "nodefony";
5
5
  import http from "node:http";
6
6
  import { randomBytes } from "node:crypto";
@@ -275,10 +275,15 @@ var ReverseProxy = class extends Service {
275
275
  refuseUpgrade(socket, 421, "Misdirected Request");
276
276
  return true;
277
277
  }
278
- if (hostname === null || isAmbiguousPath(req.url ?? "/") || websocketHandshakeProblem(req.method, req.headers) !== null) {
278
+ if (hostname === null || isAmbiguousPath(req.url ?? "/")) {
279
279
  refuseUpgrade(socket, 400, "Bad Request");
280
280
  return true;
281
281
  }
282
+ const problem = websocketHandshakeProblem(req.method, req.headers);
283
+ if (problem !== null) {
284
+ refuseMalformedHandshake(socket, problem);
285
+ return true;
286
+ }
282
287
  const secure = req.socket instanceof TLSSocket;
283
288
  if (rules !== void 0 && !rules.isWebsocketOriginAllowed(req.headers.origin, hostname, secure)) {
284
289
  refuseUpgrade(socket, 403, "Forbidden");
@@ -1,7 +1,8 @@
1
1
  import { isPeerGoneError } from "../../src/context/websocket/Response.js";
2
2
  // nodefony/service/servers/clientError.ts
3
3
  /**
4
- * Gère l'event `clientError` d'un `http.Server` / `https.Server`.
4
+ * Gère l'event `clientError` d'un `http.Server` / `https.Server`, et des connexions
5
+ * HTTP/1.1 d'un serveur HTTP/2 à `allowHTTP1`.
5
6
  *
6
7
  * ⚠️ Doc Node (`http.Server` event `'clientError'`) : dès qu'un listener
7
8
  * `clientError` est attaché, Node **cesse de fermer le socket automatiquement**
@@ -13,9 +14,32 @@ import { isPeerGoneError } from "../../src/context/websocket/Response.js";
13
14
  * @param socket - flux client brut (l'event `clientError` fournit un `Duplex`).
14
15
  */
15
16
  function handleClientError(error, socket) {
16
- if (error.code === "ECONNRESET" || !socket.writable) return;
17
- const statusLine = error.code === "HPE_HEADER_OVERFLOW" ? "431 Request Header Fields Too Large" : "400 Bad Request";
18
- socket.end(`HTTP/1.1 ${statusLine}\r\nConnection: close\r\n\r\n`);
17
+ const code = "code" in error ? error.code : void 0;
18
+ if (code === "ECONNRESET" || !socket.writable) return;
19
+ const statusLine = code === "HPE_HEADER_OVERFLOW" ? overflowStatusLine(error) : "400 Bad Request";
20
+ socket.end(`HTTP/1.1 ${statusLine}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
21
+ }
22
+ /**
23
+ * Le statut d'un `HPE_HEADER_OVERFLOW` : Node lève la même erreur pour une
24
+ * cible trop longue et pour des en-têtes trop longs, alors que la norme les
25
+ * distingue — 414 pour la cible (RFC 9112 §3), 431 pour les en-têtes
26
+ * (RFC 6585 §5).
27
+ *
28
+ * Le parseur ne constate le débordement qu'à la FIN d'un élément (la cible,
29
+ * une valeur d'en-tête), au point `bytesParsed` du paquet reçu (`rawPacket`).
30
+ * Si la ligne de requête était finie, son `CRLF` — ou celui d'un en-tête —
31
+ * précède ce point : ce sont les en-têtes qui débordent. Sinon, c'est la cible.
32
+ * Constaté sur Node 26, en un paquet comme en plusieurs. Faute de ces deux
33
+ * champs, 431 : le statut d'avant.
34
+ */
35
+ function overflowStatusLine(error) {
36
+ const raw = "rawPacket" in error ? error.rawPacket : void 0;
37
+ const parsed = "bytesParsed" in error ? error.bytesParsed : void 0;
38
+ if (Buffer.isBuffer(raw) && typeof parsed === "number") {
39
+ const eol = raw.indexOf("\r\n");
40
+ if (eol === -1 || eol >= parsed) return "414 URI Too Long";
41
+ }
42
+ return "431 Request Header Fields Too Large";
19
43
  }
20
44
  /**
21
45
  * Gère l'event `sessionError` d'un serveur HTTP/2 (h2c ou TLS) : un pair PARTI
@@ -1,10 +1,10 @@
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
1
  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";
2
+ import { handleSessionError } from "./clientError.js";
7
3
  import { http2ServerLimits, setHttp2SessionTimeout } from "../../src/servers/http2Limits.js";
4
+ import { bindWithFallback, buildBindPlan, h2cPortConflict } from "../../src/servers/portBinder.js";
5
+ import { asServersConfig, configuredServerPort, isH2cEnabled } from "../../src/servers/kernelServers.js";
6
+ import __decorateParam from "../../../_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorateParam.js";
7
+ import __decorate from "../../../_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorate.js";
8
8
  import { BootConfigurationError, Inject, Service, nodefonyError } from "nodefony";
9
9
  import http2 from "node:http2";
10
10
  // nodefony/service/servers/server-h2c.ts