@nodefony/http 10.0.0-beta.4 → 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 (103) 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 -25
  5. package/dist/nodefony/command/proxyGenerateCommand.js +2 -2
  6. package/dist/nodefony/config/config.js +6 -3
  7. package/dist/nodefony/interfaces/IRequestTrace.js +1 -0
  8. package/dist/nodefony/service/ProfilerAdminApi.js +17 -3
  9. package/dist/nodefony/service/audit-logger.js +3 -17
  10. package/dist/nodefony/service/error-renderer.js +6 -4
  11. package/dist/nodefony/service/http-kernel.js +191 -40
  12. package/dist/nodefony/service/pretty-request-logger.js +11 -10
  13. package/dist/nodefony/service/request-logger.js +1 -1
  14. package/dist/nodefony/service/reverse-proxy.js +7 -2
  15. package/dist/nodefony/service/servers/clientError.js +28 -4
  16. package/dist/nodefony/service/servers/server-h2c.js +5 -5
  17. package/dist/nodefony/service/servers/server-http.js +5 -3
  18. package/dist/nodefony/service/servers/server-https.js +12 -5
  19. package/dist/nodefony/service/servers/server-static.js +1 -6
  20. package/dist/nodefony/service/servers/server-websocket-secure.js +6 -10
  21. package/dist/nodefony/service/servers/server-websocket.js +6 -10
  22. package/dist/nodefony/service/servers/serverShutdown.js +63 -4
  23. package/dist/nodefony/service/sessions/sessions-service.js +3 -3
  24. package/dist/nodefony/service/upload/upload-service.js +2 -2
  25. package/dist/nodefony/src/assets/prebuiltUi.js +23 -5
  26. package/dist/nodefony/src/context/Context.js +56 -13
  27. package/dist/nodefony/src/context/http/ByteStream.js +359 -0
  28. package/dist/nodefony/src/context/http/HttpContext.js +75 -25
  29. package/dist/nodefony/src/context/http/Request.js +15 -6
  30. package/dist/nodefony/src/context/http/Response.js +354 -35
  31. package/dist/nodefony/src/context/http/SseStream.js +81 -146
  32. package/dist/nodefony/src/context/http/StreamLifecycle.js +273 -0
  33. package/dist/nodefony/src/context/http/urlFastPath.js +80 -1
  34. package/dist/nodefony/src/context/http2/Request.js +1 -1
  35. package/dist/nodefony/src/context/http2/Response.js +28 -5
  36. package/dist/nodefony/src/context/requestTrace.js +104 -0
  37. package/dist/nodefony/src/context/timingMode.js +21 -0
  38. package/dist/nodefony/src/context/transport.js +20 -1
  39. package/dist/nodefony/src/context/unsentResponse.js +30 -0
  40. package/dist/nodefony/src/context/websocket/Response.js +4 -2
  41. package/dist/nodefony/src/context/websocket/WebsocketContext.js +81 -20
  42. package/dist/nodefony/src/context/websocket/websocketUrl.js +7 -0
  43. package/dist/nodefony/src/context/websocket/wsBackpressure.js +15 -15
  44. package/dist/nodefony/src/context/websocket/wsFrameRate.js +71 -0
  45. package/dist/nodefony/src/context/websocket/wsLogContent.js +55 -17
  46. package/dist/nodefony/src/cookies/cookie.js +54 -13
  47. package/dist/nodefony/src/profiler/FrameProfile.js +22 -0
  48. package/dist/nodefony/src/profiler/Profiler.js +25 -22
  49. package/dist/nodefony/src/profiler/aggregate.js +26 -6
  50. package/dist/nodefony/src/proxy/forward.js +109 -9
  51. package/dist/nodefony/src/proxy/generateProxyConfig.js +6 -2
  52. package/dist/nodefony/src/proxy/rules.js +29 -14
  53. package/dist/nodefony/src/servers/upgradeDispatch.js +62 -4
  54. package/dist/types/index.d.ts +8 -0
  55. package/dist/types/nodefony/config/config.d.ts +8 -0
  56. package/dist/types/nodefony/interfaces/IContext.d.ts +9 -0
  57. package/dist/types/nodefony/interfaces/IRequestTrace.d.ts +56 -0
  58. package/dist/types/nodefony/interfaces/IReverseProxy.d.ts +8 -0
  59. package/dist/types/nodefony/interfaces/IRouting.d.ts +16 -0
  60. package/dist/types/nodefony/interfaces/ISecurity.d.ts +27 -2
  61. package/dist/types/nodefony/interfaces/index.d.ts +1 -0
  62. package/dist/types/nodefony/service/ProfilerAdminApi.d.ts +5 -1
  63. package/dist/types/nodefony/service/audit-logger.d.ts +3 -11
  64. package/dist/types/nodefony/service/error-renderer.d.ts +5 -4
  65. package/dist/types/nodefony/service/http-kernel.d.ts +65 -2
  66. package/dist/types/nodefony/service/servers/clientError.d.ts +3 -2
  67. package/dist/types/nodefony/service/servers/serverShutdown.d.ts +5 -3
  68. package/dist/types/nodefony/src/assets/prebuiltUi.d.ts +9 -4
  69. package/dist/types/nodefony/src/context/Context.d.ts +25 -1
  70. package/dist/types/nodefony/src/context/http/ByteStream.d.ts +109 -0
  71. package/dist/types/nodefony/src/context/http/HttpContext.d.ts +60 -4
  72. package/dist/types/nodefony/src/context/http/Request.d.ts +8 -1
  73. package/dist/types/nodefony/src/context/http/Response.d.ts +134 -8
  74. package/dist/types/nodefony/src/context/http/SseStream.d.ts +32 -7
  75. package/dist/types/nodefony/src/context/http/StreamLifecycle.d.ts +53 -0
  76. package/dist/types/nodefony/src/context/http/urlFastPath.d.ts +40 -0
  77. package/dist/types/nodefony/src/context/http2/Response.d.ts +17 -3
  78. package/dist/types/nodefony/src/context/requestTrace.d.ts +64 -0
  79. package/dist/types/nodefony/src/context/timingMode.d.ts +23 -0
  80. package/dist/types/nodefony/src/context/transport.d.ts +17 -0
  81. package/dist/types/nodefony/src/context/unsentResponse.d.ts +16 -0
  82. package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +41 -1
  83. package/dist/types/nodefony/src/context/websocket/websocketUrl.d.ts +4 -0
  84. package/dist/types/nodefony/src/context/websocket/wsBackpressure.d.ts +12 -2
  85. package/dist/types/nodefony/src/context/websocket/wsFrameRate.d.ts +69 -0
  86. package/dist/types/nodefony/src/context/websocket/wsLogContent.d.ts +16 -8
  87. package/dist/types/nodefony/src/cookies/cookie.d.ts +20 -2
  88. package/dist/types/nodefony/src/profiler/FrameProfile.d.ts +15 -0
  89. package/dist/types/nodefony/src/profiler/Profiler.d.ts +27 -102
  90. package/dist/types/nodefony/src/profiler/aggregate.d.ts +23 -106
  91. package/dist/types/nodefony/src/proxy/forward.d.ts +30 -2
  92. package/dist/types/nodefony/src/proxy/rules.d.ts +20 -0
  93. package/dist/types/nodefony/src/servers/upgradeDispatch.d.ts +41 -1
  94. package/docs/byte-stream.md +371 -0
  95. package/docs/cookies.md +13 -3
  96. package/docs/diagnostics-channel.md +303 -0
  97. package/docs/index.md +14 -1
  98. package/docs/observabilite.md +94 -16
  99. package/docs/reverse-proxy.md +33 -19
  100. package/docs/servers.md +26 -15
  101. package/docs/session.md +3 -3
  102. package/docs/sse.md +27 -20
  103. package/package.json +16 -16
@@ -1,5 +1,7 @@
1
- import { isLoopbackHostname } from "nodefony";
1
+ import { isLoopbackHostname, parseBareOrigin } from "nodefony";
2
2
  // nodefony/src/proxy/rules.ts
3
+ /** Schémas d’amont d’un relais : un proxy inverse ne parle que HTTP. */
4
+ const PROXY_ORIGIN_PROTOCOLS = ["http:", "https:"];
3
5
  /**
4
6
  * Règles d'un montage du proxy inverse — UNIQUE source, lue par le schéma de
5
7
  * configuration (`proxy.mounts`) ET par `ReverseProxy.mount()` : un montage
@@ -49,17 +51,7 @@ function normalizePrefix(prefix) {
49
51
  * @returns l'URL analysée, ou `null` si ce n'est pas une origine http(s) nue
50
52
  */
51
53
  function parseProxyOrigin(origin) {
52
- let url;
53
- try {
54
- url = new URL(origin);
55
- } catch {
56
- return null;
57
- }
58
- if (url.protocol !== "http:" && url.protocol !== "https:") return null;
59
- if (!/^https?:\/\/[^/?#]+\/?$/i.test(origin.trim())) return null;
60
- if (url.search !== "" || url.hash !== "") return null;
61
- if (url.username !== "" || url.password !== "") return null;
62
- return url;
54
+ return parseBareOrigin(origin, PROXY_ORIGIN_PROTOCOLS);
63
55
  }
64
56
  /**
65
57
  * Examine un montage. Rend TOUS les refus, pas le premier : une configuration
@@ -163,6 +155,29 @@ const UPGRADE_WEBSOCKET = /^\s*websocket\s*$/i;
163
155
  /** Le jeton `upgrade` dans une liste `Connection` (RFC 9110 §7.6.1). */
164
156
  const CONNECTION_UPGRADE = /(?:^|,)\s*upgrade\s*(?:,|$)/i;
165
157
  /**
158
+ * Le corps reçu porte-t-il un codage de transfert que le serveur ne sait pas
159
+ * défaire ? Seul `chunked` est compris : Node le retire, mais laisse en place
160
+ * tout codage qui le précède (`gzip, chunked`) — le corps livré serait encore
161
+ * compressé. RFC 9112 §6.1 : un tel message reçoit 501. Un `chunked` qui n'est
162
+ * pas le DERNIER codage est déjà refusé par Node en 400 (§6.3).
163
+ *
164
+ * Seule règle, pour le serveur d'origine et pour le proxy inverse. Chemin de
165
+ * chaque requête : une lecture d'en-tête, rien d'autre sans `Transfer-Encoding`.
166
+ *
167
+ * @param coding - valeur de l'en-tête `Transfer-Encoding`, absente le plus souvent
168
+ * @returns `true` quand le corps ne peut pas être lu tel qu'annoncé
169
+ */
170
+ function isUnsupportedTransferCoding(coding) {
171
+ if (coding === void 0 || coding === "chunked") return false;
172
+ return coding.trim().toLowerCase() !== "chunked";
173
+ }
174
+ /**
175
+ * Raison rendue par {@link websocketHandshakeProblem} quand le client parle une
176
+ * autre version du protocole : la seule qui appelle une réponse distincte —
177
+ * le client doit apprendre vers quelle version se rabattre (RFC 6455 §4.4).
178
+ */
179
+ const WS_UNSUPPORTED_VERSION = "Sec-WebSocket-Version ≠ 13";
180
+ /**
166
181
  * Un upgrade est-il un handshake WebSocket conforme (RFC 6455 §4.2.1) ? Seul
167
182
  * un tel handshake est relayé : un `Upgrade` quelconque (`h2c`…) raccordé
168
183
  * octet pour octet ouvrirait un tunnel que rien n'inspecte.
@@ -180,9 +195,9 @@ function websocketHandshakeProblem(method, headers) {
180
195
  if (typeof upgrade !== "string" || !UPGRADE_WEBSOCKET.test(upgrade)) return "Upgrade autre que websocket";
181
196
  const connection = headers.connection;
182
197
  if (!(typeof connection === "string" ? CONNECTION_UPGRADE.test(connection) : Array.isArray(connection) && connection.some((token) => CONNECTION_UPGRADE.test(token)))) return "Connection sans upgrade";
183
- if (headers["sec-websocket-version"] !== "13") return "Sec-WebSocket-Version ≠ 13";
198
+ if (headers["sec-websocket-version"] !== "13") return WS_UNSUPPORTED_VERSION;
184
199
  const key = headers["sec-websocket-key"];
185
200
  if (typeof key !== "string" || !WS_KEY.test(key)) return "Sec-WebSocket-Key invalide";
186
201
  return null;
187
202
  }
188
- export { MODULE_PROXY_PREFIXES, RESERVED_PROXY_PREFIXES, isAmbiguousPath, normalizePrefix, parseProxyOrigin, proxyMountProblems, websocketHandshakeProblem };
203
+ export { MODULE_PROXY_PREFIXES, RESERVED_PROXY_PREFIXES, WS_UNSUPPORTED_VERSION, isAmbiguousPath, isUnsupportedTransferCoding, normalizePrefix, parseProxyOrigin, proxyMountProblems, websocketHandshakeProblem };
@@ -1,7 +1,60 @@
1
+ import { refuseMalformedHandshake, refuseUpgrade } from "../proxy/forward.js";
1
2
  import { websocketHandshakeProblem } from "../proxy/rules.js";
2
- import { refuseUpgrade } from "../proxy/forward.js";
3
3
  import { STATUS_CODES } from "node:http";
4
+ import { WebSocketServer } from "ws";
4
5
  // nodefony/src/servers/upgradeDispatch.ts
6
+ /**
7
+ * Sous-protocole choisi par handshake, entre {@link IUpgradeAdmission.selectSubprotocol}
8
+ * et le rappel `handleProtocols` de `ws` — qui ne reçoit que la requête. Une
9
+ * table faible plutôt qu'une variable « en cours » : un `verifyClient`
10
+ * asynchrone sépare les deux instants, et deux handshakes s'y croiseraient.
11
+ * Une entrée n'existe que pour un handshake qui a choisi.
12
+ */
13
+ const chosenSubprotocols = /* @__PURE__ */ new WeakMap();
14
+ /**
15
+ * Fabrique l'option `handleProtocols` du serveur `ws` de Nodefony : le choix
16
+ * retenu par l'admission ({@link IUpgradeAdmission.selectSubprotocol}) s'il y
17
+ * en a un, sinon `fallback` (celle de la configuration), sinon le premier
18
+ * offert — le défaut de `ws` 8, conservé pour tout contrôleur qui ne choisit
19
+ * pas.
20
+ *
21
+ * @param fallback - l'option `handleProtocols` de la configuration, s'il y en a une
22
+ * @returns le rappel à passer à `new WebSocketServer({ handleProtocols })`
23
+ */
24
+ function createSubprotocolHandler(fallback) {
25
+ return (protocols, request) => {
26
+ const chosen = chosenSubprotocols.get(request);
27
+ if (chosen !== void 0) {
28
+ chosenSubprotocols.delete(request);
29
+ return chosen !== false && protocols.has(chosen) ? chosen : false;
30
+ }
31
+ if (fallback !== void 0) return fallback(protocols, request);
32
+ const first = protocols.values().next();
33
+ return first.done === true ? false : first.value;
34
+ };
35
+ }
36
+ /**
37
+ * Construit le serveur `ws` de Nodefony à partir des options de la
38
+ * configuration, en forçant ce que Nodefony gère — seul endroit où ces
39
+ * options sont posées, pour le serveur clair comme pour le sécurisé :
40
+ *
41
+ * - `noServer` : l'upgrade passe d'abord par le proxy inverse, puis par
42
+ * {@link attachUpgradeDispatch} ;
43
+ * - `clientTracking` : requis par `broadcast()` et le heartbeat ;
44
+ * - `handleProtocols` : le sous-protocole CHOISI par le contrôleur
45
+ * ({@link createSubprotocolHandler}), celui de la configuration à défaut.
46
+ *
47
+ * @param options - options `ws` issues de la configuration du serveur WebSocket
48
+ * @returns le serveur, sans écoute propre
49
+ */
50
+ function createNodefonyWebSocketServer(options) {
51
+ return new WebSocketServer({
52
+ ...options,
53
+ noServer: true,
54
+ clientTracking: true,
55
+ handleProtocols: createSubprotocolHandler(options.handleProtocols)
56
+ });
57
+ }
5
58
  /** Défaut sans noyau : tout upgrade conforme est accepté puis émis en `connection`. */
6
59
  function acceptAll(wss) {
7
60
  return (req, admission) => {
@@ -38,6 +91,9 @@ var UpgradeAdmission = class {
38
91
  this.#onPendingError = () => socket.destroy();
39
92
  socket.on("error", this.#onPendingError);
40
93
  }
94
+ selectSubprotocol(protocol) {
95
+ if (!this.#settled) chosenSubprotocols.set(this.req, protocol);
96
+ }
41
97
  #settle() {
42
98
  if (this.#settled) return false;
43
99
  this.#settled = true;
@@ -71,6 +127,7 @@ var UpgradeAdmission = class {
71
127
  }
72
128
  refuse(status, headers) {
73
129
  if (!this.#settle()) return;
130
+ chosenSubprotocols.delete(this.req);
74
131
  refuseUpgrade(this.socket, status, STATUS_CODES[status] ?? "Refused", headers);
75
132
  }
76
133
  };
@@ -114,8 +171,9 @@ function attachUpgradeDispatch(server, wss, relay, refusal, admit = acceptAll(ws
114
171
  refuseUpgrade(socket, status, STATUS_CODES[status] ?? "Refused");
115
172
  return;
116
173
  }
117
- if (websocketHandshakeProblem(req.method, req.headers) !== null) {
118
- refuseUpgrade(socket, 400, STATUS_CODES[400] ?? "Bad Request");
174
+ const problem = websocketHandshakeProblem(req.method, req.headers);
175
+ if (problem !== null) {
176
+ refuseMalformedHandshake(socket, problem);
119
177
  return;
120
178
  }
121
179
  admit(req, new UpgradeAdmission(wss, req, socket, head));
@@ -125,4 +183,4 @@ function attachUpgradeDispatch(server, wss, relay, refusal, admit = acceptAll(ws
125
183
  server.removeListener("upgrade", onUpgrade);
126
184
  };
127
185
  }
128
- export { attachUpgradeDispatch };
186
+ export { attachUpgradeDispatch, createNodefonyWebSocketServer, createSubprotocolHandler };
@@ -68,10 +68,16 @@ declare class Http extends Module<IHttpConfig> {
68
68
  }
69
69
  export default Http;
70
70
  export { defineHttpConfig, httpConfigJsonSchema, } from "./nodefony/config/defineModuleConfig.js";
71
+ export { parseRequestTarget } from "./nodefony/src/context/http/urlFastPath.js";
72
+ export { isContentlessStatus } from "./nodefony/src/context/http/Response.js";
73
+ export type { ISplitTarget } from "./nodefony/src/context/http/urlFastPath.js";
71
74
  export { httpConfigSchema } from "./nodefony/config/config.js";
72
75
  export type { IHttpConfig, IHttpConfigInput, } from "./nodefony/interfaces/IHttpConfig.js";
73
76
  export { decideSend, readBackpressureOptions, type WsBackpressurePolicy, type WsSendDecision, type IWsBackpressureOptions, type IBackpressureSocket, type IBackpressureTarget, } from "./nodefony/src/context/websocket/wsBackpressure.js";
77
+ export { admitFrame, frameRateRejectedTotal, readFrameRateBound, readFrameRateCloseAfter, FRAME_RATE_WINDOW_MS, type IFrameRateWindow, type IWsFrameRateOptions, } from "./nodefony/src/context/websocket/wsFrameRate.js";
74
78
  export { isWebsocketContext } from "./nodefony/src/context/websocket/isWebsocketContext.js";
79
+ export { REQUEST_TRACE_CHANNEL } from "./nodefony/src/context/requestTrace.js";
80
+ export type { IRequestTrace, RequestTraceProtocol, } from "./nodefony/interfaces/IRequestTrace.js";
75
81
  export { compileDomainPattern, compileDomainPatterns, compileTrustedHosts, isDomainAllowed, } from "./nodefony/src/context/domainMatcher.js";
76
82
  export type { DomainPattern, ITrustedHostsConfig, } from "./nodefony/src/context/domainMatcher.js";
77
83
  export { MemoryRateLimitStore } from "./nodefony/src/rateLimit/MemoryRateLimitStore.js";
@@ -79,6 +85,8 @@ export type { IRateLimitStore, IRateLimitOptions, RateLimitVerdict, } from "./no
79
85
  export { Context, Session, SessionsService, HttpError, HttpKernel, Response, Http2Response, Response as HttpResponse, wsResponse, wsResponse as WebsocketResponse, Cookie, HttpContext, WebsocketContext, HttpRequest, Http2Request, DefaultErrorRenderer, DefaultRequestLogger, JsonAuditLogger, PrettyRequestLogger, Profiler, };
80
86
  export { SseStream, openSseStream, acceptsEventStream, SSE_HEARTBEAT_MS, } from "./nodefony/src/context/http/SseStream.js";
81
87
  export type { ISseStreamOptions, ISseSendOptions, } from "./nodefony/src/context/http/SseStream.js";
88
+ export { openByteStream } from "./nodefony/src/context/http/ByteStream.js";
89
+ export type { ByteStreamEndReason, ByteStreamSource, IByteStreamOptions, IByteStreamResult, } from "./nodefony/src/context/http/ByteStream.js";
82
90
  export { schemaMismatchOf, type SchemaMismatchConfidence, } from "./nodefony/service/error-renderer.js";
83
91
  export { toValidationFields, type IValidationField, } from "./nodefony/service/error-renderer.js";
84
92
  export type { ProfileEntry, ProfileSummary, ProfilePhase, ProfileQuery, ProfileSecurity, IProfileBatch, IProfilerReader, } from "./nodefony/src/profiler/Profiler.js";
@@ -115,6 +115,8 @@ export declare const httpConfigSchema: z.ZodObject<{
115
115
  close: "close";
116
116
  drop: "drop";
117
117
  }>>;
118
+ maxFramesPerSecond: z.ZodDefault<z.ZodNumber>;
119
+ frameRateCloseAfterRejects: z.ZodDefault<z.ZodNumber>;
118
120
  }, z.core.$loose>>;
119
121
  websocketSecure: z.ZodDefault<z.ZodObject<{
120
122
  keepaliveInterval: z.ZodDefault<z.ZodNumber>;
@@ -139,6 +141,8 @@ export declare const httpConfigSchema: z.ZodObject<{
139
141
  close: "close";
140
142
  drop: "drop";
141
143
  }>>;
144
+ maxFramesPerSecond: z.ZodDefault<z.ZodNumber>;
145
+ frameRateCloseAfterRejects: z.ZodDefault<z.ZodNumber>;
142
146
  }, z.core.$loose>>;
143
147
  statics: z.ZodDefault<z.ZodObject<{
144
148
  enabled: z.ZodDefault<z.ZodBoolean>;
@@ -198,6 +202,10 @@ export declare const httpConfigSchema: z.ZodObject<{
198
202
  stripHeaders: z.ZodDefault<z.ZodArray<z.ZodString>>;
199
203
  timeoutMs: z.ZodOptional<z.ZodNumber>;
200
204
  secure: z.ZodDefault<z.ZodBoolean>;
205
+ accessLog: z.ZodDefault<z.ZodEnum<{
206
+ all: "all";
207
+ errors: "errors";
208
+ }>>;
201
209
  }, z.core.$strict>>>;
202
210
  }, z.core.$strict>>;
203
211
  wsMaxConnectionsPerIp: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
@@ -127,6 +127,15 @@ export interface IContext {
127
127
  readonly phases: PhaseTiming[];
128
128
  phaseStart(name: PhaseName): void;
129
129
  phaseEnd(name: PhaseName): void;
130
+ /**
131
+ * Arrête l'horloge de la requête (au premier appel) et rend sa durée en ms —
132
+ * la SEULE durée de requête du framework, mesurée partout par défaut ; `null`
133
+ * seulement si `timing.enabled` vaut `false`.
134
+ *
135
+ * Membre REQUIS : le journal d'accès et le profileur le lisent sans repli.
136
+ * Une implémentation maison d'`IContext` qui ne mesure rien rend `null`.
137
+ */
138
+ stopClock(): number | null;
130
139
  profiling: boolean;
131
140
  securityTrace: ISecurityTrace | null;
132
141
  onAfterResponse(fn: AfterResponseHandler): void;
@@ -0,0 +1,56 @@
1
+ import type { SchemeType } from "./IContext.js";
2
+ import type { HTTPMethodType } from "./IRequest.js";
3
+ /**
4
+ * Nature de l'opération tracée : une requête HTTP, ou le handshake d'une
5
+ * connexion WebSocket.
6
+ */
7
+ export type RequestTraceProtocol = "http" | "websocket";
8
+ /**
9
+ * Message publié sur le `TracingChannel` `nodefony.http.request`, le même
10
+ * objet de `start` à `asyncEnd` pour une requête donnée.
11
+ *
12
+ * Les champs d'identité (`requestId`, `protocol`, `method`, `scheme`, `url`)
13
+ * sont posés dès `start`. Les champs de résultat (`route`, `routePath`,
14
+ * `module`, `statusCode`, `durationMs`, `error`) valent `null` jusqu'à la fin
15
+ * de la requête : un abonné les lit sur `asyncEnd`. Tout est en lecture seule
16
+ * pour l'abonné — c'est Nodefony qui les remplit.
17
+ */
18
+ export interface IRequestTrace {
19
+ /** Identifiant de corrélation de la requête (`X-Request-Id` entrant, sinon généré). */
20
+ readonly requestId: string;
21
+ /** `"http"` pour une requête, `"websocket"` pour un handshake. */
22
+ readonly protocol: RequestTraceProtocol;
23
+ /** Méthode de la requête (`"WEBSOCKET"` pour un handshake). */
24
+ readonly method: HTTPMethodType | null;
25
+ /** Schéma vu par le client, proxy de confiance compris (`url.scheme` d'OpenTelemetry). */
26
+ readonly scheme: SchemeType;
27
+ /** URL complète de la requête, chaîne de requête comprise — l'abonné en tire `url.path` et `url.query`. */
28
+ readonly url: string;
29
+ /** Nom de la route qui a répondu, `null` sans route (statique, 404). */
30
+ readonly route: string | null;
31
+ /**
32
+ * Gabarit de la route, préfixe compris (`/users/{id}`) : faible cardinalité,
33
+ * c'est l'attribut `http.route` d'OpenTelemetry. La racine vaut `/`. `null`
34
+ * sans route.
35
+ */
36
+ readonly routePath: string | null;
37
+ /** Module qui a signé la réponse — le même que celui de la ligne d'accès. */
38
+ readonly module: string | null;
39
+ /**
40
+ * Statut rendu au client : celui de l'amont pour une requête relayée, `101`
41
+ * pour un handshake accepté, le statut du refus sinon. `null` si le client
42
+ * est parti avant toute réponse.
43
+ */
44
+ readonly statusCode: number | null;
45
+ /**
46
+ * Durée de la requête en millisecondes, construction du contexte → dernier
47
+ * octet remis (fin du handshake en WebSocket). `null` si `timing.enabled`
48
+ * vaut `false`.
49
+ */
50
+ readonly durationMs: number | null;
51
+ /**
52
+ * Exception attrapée par le pipeline, `null` sinon. Un statut 4xx rendu
53
+ * sans exception n'en porte pas ; c'est à l'abonné de classer un statut.
54
+ */
55
+ readonly error: unknown;
56
+ }
@@ -59,6 +59,14 @@ export interface IProxyMountOptions {
59
59
  * ACCEPTÉ que vers la boucle locale (refus au montage sinon).
60
60
  */
61
61
  secure?: boolean | undefined;
62
+ /**
63
+ * Ligne d'accès des requêtes relayées : `"all"` (défaut) les écrit toutes,
64
+ * `"errors"` seulement celles en échec (statut ≥ 400). Pour un amont qui sert
65
+ * des centaines de fichiers par page (le relais de Vite) : un fichier bien
66
+ * servi n'apprend rien, et ses lignes chassaient les autres de la mémoire
67
+ * du journal.
68
+ */
69
+ accessLog?: "all" | "errors" | undefined;
62
70
  }
63
71
  /** Un montage tel que le proxy le garde : préfixe normalisé + réglages. */
64
72
  export interface IProxyMount extends IProxyMountOptions {
@@ -11,6 +11,15 @@ import type { ContextType } from "../service/http-kernel.js";
11
11
  export interface IResolvedRoute {
12
12
  /** Nom de la route (journalisation, diagnostic). */
13
13
  readonly name: string;
14
+ /**
15
+ * Gabarit de la route, préfixe compris (`/users/{id}`) — faible cardinalité,
16
+ * publié comme `routePath` sur le canal `nodefony.http.request`.
17
+ */
18
+ readonly path?: string | undefined;
19
+ /** Module propriétaire — il signe la ligne d'accès de la requête. */
20
+ readonly module?: {
21
+ readonly name: string;
22
+ } | undefined;
14
23
  /** Corps laissé en flux (`@Body({ stream: true })`) : le parse est sauté. */
15
24
  readonly bodyStream?: boolean | undefined;
16
25
  }
@@ -35,6 +44,13 @@ export interface IRouteResolver {
35
44
  } | null | undefined;
36
45
  /** Nom de l'action résolue. */
37
46
  actionName?: string | undefined;
47
+ /**
48
+ * Ce que l'action a rendu sans que rien ne soit envoyé (`"null"`,
49
+ * `"undefined"`, `"une instance de Book"`…), `null` sinon. Posé seulement
50
+ * sur la branche « l'action répond elle-même » : lu au démontage pour dire,
51
+ * en développement, POURQUOI une requête a attendu son délai d'expiration.
52
+ */
53
+ readonly unrendered?: string | null | undefined;
38
54
  /**
39
55
  * Rejoue le match d'une route sur un contexte (WS : handshake puis frames).
40
56
  *
@@ -40,12 +40,37 @@ export interface IFirewallGate {
40
40
  enforceCsrf(context: ContextType): void;
41
41
  /** Répond au preflight CORS ; `204` quand la réponse est déjà envoyée. */
42
42
  handleCors(context: ContextType): number | undefined;
43
- /** Pose les en-têtes de sécurité (CSP, HSTS…) de la réponse. */
44
- applySecurityHeaders(context: ContextType): void;
43
+ /**
44
+ * Pose les en-têtes de sécurité applicatifs (CSP, Permissions-Policy…) de la
45
+ * réponse, appelé par la réponse juste avant son `writeHead`.
46
+ *
47
+ * @param contentType - `Content-Type` de la réponse, qui choisit la politique ;
48
+ * `null` = réponse sans contenu ; omis = type inconnu (politique complète).
49
+ * @param servedAsFile - corps servi tel quel (fichier statique, relais) : aucun
50
+ * gabarit ne l'a écrit, il ne peut pas porter de nonce.
51
+ */
52
+ applySecurityHeaders(context: ContextType, contentType?: string | null, servedAsFile?: boolean): void;
45
53
  /**
46
54
  * Le revalidateur de l'identité authentifiée de la requête courante, pour une
47
55
  * connexion longue qui la gardera (flux SSE) : la même règle que les sockets
48
56
  * WebSocket. `null` pour un anonyme — rien à révoquer.
49
57
  */
50
58
  currentRevalidator?(): ((nowMs?: number) => Promise<boolean>) | null;
59
+ /**
60
+ * Prévenu quand le noyau HTTP a recalculé ses en-têtes de transport (édition à
61
+ * chaud de `securityHeaders`) : la politique de contenu suit `frameOptions`,
62
+ * qui pilote aussi `frame-ancestors`.
63
+ */
64
+ onTransportHeadersChanged?(): void;
65
+ /**
66
+ * Déclare les sources qu'un module ajoute à la politique de contenu
67
+ * (`{ "connect-src": ["wss://rt.example.com"] }`). Un second appel du même
68
+ * module REMPLACE sa contribution. Absent quand aucun pare-feu ne pose de CSP.
69
+ *
70
+ * @param moduleName - module qui contribue, clé de sa contribution
71
+ * @param fragment - sources par directive, origines exactes (sans joker)
72
+ */
73
+ registerCspOrigins?(moduleName: string, fragment: Readonly<Record<string, readonly string[]>>): void;
74
+ /** Retire la contribution CSP de `moduleName`. */
75
+ unregisterCspOrigins?(moduleName: string): void;
51
76
  }
@@ -7,4 +7,5 @@ export type { IContext, IHttpContext, IWebsocketContext, ServerType, ProtocolNam
7
7
  export type { IHttpKernel } from "./IHttpKernel.js";
8
8
  export type { IResolvedRoute, IRouteResolver, IRequestRouter, } from "./IRouting.js";
9
9
  export type { ISecurityZone, IFirewallGate } from "./ISecurity.js";
10
+ export type { IRequestTrace, RequestTraceProtocol } from "./IRequestTrace.js";
10
11
  export type { IProxyMount, IProxyMountOptions, ProxyTarget, } from "./IReverseProxy.js";
@@ -10,7 +10,11 @@ import type { Profiler } from "../src/profiler/Profiler.js";
10
10
  *
11
11
  * Endpoints :
12
12
  * - `GET /nodefony/profiler/api/recent` → derniers profils (résumés, récent → ancien)
13
- * - `GET /nodefony/profiler/api/{id}` → profil complet (phases) d'un requestId
13
+ * `?connection=<requestId>` : les seules frames de cette socket WebSocket
14
+ * - `GET /nodefony/profiler/api/summary` → les profils de l'anneau agrégés par
15
+ * route (percentiles, phases, formes SQL, N+1, erreurs — `aggregateProfiles`)
16
+ * - `GET /nodefony/profiler/api/{id}` → profil complet (phases) d'un requestId ;
17
+ * absent → 404 `{ startedAt, clearedAt }` : depuis quand l'anneau sait quelque chose
14
18
  * - `DELETE /nodefony/profiler/api/recent` → vide le ring buffer
15
19
  *
16
20
  * La debug bar (toute page, dev) lit `X-Request-Id` de SON appel AJAX puis
@@ -1,6 +1,7 @@
1
1
  import type { Severity } from "nodefony";
2
2
  import type { IRequestLogger, IRequestLogEntry } from "../interfaces/IRequestLogger.js";
3
- import type { IHttpContext, IWebsocketContext, PhaseTiming } from "../interfaces/IContext.js";
3
+ import type { IContext, IHttpContext, IWebsocketContext } from "../interfaces/IContext.js";
4
+ declare const statusOf: (context: IContext) => number | null;
4
5
  /**
5
6
  * Canonical audit log entry — 1 JSON PDU per request (P3.1).
6
7
  *
@@ -52,15 +53,6 @@ export interface AuditErrorEntry {
52
53
  /** Recursive — capped to depth 5 to avoid pathological cycles. */
53
54
  cause?: AuditErrorEntry;
54
55
  }
55
- /**
56
- * Durée totale de la requête, de la première phase (`startMs`) à maintenant.
57
- * `null` si le chronométrage est coupé (aucune phase enregistrée). Partagée
58
- * par le journal d'audit et le journal lisible — une seule définition.
59
- *
60
- * @param phases - phases chronométrées du contexte
61
- * @returns durée en millisecondes, ou `null`
62
- */
63
- declare function computeDurationMs(phases: PhaseTiming[]): number | null;
64
56
  /**
65
57
  * Severity derived from HTTP status code — RFC 9110 categories.
66
58
  * 1xx/2xx/3xx → INFO ; 4xx → WARNING ; 5xx → ERROR.
@@ -145,4 +137,4 @@ declare class JsonAuditLogger implements IRequestLogger {
145
137
  private serializeError;
146
138
  }
147
139
  export default JsonAuditLogger;
148
- export { computeDurationMs, severityFromStatus };
140
+ export { severityFromStatus, statusOf };
@@ -34,10 +34,11 @@ export declare function toValidationFields(error: Error): IValidationField[] | n
34
34
  /**
35
35
  * Reconnaît un identifiant MAL FORMÉ refusé par la base, et rend la valeur fautive.
36
36
  *
37
- * Deux moteurs le disent : MongoDB par une `CastError` de Mongoose (la clé est
38
- * un ObjectId), PostgreSQL par `22P02` (la clé est un `uuid` ou un entier).
39
- * SQLite et MySQL rangent la clé en texte : ils ne lèvent rien, la lecture rend
40
- * simplement « rien », d'où leur 404 — c'est la cohérence avec eux qui est visée.
37
+ * Trois sources le disent : MongoDB par une `CastError` de Mongoose (la clé est
38
+ * un ObjectId), PostgreSQL par `22P02` (la clé est un `uuid` ou un entier), et
39
+ * le kit de colonnes portable par une `ColumnValueError` de genre `uuid` ou
40
+ * `integer` — il refuse sur les trois moteurs la clé que PostgreSQL refuserait. Une clé rangée en
41
+ * texte NATIF ne lève rien, la lecture rend « rien » : le même 404.
41
42
  *
42
43
  * Même descente dans `cause` que {@link isUniqueViolation} (Drizzle enveloppe).
43
44
  *
@@ -134,6 +134,8 @@ declare class HttpKernel extends Service implements IHttpKernelInterface {
134
134
  private errorRenderer;
135
135
  private requestLogger;
136
136
  private profiler;
137
+ /** `timing.tooling` : profiler aussi l'outillage (lu une fois, à onReady). */
138
+ private profileTooling;
137
139
  private _trustProxyChecker;
138
140
  private _wsOriginPolicy;
139
141
  private rateLimiter;
@@ -145,11 +147,20 @@ declare class HttpKernel extends Service implements IHttpKernelInterface {
145
147
  * dans `onHttpRequest` (0 alloc/concat par requête).
146
148
  */
147
149
  private computeSecurityHeaderCaches;
150
+ /**
151
+ * En-têtes du transport d'une réponse, figés au démarrage — ce que
152
+ * `HttpResponse` place en tête de sa réserve.
153
+ *
154
+ * @param type - famille du serveur qui a reçu la requête.
155
+ * @returns la liste plate `[nom, valeur, …]`, noms en minuscules.
156
+ */
157
+ transportHeaders(type: ServerType): readonly string[];
148
158
  /**
149
159
  * Ce que ce kernel ÉMET réellement comme en-têtes de sécurité transport.
150
160
  *
151
161
  * Ces trois en-têtes (HSTS, X-Frame-Options, X-Content-Type-Options) sont posés
152
- * ici, à l'entrée brute, pour couvrir aussi les statiques et les pages d'erreur
162
+ * par `@nodefony/http` sur TOUTE réponse (en tête de la réserve de `HttpResponse`),
163
+ * statiques et pages d'erreur comprises
153
164
  * — pas par `@nodefony/security`, dont les clés homonymes sont inertes. Toute
154
165
  * console d'administration doit donc lire CE point, sinon elle affiche une
155
166
  * intention de configuration au lieu de l'état appliqué.
@@ -186,6 +197,32 @@ declare class HttpKernel extends Service implements IHttpKernelInterface {
186
197
  * tel quel plutôt que d'inventer un état.
187
198
  */
188
199
  get rateLimitStore(): IRateLimitStore | null;
200
+ /**
201
+ * Cette requête — ou cette frame du pont — se profile-t-elle ? Jamais sans
202
+ * profileur (production). L'outillage (console, profileur, journaux, Vite :
203
+ * `isProfiledUrl`, sur la règle `isConsolePath` du cœur) seulement sous
204
+ * `timing.tooling` — on ne
205
+ * mesure pas la façon dont on mesure : ni tampon SQL, ni instantané, ni place
206
+ * dans les anneaux du profileur.
207
+ *
208
+ * @param url - URL ou chemin de la requête
209
+ * @returns `true` quand le profil doit être pris
210
+ */
211
+ shouldProfile(url: string): boolean;
212
+ /**
213
+ * En développement, signale une action qui a rendu une valeur que le
214
+ * Resolver ne sait pas envoyer (`null`, `undefined`, instance de classe)
215
+ * et n'a rien envoyé elle-même : la requête attend son délai d'expiration.
216
+ * Le message nomme l'action, ce qu'elle a rendu et le geste qui convient
217
+ * (`unsentResponseWarning`).
218
+ *
219
+ * Appelé au timeout, AVANT que le 504/408 parte — après, `sended` est vrai et
220
+ * plus rien ne se signalerait — et au démontage, pour un client parti avant.
221
+ * Gratuit en production : rien n'est lu au-delà de `waitAsync`.
222
+ *
223
+ * @param context - le contexte HTTP de la requête
224
+ */
225
+ warnUnsentResponse(context: HttpContext): void;
189
226
  private configureRateLimit;
190
227
  /**
191
228
  * (Re)configure le backstop OPT-IN de connexions WS concurrentes par IP (F6c)
@@ -474,6 +511,18 @@ declare class HttpKernel extends Service implements IHttpKernelInterface {
474
511
  * @returns le contexte servi, ou sa promesse.
475
512
  */
476
513
  handleHttp(scope: Scope, request: httpRequest, response: httpResponse, type: ServerType): MaybePromise<HttpContext>;
514
+ /**
515
+ * Sert un contexte HTTP fraîchement créé : le crochet `onCreateContext`
516
+ * s'il a des écouteurs, puis le pipeline.
517
+ *
518
+ * Hot path : `fireAsync` est une fonction async → 1 Promise + 1 microtask
519
+ * MÊME à 0 listener. En prod sans @nodefony/security ces seams
520
+ * (onCreateContext/beforeResolve/afterAuth) n'ont aucun listener → garde
521
+ * `listenerCount` (O(1), 0 alloc) pour ne RIEN planifier.
522
+ *
523
+ * @returns le contexte servi, ou sa promesse
524
+ */
525
+ private dispatchHttpContext;
477
526
  /**
478
527
  * Traceparent, tampon du profileur, puis le pipeline dans la bulle ALS de la
479
528
  * requête (`RequestContext.run`).
@@ -482,6 +531,10 @@ declare class HttpKernel extends Service implements IHttpKernelInterface {
482
531
  /**
483
532
  * Chemin d'erreur de {@link handleHttp} : libère le scope d'un contexte qui
484
533
  * n'a pas pu se construire, puis rend l'erreur au client (`onError`).
534
+ *
535
+ * Sans contexte, `onError` n'a rien sur quoi écrire : la réponse BRUTE
536
+ * reçoit alors un 500 nu. Sinon HTTP/1.1 coupait la connexion sans un octet
537
+ * et HTTP/2 laissait le flux pendu.
485
538
  */
486
539
  private failHttp;
487
540
  /**
@@ -578,6 +631,16 @@ declare class HttpKernel extends Service implements IHttpKernelInterface {
578
631
  * @returns ce que rend l'action de handshake
579
632
  */
580
633
  handleWebsocket(scope: Scope, req: IncomingMessage, admission: IUpgradeAdmission, type: ServerType): Promise<unknown>;
634
+ /**
635
+ * Fin du canal `nodefony.http.request` pour un handshake interrompu par une
636
+ * exception — sans effet si un refus l'a déjà publiée (`refuseHandshake`).
637
+ * Le statut est un fait, pas une déduction : une socket rattachée dit que
638
+ * le `101` est parti ; sinon c'est le `500` du filet qui a été rendu.
639
+ *
640
+ * @param context - le contexte du handshake, `null` s'il n'a pu être construit
641
+ * @param error - l'exception qui a interrompu le pipeline
642
+ */
643
+ private closeFailedWebsocketTrace;
581
644
  private releaseOrphanWsScope;
582
645
  /**
583
646
  * Juge un handshake WebSocket AVANT son acceptation : hôte, origine,
@@ -665,7 +728,7 @@ declare class HttpKernel extends Service implements IHttpKernelInterface {
665
728
  once(event: "close", listener: () => void): unknown;
666
729
  }): boolean;
667
730
  /**
668
- * Re-valide tous les flux SSE et ferme ceux dont l'identité est morte.
731
+ * Re-valide tous les flux en cours et ferme ceux dont l'identité est morte.
669
732
  * Publique pour qu'un banc la déclenche sans attendre le tick.
670
733
  */
671
734
  revalidateStreams(): Promise<void>;
@@ -1,6 +1,7 @@
1
1
  import type { Duplex } from "node:stream";
2
2
  /**
3
- * Gère l'event `clientError` d'un `http.Server` / `https.Server`.
3
+ * Gère l'event `clientError` d'un `http.Server` / `https.Server`, et des connexions
4
+ * HTTP/1.1 d'un serveur HTTP/2 à `allowHTTP1`.
4
5
  *
5
6
  * ⚠️ Doc Node (`http.Server` event `'clientError'`) : dès qu'un listener
6
7
  * `clientError` est attaché, Node **cesse de fermer le socket automatiquement**
@@ -11,7 +12,7 @@ import type { Duplex } from "node:stream";
11
12
  * @param error - erreur émise (codes `llhttp` : `HPE_*`, ou `ECONNRESET`…).
12
13
  * @param socket - flux client brut (l'event `clientError` fournit un `Duplex`).
13
14
  */
14
- export declare function handleClientError(error: NodeJS.ErrnoException, socket: Duplex): void;
15
+ export declare function handleClientError(error: Error, socket: Duplex): void;
15
16
  /**
16
17
  * Gère l'event `sessionError` d'un serveur HTTP/2 (h2c ou TLS) : un pair PARTI
17
18
  * se tait, toute autre erreur est rapportée.
@@ -16,9 +16,11 @@ export interface IDrainTerminator {
16
16
  * Fabrique l'arrêt gracieux d'un serveur HTTP/1.1, HTTPS, HTTP/2 sur TLS ou h2c.
17
17
  *
18
18
  * Au `terminate()` (SIGTERM, `docker stop`), le serveur cesse d'accepter, puis :
19
- * - **HTTP/2** : chaque session reçoit un GOAWAY (`session.close()`) — les flux
20
- * en vol se terminent, les nouveaux sont refusés, et le client sait qu'il doit
21
- * rouvrir ailleurs au lieu de constater une coupure ;
19
+ * - **HTTP/2** : chaque session reçoit un GOAWAY en deux temps — d'abord à
20
+ * `2^31-1` ({@link announceShutdown}), puis, un aller-retour plus tard, le
21
+ * définitif (`session.close()`). Les flux en vol se terminent, les nouveaux
22
+ * sont refusés, et le client sait qu'il doit rouvrir ailleurs au lieu de
23
+ * constater une coupure ;
22
24
  * - **HTTP/1.1** : la réponse en vol part avec `Connection: close`, la socket
23
25
  * inactive est fermée tout de suite, une requête qui arrive encore sur une
24
26
  * socket gardée ouverte reçoit aussi `Connection: close`.
@@ -67,8 +67,8 @@ export interface IPrebuiltUiOptions {
67
67
  * requête. Le SPA fallback reste au controller du module (routes littérales,
68
68
  * cf. StudioController).
69
69
  *
70
- * Perf : l'index est lu UNE fois (lazy, caché) ; seule l'injection du nonce
71
- * coûte un `replaceAll` par rendu — route d'entrée UI, jamais un hot path.
70
+ * Perf : l'index est lu UNE fois (lazy, caché) et découpé UNE fois autour des
71
+ * emplacements du nonce ; un rendu ne coûte qu'un `join`.
72
72
  */
73
73
  export declare class PrebuiltUi {
74
74
  readonly publicPath: string;
@@ -76,6 +76,8 @@ export declare class PrebuiltUi {
76
76
  private readonly indexPath;
77
77
  /** Template HTML caché — `null` tant que non lu (lazy). */
78
78
  private template;
79
+ /** Index découpé autour des emplacements du nonce — `null` tant que non calculé. */
80
+ private nonceParts;
79
81
  constructor(opts: IPrebuiltUiOptions);
80
82
  /**
81
83
  * Monte `distDir` sous `publicPath` auprès de `server-static` (résolu par
@@ -90,8 +92,11 @@ export declare class PrebuiltUi {
90
92
  * Document HTML d'entrée de l'UI (index pré-buildé par Vite).
91
93
  *
92
94
  * @param nonce nonce CSP de la requête (`Context.cspNonce`) — injecté sur
93
- * chaque `<script>` ; les assets `src` same-origin passent déjà
94
- * par `'self'`, le nonce couvre un éventuel inline Vite.
95
+ * chaque `<script>`, `src` compris : sous `'strict-dynamic'`
96
+ * (ADR-0024 §C1), `'self'` est ignoré et seul le nonce autorise
97
+ * une balise ; ce qu'elle importe ensuite hérite de la confiance.
98
+ * Annoncé aussi par `<meta property="csp-nonce">` en tête de
99
+ * `<head>` (ADR-0024 §C2) : les `<style>` créés à l'exécution.
95
100
  * @returns le HTML, ou un commentaire HTML fail-loud si l'index est absent
96
101
  */
97
102
  renderIndex(nonce?: string): string;