@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.
- package/dist/index.js +3 -2
- package/dist/nodefony/config/config.js +2 -1
- package/dist/nodefony/interfaces/IRequestTrace.js +1 -0
- package/dist/nodefony/service/ProfilerAdminApi.js +12 -1
- package/dist/nodefony/service/audit-logger.js +3 -17
- package/dist/nodefony/service/http-kernel.js +80 -7
- package/dist/nodefony/service/pretty-request-logger.js +11 -10
- package/dist/nodefony/src/context/Context.js +56 -13
- package/dist/nodefony/src/context/http/HttpContext.js +21 -0
- package/dist/nodefony/src/context/http2/Response.js +20 -1
- package/dist/nodefony/src/context/requestTrace.js +104 -0
- package/dist/nodefony/src/context/timingMode.js +21 -0
- package/dist/nodefony/src/context/unsentResponse.js +30 -0
- package/dist/nodefony/src/context/websocket/WebsocketContext.js +12 -8
- package/dist/nodefony/src/profiler/FrameProfile.js +22 -0
- package/dist/nodefony/src/profiler/Profiler.js +11 -21
- package/dist/nodefony/src/profiler/aggregate.js +26 -6
- package/dist/types/index.d.ts +2 -0
- package/dist/types/nodefony/config/config.d.ts +4 -0
- package/dist/types/nodefony/interfaces/IContext.d.ts +9 -0
- package/dist/types/nodefony/interfaces/IRequestTrace.d.ts +56 -0
- package/dist/types/nodefony/interfaces/IReverseProxy.d.ts +8 -0
- package/dist/types/nodefony/interfaces/IRouting.d.ts +16 -0
- package/dist/types/nodefony/interfaces/index.d.ts +1 -0
- package/dist/types/nodefony/service/ProfilerAdminApi.d.ts +3 -0
- package/dist/types/nodefony/service/audit-logger.d.ts +3 -11
- package/dist/types/nodefony/service/http-kernel.d.ts +50 -0
- package/dist/types/nodefony/src/context/Context.d.ts +25 -1
- package/dist/types/nodefony/src/context/http/HttpContext.d.ts +15 -1
- package/dist/types/nodefony/src/context/http2/Response.d.ts +12 -0
- package/dist/types/nodefony/src/context/requestTrace.d.ts +64 -0
- package/dist/types/nodefony/src/context/timingMode.d.ts +23 -0
- package/dist/types/nodefony/src/context/unsentResponse.d.ts +16 -0
- package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +1 -1
- package/dist/types/nodefony/src/profiler/FrameProfile.d.ts +15 -0
- package/dist/types/nodefony/src/profiler/Profiler.d.ts +16 -101
- package/dist/types/nodefony/src/profiler/aggregate.d.ts +23 -106
- package/docs/diagnostics-channel.md +303 -0
- package/docs/index.md +6 -1
- package/docs/observabilite.md +94 -16
- package/docs/reverse-proxy.md +18 -16
- 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:
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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)
|
|
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
|
-
|
|
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 {
|
|
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
|
|
27
|
+
const status = statusOf(ctx) ?? (err ? 500 : null);
|
|
28
28
|
const method = ctx.method ?? "?";
|
|
29
|
-
const duration =
|
|
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
|
|
40
|
-
const duration =
|
|
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
|
-
|
|
50
|
+
const family = accessStatusFamily(status);
|
|
51
|
+
if (status === null || family === null) return logColor.blackBright("---");
|
|
51
52
|
const s = String(status);
|
|
52
|
-
if (
|
|
53
|
-
if (
|
|
54
|
-
if (
|
|
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
|
-
|
|
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)
|
|
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;
|