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

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 (42) hide show
  1. package/dist/index.js +3 -2
  2. package/dist/nodefony/config/config.js +2 -1
  3. package/dist/nodefony/interfaces/IRequestTrace.js +1 -0
  4. package/dist/nodefony/service/ProfilerAdminApi.js +12 -1
  5. package/dist/nodefony/service/audit-logger.js +3 -17
  6. package/dist/nodefony/service/http-kernel.js +80 -7
  7. package/dist/nodefony/service/pretty-request-logger.js +11 -10
  8. package/dist/nodefony/src/context/Context.js +56 -13
  9. package/dist/nodefony/src/context/http/HttpContext.js +21 -0
  10. package/dist/nodefony/src/context/http2/Response.js +20 -1
  11. package/dist/nodefony/src/context/requestTrace.js +104 -0
  12. package/dist/nodefony/src/context/timingMode.js +21 -0
  13. package/dist/nodefony/src/context/unsentResponse.js +30 -0
  14. package/dist/nodefony/src/context/websocket/WebsocketContext.js +12 -8
  15. package/dist/nodefony/src/profiler/FrameProfile.js +22 -0
  16. package/dist/nodefony/src/profiler/Profiler.js +11 -21
  17. package/dist/nodefony/src/profiler/aggregate.js +26 -6
  18. package/dist/types/index.d.ts +2 -0
  19. package/dist/types/nodefony/config/config.d.ts +4 -0
  20. package/dist/types/nodefony/interfaces/IContext.d.ts +9 -0
  21. package/dist/types/nodefony/interfaces/IRequestTrace.d.ts +56 -0
  22. package/dist/types/nodefony/interfaces/IReverseProxy.d.ts +8 -0
  23. package/dist/types/nodefony/interfaces/IRouting.d.ts +16 -0
  24. package/dist/types/nodefony/interfaces/index.d.ts +1 -0
  25. package/dist/types/nodefony/service/ProfilerAdminApi.d.ts +3 -0
  26. package/dist/types/nodefony/service/audit-logger.d.ts +3 -11
  27. package/dist/types/nodefony/service/http-kernel.d.ts +50 -0
  28. package/dist/types/nodefony/src/context/Context.d.ts +25 -1
  29. package/dist/types/nodefony/src/context/http/HttpContext.d.ts +15 -1
  30. package/dist/types/nodefony/src/context/http2/Response.d.ts +12 -0
  31. package/dist/types/nodefony/src/context/requestTrace.d.ts +64 -0
  32. package/dist/types/nodefony/src/context/timingMode.d.ts +23 -0
  33. package/dist/types/nodefony/src/context/unsentResponse.d.ts +16 -0
  34. package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +1 -1
  35. package/dist/types/nodefony/src/profiler/FrameProfile.d.ts +15 -0
  36. package/dist/types/nodefony/src/profiler/Profiler.d.ts +16 -101
  37. package/dist/types/nodefony/src/profiler/aggregate.d.ts +23 -106
  38. package/docs/diagnostics-channel.md +303 -0
  39. package/docs/index.md +6 -1
  40. package/docs/observabilite.md +94 -16
  41. package/docs/reverse-proxy.md +18 -16
  42. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -2,12 +2,13 @@ import { normalizePrefix, parseProxyOrigin, proxyMountProblems } from "./nodefon
2
2
  import config, { httpConfigSchema, httpOverlaySchema } from "./nodefony/config/config.js";
3
3
  import { defineHttpConfig, httpConfigJsonSchema } from "./nodefony/config/defineModuleConfig.js";
4
4
  import { createHttpAdminApi } from "./nodefony/service/HttpAdminApi.js";
5
- import { createProfilerAdminApi } from "./nodefony/service/ProfilerAdminApi.js";
6
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
+ import { createProfilerAdminApi } from "./nodefony/service/ProfilerAdminApi.js";
7
7
  import { Profiler } from "./nodefony/src/profiler/Profiler.js";
8
8
  import HttpError from "./nodefony/src/errors/httpError.js";
9
9
  import { MemoryRateLimitStore } from "./nodefony/src/rateLimit/MemoryRateLimitStore.js";
10
10
  import { compileDomainPattern, compileDomainPatterns, compileTrustedHosts, isDomainAllowed } from "./nodefony/src/context/domainMatcher.js";
11
+ import { REQUEST_TRACE_CHANNEL } from "./nodefony/src/context/requestTrace.js";
11
12
  import Cookie from "./nodefony/src/cookies/cookie.js";
12
13
  import Context from "./nodefony/src/context/Context.js";
13
14
  import { decideSend, readBackpressureOptions } from "./nodefony/src/context/websocket/wsBackpressure.js";
@@ -115,4 +116,4 @@ Http = __decorate([services([
115
116
  upload_service_default
116
117
  ])], Http);
117
118
  var http_default = Http;
118
- 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, 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 };
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 };
@@ -297,7 +297,8 @@ const proxyMountSchema = z.strictObject({
297
297
  preserveHost: z.boolean().default(false).describe("Transmet le `Host` du client au lieu de celui de la cible. Défaut false : l'amont reçoit son propre nom (ce qu'attend un serveur qui filtre les noms d'hôte), le nom d'origine part en `X-Forwarded-Host`."),
298
298
  stripHeaders: z.array(z.string()).default([]).describe("En-têtes de requête à ne PAS transmettre (ex. `[\"cookie\", \"authorization\"]` vers un amont qui n'a pas à voir la session). Les en-têtes de connexion (RFC 9110 §7.6.1) ne le sont jamais."),
299
299
  timeoutMs: z.number().optional().describe("Délai d'inactivité avec l'amont (ms) — dépassé avant la réponse (ou avant le `101` d'un upgrade WebSocket) : 504. Absent = `proxy.timeoutMs`. Ne s'applique pas à un WebSocket établi."),
300
- secure: z.boolean().default(true).describe("Vérifie le certificat d'une cible https. `false` n'est accepté que vers la boucle locale (`127.0.0.0/8`, `localhost`, `::1`) ; ailleurs, fournir l'autorité de l'amont (`certificates.extraCaFiles`, ou `NODE_EXTRA_CA_CERTS`).")
300
+ secure: z.boolean().default(true).describe("Vérifie le certificat d'une cible https. `false` n'est accepté que vers la boucle locale (`127.0.0.0/8`, `localhost`, `::1`) ; ailleurs, fournir l'autorité de l'amont (`certificates.extraCaFiles`, ou `NODE_EXTRA_CA_CERTS`)."),
301
+ accessLog: z.enum(["all", "errors"]).default("all").describe("Ligne d'accès des requêtes relayées : `all` (défaut) les écrit toutes, `errors` seulement celles en échec (statut ≥ 400) — pour un amont qui sert beaucoup de petits fichiers.")
301
302
  }).describe("Un montage du proxy inverse : un préfixe d'URL relayé vers un amont.");
302
303
  const proxySchema = z.strictObject({
303
304
  timeoutMs: z.number().int().positive().default(3e4).describe("Délai d'inactivité par défaut de tous les montages (ms) : un amont muet au-delà rend 504 Gateway Timeout. Défaut 30 s."),
@@ -0,0 +1 @@
1
+ export {};
@@ -1,3 +1,4 @@
1
+ import { aggregateProfiles } from "../src/profiler/aggregate.js";
1
2
  // nodefony/service/ProfilerAdminApi.ts
2
3
  /**
3
4
  * Producteur `IAdminApi` du **profiler** — exposé sous `/nodefony/profiler/api/*`.
@@ -9,6 +10,9 @@
9
10
  *
10
11
  * Endpoints :
11
12
  * - `GET /nodefony/profiler/api/recent` → derniers profils (résumés, récent → ancien)
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`)
12
16
  * - `GET /nodefony/profiler/api/{id}` → profil complet (phases) d'un requestId
13
17
  * - `DELETE /nodefony/profiler/api/recent` → vide le ring buffer
14
18
  *
@@ -32,9 +36,11 @@ function createProfilerAdminApi(profiler) {
32
36
  const raw = query.limit;
33
37
  const n = typeof raw === "string" ? parseInt(raw, 10) : NaN;
34
38
  const limit = Number.isFinite(n) && n > 0 ? Math.min(n, 200) : 60;
39
+ const of = query.connection;
40
+ const connection = typeof of === "string" && of.length > 0 && of.length <= 200 ? of : void 0;
35
41
  return {
36
42
  count: profiler.size,
37
- entries: profiler.recent(limit)
43
+ entries: profiler.recent(limit, connection)
38
44
  };
39
45
  }
40
46
  },
@@ -47,6 +53,11 @@ function createProfilerAdminApi(profiler) {
47
53
  return { cleared: true };
48
54
  }
49
55
  },
56
+ {
57
+ path: "summary",
58
+ summary: "Profiles aggregated by route: percentiles, SQL, N+1, errors",
59
+ handler: () => aggregateProfiles(profiler.since(0).entries)
60
+ },
50
61
  {
51
62
  path: "{id}",
52
63
  summary: "Full profile (phase timeline) for a requestId",
@@ -1,5 +1,4 @@
1
1
  import { handshakeRefusalStatus } from "../src/context/websocket/WebsocketContext.js";
2
- import { performance } from "node:perf_hooks";
3
2
  // nodefony/service/audit-logger.ts
4
3
  const statusOf = (context) => {
5
4
  return context.response?.statusCode ?? null;
@@ -8,19 +7,6 @@ const headersOf = (context) => {
8
7
  return context.request?.headers ?? {};
9
8
  };
10
9
  /**
11
- * Durée totale de la requête, de la première phase (`startMs`) à maintenant.
12
- * `null` si le chronométrage est coupé (aucune phase enregistrée). Partagée
13
- * par le journal d'audit et le journal lisible — une seule définition.
14
- *
15
- * @param phases - phases chronométrées du contexte
16
- * @returns durée en millisecondes, ou `null`
17
- */
18
- function computeDurationMs(phases) {
19
- const first = phases.at(0);
20
- if (first === void 0 || typeof first.startMs !== "number") return null;
21
- return performance.now() - first.startMs;
22
- }
23
- /**
24
10
  * Severity derived from HTTP status code — RFC 9110 categories.
25
11
  * 1xx/2xx/3xx → INFO ; 4xx → WARNING ; 5xx → ERROR.
26
12
  * Unknown/missing status → INFO.
@@ -96,7 +82,7 @@ var JsonAuditLogger = class {
96
82
  method: ctx.method,
97
83
  url: ctx.url,
98
84
  status,
99
- durationMs: computeDurationMs(ctx.phases),
85
+ durationMs: ctx.stopClock(),
100
86
  remoteAddress: ctx.remoteAddress ?? null,
101
87
  host: ctx.getHost() ?? null,
102
88
  userAgent: ctx.getUserAgent() ?? null,
@@ -128,7 +114,7 @@ var JsonAuditLogger = class {
128
114
  method: ctx.method,
129
115
  url: ctx.url,
130
116
  status,
131
- durationMs: computeDurationMs(ctx.phases),
117
+ durationMs: ctx.stopClock(),
132
118
  remoteAddress: ctx.remoteAddress ?? null,
133
119
  host: ctx.getHost() ?? null,
134
120
  userAgent: ctx.getUserAgent() ?? null,
@@ -165,4 +151,4 @@ var JsonAuditLogger = class {
165
151
  return entry;
166
152
  }
167
153
  };
168
- export { computeDurationMs, JsonAuditLogger as default, severityFromStatus };
154
+ export { JsonAuditLogger as default, severityFromStatus, statusOf };
@@ -1,3 +1,4 @@
1
+ import { isProfiledUrl } from "../src/profiler/aggregate.js";
1
2
  import HttpError from "../src/errors/httpError.js";
2
3
  import { buildTrustProxy } from "../src/context/trustProxy.js";
3
4
  import { resolveForwarded } from "../src/context/forwarded.js";
@@ -5,6 +6,8 @@ import { MemoryRateLimitStore } from "../src/rateLimit/MemoryRateLimitStore.js";
5
6
  import { WsConnectionCounter } from "../src/rateLimit/WsConnectionCounter.js";
6
7
  import { compileDomainPatterns, compileTrustedHosts, isDomainAllowed } from "../src/context/domainMatcher.js";
7
8
  import { responseEnded } from "../src/context/responseEnded.js";
9
+ import { unsentResponseWarning } from "../src/context/unsentResponse.js";
10
+ import { closeRequestTrace, hasRequestTraceSubscribers, openRequestTrace, runTraced } from "../src/context/requestTrace.js";
8
11
  import { parseWebsocketUrl } from "../src/context/websocket/websocketUrl.js";
9
12
  import WebsocketContext, { toHandshakeStatus } from "../src/context/websocket/WebsocketContext.js";
10
13
  import { trackPong } from "./servers/wsHeartbeat.js";
@@ -110,6 +113,8 @@ let HttpKernel = class HttpKernel extends Service {
110
113
  errorRenderer = new DefaultErrorRenderer();
111
114
  requestLogger = new DefaultRequestLogger();
112
115
  profiler = null;
116
+ /** `timing.tooling` : profiler aussi l'outillage (lu une fois, à onReady). */
117
+ profileTooling = false;
113
118
  _trustProxyChecker = null;
114
119
  _wsOriginPolicy = Object.create(null);
115
120
  rateLimiter = null;
@@ -207,6 +212,36 @@ let HttpKernel = class HttpKernel extends Service {
207
212
  get rateLimitStore() {
208
213
  return this.rateLimiter;
209
214
  }
215
+ /**
216
+ * Cette requête — ou cette frame du pont — se profile-t-elle ? Jamais sans
217
+ * profileur (production). L'outillage (console, profileur, journaux, Vite :
218
+ * `isProfiledUrl`, sur la règle `isConsolePath` du cœur) seulement sous
219
+ * `timing.tooling` — on ne
220
+ * mesure pas la façon dont on mesure : ni tampon SQL, ni instantané, ni place
221
+ * dans les anneaux du profileur.
222
+ *
223
+ * @param url - URL ou chemin de la requête
224
+ * @returns `true` quand le profil doit être pris
225
+ */
226
+ shouldProfile(url) {
227
+ return this.profiler !== null && isProfiledUrl(url, this.profileTooling);
228
+ }
229
+ /**
230
+ * En développement, signale une action qui a rendu une valeur que le
231
+ * Resolver ne sait pas envoyer (`null`, `undefined`, instance de classe)
232
+ * et n'a rien envoyé elle-même : la requête attend son délai d'expiration.
233
+ * Le message nomme l'action, ce qu'elle a rendu et le geste qui convient
234
+ * (`unsentResponseWarning`).
235
+ *
236
+ * Appelé au timeout, AVANT que le 504/408 parte — après, `sended` est vrai et
237
+ * plus rien ne se signalerait — et au démontage, pour un client parti avant.
238
+ * Gratuit en production : rien n'est lu au-delà de `waitAsync`.
239
+ *
240
+ * @param context - le contexte HTTP de la requête
241
+ */
242
+ warnUnsentResponse(context) {
243
+ if (context.waitAsync && !context.sended && this.kernel?.environment === "development") this.log(unsentResponseWarning(context.resolver, context.url), "WARNING");
244
+ }
210
245
  configureRateLimit() {
211
246
  this.rateLimitGc?.stop();
212
247
  this.rateLimitGc = null;
@@ -277,6 +312,8 @@ let HttpKernel = class HttpKernel extends Service {
277
312
  this.regAlias = this.compileAlias();
278
313
  this.sessionService = this.get("sessions");
279
314
  this.profiler = this.get("profiler");
315
+ const timing = this.kernel?.options.timing;
316
+ this.profileTooling = typeof timing === "object" && timing !== null && "tooling" in timing && timing.tooling === true;
280
317
  this.configureRateLimit();
281
318
  this.configureWsConnectionLimit();
282
319
  this.configureHealth();
@@ -816,10 +853,12 @@ let HttpKernel = class HttpKernel extends Service {
816
853
  teardownHttp(context, scope) {
817
854
  if (context.finished) return void 0;
818
855
  try {
819
- if (context.waitAsync && !context.sended && this.kernel?.environment === "development") this.log(`Action "${context.resolver?.route?.name ?? context.url}" returned a non-renderable value (void/null/class instance) and never sent a response. Use 'return <object|string|number|boolean|Buffer>' (auto-JSON) or send/stream manually.`, "WARNING");
856
+ context.stopClock();
857
+ if (context.waitAsync) this.warnUnsentResponse(context);
820
858
  context.logRequest();
859
+ if (context.requestTrace !== null) closeRequestTrace(context, context.response.statusCode, context.error);
821
860
  context.logPhasesVerbose();
822
- this.profiler?.collect(context);
861
+ if (this.shouldProfile(context.url)) this.profiler?.collect(context);
823
862
  const afterResponse = context._runAfterResponse();
824
863
  if (afterResponse !== void 0 || context.listenerCount("onFinish")) return this.finishTeardownHttp(context, scope, afterResponse);
825
864
  this.releaseHttpContext(context, scope);
@@ -903,7 +942,7 @@ let HttpKernel = class HttpKernel extends Service {
903
942
  try {
904
943
  context = this.createHttpContext(scope, request, response, type);
905
944
  const created = context;
906
- const served = this.listenerCount("onCreateContext") ? this.fireAsync("onCreateContext", created).then(() => this.serveHttpContext(created, scope, request, response)) : this.serveHttpContext(created, scope, request, response);
945
+ const served = hasRequestTraceSubscribers() ? runTraced(openRequestTrace(created, "http"), this.dispatchHttpContext, this, created, scope, request, response) : this.dispatchHttpContext(created, scope, request, response);
907
946
  if (!isPromise(served)) return served;
908
947
  return served.catch((e) => this.failHttp(e, created, scope));
909
948
  } catch (e) {
@@ -911,12 +950,26 @@ let HttpKernel = class HttpKernel extends Service {
911
950
  }
912
951
  }
913
952
  /**
953
+ * Sert un contexte HTTP fraîchement créé : le crochet `onCreateContext`
954
+ * s'il a des écouteurs, puis le pipeline.
955
+ *
956
+ * Hot path : `fireAsync` est une fonction async → 1 Promise + 1 microtask
957
+ * MÊME à 0 listener. En prod sans @nodefony/security ces seams
958
+ * (onCreateContext/beforeResolve/afterAuth) n'ont aucun listener → garde
959
+ * `listenerCount` (O(1), 0 alloc) pour ne RIEN planifier.
960
+ *
961
+ * @returns le contexte servi, ou sa promesse
962
+ */
963
+ dispatchHttpContext(context, scope, request, response) {
964
+ return this.listenerCount("onCreateContext") ? this.fireAsync("onCreateContext", context).then(() => this.serveHttpContext(context, scope, request, response)) : this.serveHttpContext(context, scope, request, response);
965
+ }
966
+ /**
914
967
  * Traceparent, tampon du profileur, puis le pipeline dans la bulle ALS de la
915
968
  * requête (`RequestContext.run`).
916
969
  */
917
970
  serveHttpContext(context, scope, request, response) {
918
971
  context.traceparent = resolveTraceparent(request.headers.traceparent);
919
- const profilerQueries = this.profiler ? [] : null;
972
+ const profilerQueries = this.shouldProfile(context.url) ? [] : null;
920
973
  context.profilerQueries = profilerQueries;
921
974
  context.profiling = profilerQueries !== null;
922
975
  const store = {
@@ -968,7 +1021,10 @@ let HttpKernel = class HttpKernel extends Service {
968
1021
  routeHttpRequest(httpContext, request, response) {
969
1022
  if (this.reverseProxy !== null && this.reverseProxy.mounts !== null && (httpContext.validDomain || !this.domainCheck)) {
970
1023
  const relayed = this.reverseProxy.forward(request, response, httpContext.scheme);
971
- if (relayed !== void 0) return relayed.then(() => httpContext);
1024
+ if (relayed !== void 0) {
1025
+ httpContext.relayAccessLog = this.reverseProxy.match(request.url)?.accessLog ?? "all";
1026
+ return relayed.then(() => httpContext);
1027
+ }
972
1028
  }
973
1029
  httpContext.phaseStart("resolve");
974
1030
  httpContext.resolver = this.router ? this.router.resolve(httpContext) : null;
@@ -1146,7 +1202,7 @@ let HttpKernel = class HttpKernel extends Service {
1146
1202
  scope
1147
1203
  };
1148
1204
  context?.holdRequestStore(store);
1149
- return await RequestContext.run(store, async () => {
1205
+ const pipeline = async () => {
1150
1206
  await this.onConnect(context, error);
1151
1207
  if (this.firewall && context?.secure) {
1152
1208
  context.phaseStart("firewall");
@@ -1168,6 +1224,7 @@ let HttpKernel = class HttpKernel extends Service {
1168
1224
  const accepted = admission.accept();
1169
1225
  const ws = accepted instanceof Promise ? await accepted : accepted;
1170
1226
  if (ws === null) {
1227
+ if (context.requestTrace !== null) closeRequestTrace(context, null, null);
1171
1228
  this.releaseOrphanWsScope(scope, context);
1172
1229
  return;
1173
1230
  }
@@ -1180,20 +1237,36 @@ let HttpKernel = class HttpKernel extends Service {
1180
1237
  } finally {
1181
1238
  context.phaseEnd("action");
1182
1239
  }
1183
- });
1240
+ };
1241
+ return await (context !== null && hasRequestTraceSubscribers() ? runTraced(openRequestTrace(context, "websocket"), RequestContext.run, RequestContext, store, pipeline) : RequestContext.run(store, pipeline));
1184
1242
  } catch (e) {
1185
1243
  try {
1186
1244
  await this.onError(e, context);
1187
1245
  } catch (errorHandlingError) {
1188
1246
  admission.refuse(500);
1247
+ this.closeFailedWebsocketTrace(context, e);
1189
1248
  this.releaseOrphanWsScope(scope, context);
1190
1249
  throw errorHandlingError;
1191
1250
  }
1192
1251
  admission.refuse(500);
1252
+ this.closeFailedWebsocketTrace(context, e);
1193
1253
  this.releaseOrphanWsScope(scope, context);
1194
1254
  throw e;
1195
1255
  }
1196
1256
  }
1257
+ /**
1258
+ * Fin du canal `nodefony.http.request` pour un handshake interrompu par une
1259
+ * exception — sans effet si un refus l'a déjà publiée (`refuseHandshake`).
1260
+ * Le statut est un fait, pas une déduction : une socket rattachée dit que
1261
+ * le `101` est parti ; sinon c'est le `500` du filet qui a été rendu.
1262
+ *
1263
+ * @param context - le contexte du handshake, `null` s'il n'a pu être construit
1264
+ * @param error - l'exception qui a interrompu le pipeline
1265
+ */
1266
+ closeFailedWebsocketTrace(context, error) {
1267
+ if (!context?.requestTrace) return;
1268
+ closeRequestTrace(context, context.connection !== null ? 101 : 500, error);
1269
+ }
1197
1270
  releaseOrphanWsScope(scope, context) {
1198
1271
  if (!context) {
1199
1272
  this.container?.leaveScope(scope);
@@ -1,6 +1,6 @@
1
1
  import { handshakeRefusalStatus } from "../src/context/websocket/WebsocketContext.js";
2
- import { computeDurationMs, severityFromStatus } from "./audit-logger.js";
3
- import { logColor } from "nodefony";
2
+ import { severityFromStatus, statusOf } from "./audit-logger.js";
3
+ import { accessStatusFamily, logColor } from "nodefony";
4
4
  // nodefony/service/pretty-request-logger.ts
5
5
  /**
6
6
  * Pretty single-line logger for dev (P3.2) — the biggest gain for humans.
@@ -24,9 +24,9 @@ var PrettyRequestLogger = class {
24
24
  renderHttp(context, error) {
25
25
  const ctx = context;
26
26
  const err = error ?? ctx.error ?? null;
27
- const status = ctx.response?.statusCode ?? (err ? 500 : null);
27
+ const status = statusOf(ctx) ?? (err ? 500 : null);
28
28
  const method = ctx.method ?? "?";
29
- const duration = computeDurationMs(ctx.phases);
29
+ const duration = ctx.stopClock();
30
30
  return {
31
31
  text: `${logColor.cyan(method.padEnd(4))} ${colorizeStatus(status)} ${ctx.url} ${logColor.blackBright(formatDuration(duration))} ${logColor.blackBright(ctx.remoteAddress ?? "-")} ` + logColor.magenta(`[${shortId(ctx.requestId)}]`) + (err ? ` ${logColor.red(err.message)}` : ""),
32
32
  severity: severityFromStatus(status),
@@ -36,8 +36,8 @@ var PrettyRequestLogger = class {
36
36
  renderWebsocket(context, error, acceptedProtocol) {
37
37
  const ctx = context;
38
38
  const refused = error ? handshakeRefusalStatus(context, error) : null;
39
- const status = refused ?? ctx.response?.statusCode ?? (error ? 500 : null);
40
- const duration = computeDurationMs(ctx.phases);
39
+ const status = refused ?? statusOf(ctx) ?? (error ? 500 : null);
40
+ const duration = ctx.stopClock();
41
41
  const proto = acceptedProtocol ? `[${acceptedProtocol}]` : "";
42
42
  return {
43
43
  text: `${logColor.cyan("WS ")} ${colorizeStatus(status)} ${ctx.url} ${logColor.blackBright(proto)} ${logColor.blackBright(formatDuration(duration))} ${logColor.blackBright(ctx.remoteAddress ?? "-")} ` + logColor.magenta(`[${shortId(ctx.requestId)}]`) + (error ? ` ${logColor.red(error.message)}` : ""),
@@ -47,11 +47,12 @@ var PrettyRequestLogger = class {
47
47
  }
48
48
  };
49
49
  function colorizeStatus(status) {
50
- if (status === null) return logColor.blackBright("---");
50
+ const family = accessStatusFamily(status);
51
+ if (status === null || family === null) return logColor.blackBright("---");
51
52
  const s = String(status);
52
- if (status >= 500) return logColor.red(s);
53
- if (status >= 400) return logColor.yellowBold(s);
54
- if (status >= 300) return logColor.yellow(s);
53
+ if (family >= 5) return logColor.red(s);
54
+ if (family === 4) return logColor.yellowBold(s);
55
+ if (family === 3) return logColor.yellow(s);
55
56
  return logColor.green(s);
56
57
  }
57
58
  function formatDuration(ms) {
@@ -1,5 +1,6 @@
1
1
  import { buildMetaData } from "./metaData.js";
2
2
  import { protocolNameOf } from "./transport.js";
3
+ import { resolveTimingMode } from "./timingMode.js";
3
4
  import Cookie, { cookiesParser } from "../cookies/cookie.js";
4
5
  import { RequestContext, Service, logColor } from "nodefony";
5
6
  import { randomFillSync, randomUUID } from "node:crypto";
@@ -106,6 +107,12 @@ var Context = class extends Service {
106
107
  router = this.get("router");
107
108
  resolver = null;
108
109
  /**
110
+ * Module qui signe les lignes de CETTE requête : la ligne d'accès et les
111
+ * lignes du contrôleur. `null` = le module de la route (sans route : le
112
+ * journal du noyau). Un contrôleur le change : `this.context.logModule = "billing"`.
113
+ */
114
+ logModule = null;
115
+ /**
109
116
  * Intent de session de la route courante (posé par le Resolver depuis
110
117
  * `@UseSession` / paramètre `@Session`). Pilote le point d'activation unique
111
118
  * (HTTP + WS). `null` = aucune session sauf cookie existant (reprise L1).
@@ -137,8 +144,20 @@ var Context = class extends Service {
137
144
  profilerQueries = null;
138
145
  profiling = false;
139
146
  securityTrace = null;
147
+ /**
148
+ * Message du canal `nodefony.http.request` pour CETTE requête, ou `null` si
149
+ * personne n'écoutait à son début (le cas par défaut : un champ, aucune
150
+ * allocation). Posé par `openRequestTrace`, remis à `null` par
151
+ * `closeRequestTrace` — c'est ce `null` qui rend la fin idempotente.
152
+ *
153
+ * @internal
154
+ */
155
+ requestTrace = null;
140
156
  _timingEnabled = false;
141
157
  _timingVerbose = false;
158
+ _clockEnabled = false;
159
+ _clockStartMs = 0;
160
+ _durationMs = null;
142
161
  phases = EMPTY_PHASES;
143
162
  _phaseIndex = null;
144
163
  _afterResponseFns = null;
@@ -157,20 +176,24 @@ var Context = class extends Service {
157
176
  const p = globalThis.__nfPerfProbe;
158
177
  if (p && p.t0 !== 0n) p.svcNs += Number(process.hrtime.bigint() - p.t0);
159
178
  }
179
+ const timing = this.kernel?.options.timing;
180
+ const timingSection = typeof timing === "object" && timing !== null ? timing : null;
181
+ const mode = resolveTimingMode(timingSection !== null && "enabled" in timingSection ? timingSection.enabled : void 0, this.kernel?.environment);
182
+ if (mode !== "off") {
183
+ this._clockEnabled = true;
184
+ this._clockStartMs = performance.now();
185
+ }
186
+ if (mode === "phases") {
187
+ this._timingEnabled = true;
188
+ this.phases = [];
189
+ this._timingVerbose = timingSection !== null && "verbose" in timingSection && timingSection.verbose === true;
190
+ }
160
191
  this.serverType = serverType;
161
192
  this.type = protocolNameOf(serverType);
162
193
  this.set("context", this);
163
194
  this.httpKernel = this.get("HttpKernel");
164
195
  this.sessionService = this.get("sessions");
165
196
  this.setMetaData();
166
- const explicit = (this.kernel?.options)?.timing?.enabled;
167
- if (typeof explicit === "boolean") this._timingEnabled = explicit;
168
- else this._timingEnabled = this.kernel?.environment !== "production";
169
- if (this._timingEnabled) {
170
- this.phases = [];
171
- const verbose = (this.kernel?.options)?.timing?.verbose;
172
- this._timingVerbose = verbose === true;
173
- }
174
197
  this.scheme = serverType;
175
198
  switch (serverType) {
176
199
  case "http": {
@@ -245,6 +268,24 @@ var Context = class extends Service {
245
268
  p.endMs = performance.now();
246
269
  p.durationMs = p.endMs - p.startMs;
247
270
  }
271
+ /**
272
+ * Arrête l'horloge de la requête et rend sa durée, en millisecondes.
273
+ *
274
+ * La durée court de la construction du contexte au PREMIER appel : le
275
+ * démontage HTTP (`close` de la réponse, dernier octet remis — la mesure
276
+ * d'OpenTelemetry et du `$request_time` de nginx), la ligne d'accès pour un
277
+ * handshake WebSocket. Les appels suivants rendent la même valeur : journal
278
+ * d'accès, profiler et trace verbose lisent UNE durée, jamais trois.
279
+ *
280
+ * Active par défaut dans tous les environnements, production comprise :
281
+ * seul `timing.enabled: false` la coupe (cf `resolveTimingMode`).
282
+ *
283
+ * @returns la durée, ou `null` si `timing.enabled` vaut `false`
284
+ */
285
+ stopClock() {
286
+ if (!this._clockEnabled) return null;
287
+ return this._durationMs ??= performance.now() - this._clockStartMs;
288
+ }
248
289
  onAfterResponse(fn) {
249
290
  const boundFn = AsyncResource.bind(fn);
250
291
  if (this._afterResponseFired) {
@@ -296,10 +337,10 @@ var Context = class extends Service {
296
337
  _abortIfPending(reason) {
297
338
  if (this._abortController && !this._abortController.signal.aborted) this._abortController.abort(new Error(reason ?? "Context aborted"));
298
339
  }
299
- log(pci, severity, msgid, msg) {
340
+ log(pci, severity, msgid, msg, moduleName) {
300
341
  if (!msgid) msgid = this.serverType;
301
- if (this.requestId && RequestContext.getRequestId() === void 0) return RequestContext.run({ requestId: this.requestId }, () => super.log(pci, severity, msgid, msg));
302
- return super.log(pci, severity, msgid, msg);
342
+ if (this.requestId && RequestContext.getRequestId() === void 0) return RequestContext.run({ requestId: this.requestId }, () => super.log(pci, severity, msgid, msg, moduleName));
343
+ return super.log(pci, severity, msgid, msg, moduleName);
303
344
  }
304
345
  /**
305
346
  * Confie au contexte le magasin `RequestContext` de son unité de travail
@@ -359,7 +400,7 @@ var Context = class extends Service {
359
400
  if (!logger) return;
360
401
  if (logger.shouldSample && !logger.shouldSample(this, err)) return;
361
402
  const entry = logger.renderHttp(this, err);
362
- return this.log(entry.text, entry.severity, entry.msgid);
403
+ return this.log(entry.text, entry.severity, entry.msgid, void 0, this.logModule ?? this.resolver?.route?.module?.name);
363
404
  } catch {
364
405
  return;
365
406
  }
@@ -387,7 +428,9 @@ var Context = class extends Service {
387
428
  } else line += `${p.name}=…`;
388
429
  i++;
389
430
  }
390
- this.log(`TRACE phases [Σ ${total.toFixed(2)}ms] ${line}`, "DEBUG", `${this.serverType} TIMING`);
431
+ const duration = this.stopClock();
432
+ const head = duration === null ? "" : `total ${duration.toFixed(2)}ms · `;
433
+ this.log(`TRACE phases [${head}Σ ${total.toFixed(2)}ms] ${line}`, "DEBUG", `${this.serverType} TIMING`);
391
434
  }
392
435
  addRequestCookie(cookie) {
393
436
  if (cookie instanceof Cookie) return this.cookies[cookie.name] = cookie;
@@ -56,6 +56,11 @@ var HttpContext = class extends Context {
56
56
  activeSocket = null;
57
57
  isRedirect = false;
58
58
  sended = false;
59
+ /**
60
+ * Requête relayée par le proxy inverse : la politique de ligne d'accès de
61
+ * son montage. `null` = servie par l'application (cas de toute requête).
62
+ */
63
+ relayAccessLog = null;
59
64
  request;
60
65
  response;
61
66
  uploadService;
@@ -126,6 +131,7 @@ var HttpContext = class extends Context {
126
131
  _onTimeout() {
127
132
  if (this.listenerCount("onTimeout")) this.fire("onTimeout", this);
128
133
  this._abortIfPending("Request timeout");
134
+ this.httpKernel?.warnUnsentResponse(this);
129
135
  let error;
130
136
  if (this.response.stream) error = new HttpError("Gateway Timeout", 504, this);
131
137
  else error = new HttpError("Request Timeout", 408, this);
@@ -182,6 +188,21 @@ var HttpContext = class extends Context {
182
188
  }
183
189
  }
184
190
  /**
191
+ * Ligne d'accès — pour une requête RELAYÉE, avec le statut que l'amont a
192
+ * réellement rendu (le relais écrit la réponse brute, pas celle du
193
+ * contexte) ; un montage `accessLog: "errors"` ne l'écrit qu'en échec.
194
+ *
195
+ * @param httpError - l'erreur de la requête, s'il y en a une
196
+ */
197
+ logRequest(httpError) {
198
+ if (this.relayAccessLog !== null) {
199
+ const raw = this.response.response;
200
+ if (raw !== null) this.response.statusCode = raw.statusCode;
201
+ if (this.relayAccessLog === "errors" && this.response.statusCode < 400 && !httpError && !this.error) return;
202
+ }
203
+ return super.logRequest(httpError);
204
+ }
205
+ /**
185
206
  * Nettoie le contexte, et le DÉTACHE de sa socket h1 : la connexion
186
207
  * persistante survit à la requête, et l'entrée de `socketActiveContext`
187
208
  * retiendrait sinon tout le contexte jusqu'à sa fermeture. Seule l'entrée qui
@@ -93,6 +93,7 @@ var Http2Response = class extends HttpResponse {
93
93
  const stream = this.stream;
94
94
  if (!stream) return super.send(chunk, encoding);
95
95
  if (stream.destroyed || !stream.writable) {
96
+ this.settleHeadResponse();
96
97
  this.log("HTTP/2 stream not writable before send — skip", "DEBUG");
97
98
  return this;
98
99
  }
@@ -116,11 +117,29 @@ var Http2Response = class extends HttpResponse {
116
117
  */
117
118
  end(chunk, encoding) {
118
119
  if (this.stream) {
119
- if (this.stream.writableEnded || this.stream.destroyed || this.stream.closed) return this.stream;
120
+ if (this.stream.writableEnded || this.stream.destroyed || this.stream.closed) {
121
+ this.settleHeadResponse();
122
+ return this.stream;
123
+ }
120
124
  return this.stream.end(chunk, encoding || this.encoding);
121
125
  }
122
126
  return super.end(chunk, encoding);
123
127
  }
128
+ /**
129
+ * Termine la réponse de compatibilité d'un HEAD, dont Node a clos le flux de
130
+ * lui-même au `respond()`.
131
+ *
132
+ * Sans ce geste, `Http2ServerResponse` n'émet ni `finish` ni `close` : le
133
+ * démontage de la requête, branché sur ce `close` (ligne d'accès, profil,
134
+ * hooks d'après-réponse, libération du scope DI), n'a jamais lieu. Les statuts
135
+ * que Node termine aussi d'office (204, 205, 304) émettent leur `close` seuls,
136
+ * et un flux détruit (client parti) aussi : ils ne sont pas touchés. Hors du
137
+ * chemin servi — appelé seulement quand le flux est déjà clos.
138
+ */
139
+ settleHeadResponse() {
140
+ const raw = this.response;
141
+ if (raw !== null && this.context.method === "HEAD" && this.stream?.destroyed === false && !raw.writableEnded) raw.end();
142
+ }
124
143
  getStatusMessage(code) {
125
144
  if (code) {
126
145
  if (this.response) return http.STATUS_CODES[code] || this.statusMessage;