@nodefony/http 10.0.0-beta.3 → 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 (138) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  2. package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorateParam.js +1 -2
  3. package/dist/index.js +13 -10
  4. package/dist/nodefony/command/assetsPublishCommand.js +5 -3
  5. package/dist/nodefony/command/certificatesCommand.js +5 -3
  6. package/dist/nodefony/command/networkCommand.js +5 -3
  7. package/dist/nodefony/command/proxyGenerateCommand.js +8 -3
  8. package/dist/nodefony/config/config.js +3 -3
  9. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  10. package/dist/nodefony/interfaces/IRequestTrace.js +1 -0
  11. package/dist/nodefony/service/HttpAdminApi.js +2 -2
  12. package/dist/nodefony/service/ProfilerAdminApi.js +13 -3
  13. package/dist/nodefony/service/audit-logger.js +23 -30
  14. package/dist/nodefony/service/certificates.js +97 -13
  15. package/dist/nodefony/service/error-renderer.js +29 -12
  16. package/dist/nodefony/service/http-kernel.js +198 -50
  17. package/dist/nodefony/service/pretty-request-logger.js +15 -13
  18. package/dist/nodefony/service/request-logger.js +24 -16
  19. package/dist/nodefony/service/reverse-proxy.js +3 -3
  20. package/dist/nodefony/service/servers/clientError.js +22 -3
  21. package/dist/nodefony/service/servers/server-h2c.js +126 -0
  22. package/dist/nodefony/service/servers/server-http.js +5 -8
  23. package/dist/nodefony/service/servers/server-https.js +12 -24
  24. package/dist/nodefony/service/servers/server-static.js +3 -3
  25. package/dist/nodefony/service/servers/server-websocket-secure.js +10 -18
  26. package/dist/nodefony/service/servers/server-websocket.js +10 -18
  27. package/dist/nodefony/service/servers/serverShutdown.js +78 -18
  28. package/dist/nodefony/service/servers/wsHeartbeat.js +1 -2
  29. package/dist/nodefony/service/sessions/sessions-service.js +7 -14
  30. package/dist/nodefony/service/trace.js +1 -2
  31. package/dist/nodefony/service/upload/upload-service.js +5 -8
  32. package/dist/nodefony/service/x509.js +1 -2
  33. package/dist/nodefony/src/assets/collectAssets.js +1 -2
  34. package/dist/nodefony/src/assets/prebuiltUi.js +1 -2
  35. package/dist/nodefony/src/context/Context.js +78 -43
  36. package/dist/nodefony/src/context/domainMatcher.js +1 -2
  37. package/dist/nodefony/src/context/forwarded.js +1 -2
  38. package/dist/nodefony/src/context/http/HttpContext.js +28 -6
  39. package/dist/nodefony/src/context/http/Request.js +33 -29
  40. package/dist/nodefony/src/context/http/Response.js +23 -6
  41. package/dist/nodefony/src/context/http/SseStream.js +1 -2
  42. package/dist/nodefony/src/context/http/parser.js +36 -10
  43. package/dist/nodefony/src/context/http/urlFastPath.js +1 -2
  44. package/dist/nodefony/src/context/http2/Request.js +4 -8
  45. package/dist/nodefony/src/context/http2/Response.js +41 -9
  46. package/dist/nodefony/src/context/metaData.js +1 -2
  47. package/dist/nodefony/src/context/requestId.js +1 -2
  48. package/dist/nodefony/src/context/requestTrace.js +104 -0
  49. package/dist/nodefony/src/context/responseEnded.js +1 -2
  50. package/dist/nodefony/src/context/timingMode.js +21 -0
  51. package/dist/nodefony/src/context/transport.js +45 -0
  52. package/dist/nodefony/src/context/trustProxy.js +1 -2
  53. package/dist/nodefony/src/context/unsentResponse.js +30 -0
  54. package/dist/nodefony/src/context/websocket/Response.js +33 -6
  55. package/dist/nodefony/src/context/websocket/WebsocketContext.js +104 -25
  56. package/dist/nodefony/src/context/websocket/isWebsocketContext.js +17 -0
  57. package/dist/nodefony/src/context/websocket/websocketUrl.js +1 -2
  58. package/dist/nodefony/src/context/websocket/wsBackpressure.js +1 -2
  59. package/dist/nodefony/src/context/websocket/wsCloseReason.js +1 -2
  60. package/dist/nodefony/src/context/websocket/wsLogContent.js +1 -2
  61. package/dist/nodefony/src/cookies/cookie.js +3 -7
  62. package/dist/nodefony/src/errors/httpError.js +1 -2
  63. package/dist/nodefony/src/errors/thrownMessage.js +1 -2
  64. package/dist/nodefony/src/profiler/FrameProfile.js +23 -2
  65. package/dist/nodefony/src/profiler/Profiler.js +104 -32
  66. package/dist/nodefony/src/profiler/aggregate.js +442 -0
  67. package/dist/nodefony/src/proxy/forward.js +29 -5
  68. package/dist/nodefony/src/proxy/generateProxyConfig.js +14 -6
  69. package/dist/nodefony/src/proxy/rules.js +12 -6
  70. package/dist/nodefony/src/rateLimit/MemoryRateLimitStore.js +1 -2
  71. package/dist/nodefony/src/rateLimit/WsConnectionCounter.js +1 -2
  72. package/dist/nodefony/src/rateLimit/rateLimitFilters.js +1 -2
  73. package/dist/nodefony/src/servers/http2Limits.js +50 -0
  74. package/dist/nodefony/src/servers/kernelServers.js +12 -3
  75. package/dist/nodefony/src/servers/portBinder.js +41 -10
  76. package/dist/nodefony/src/servers/upgradeDispatch.js +86 -7
  77. package/dist/nodefony/src/session/session.js +2 -3
  78. package/dist/nodefony/src/session/sessionStoreFailure.js +1 -2
  79. package/dist/nodefony/src/session/storage/MemorySessionStorage.js +1 -2
  80. package/dist/nodefony/src/session/storage/RevocationGuardStorage.js +1 -2
  81. package/dist/nodefony/src/session/storage/sessionFilters.js +1 -2
  82. package/dist/nodefony/src/session/storage/sessionSort.js +1 -2
  83. package/dist/types/index.d.ts +9 -3
  84. package/dist/types/nodefony/config/config.d.ts +4 -0
  85. package/dist/types/nodefony/interfaces/IContext.d.ts +72 -4
  86. package/dist/types/nodefony/interfaces/IRequest.d.ts +9 -4
  87. package/dist/types/nodefony/interfaces/IRequestTrace.d.ts +56 -0
  88. package/dist/types/nodefony/interfaces/IReverseProxy.d.ts +8 -0
  89. package/dist/types/nodefony/interfaces/IRouting.d.ts +26 -0
  90. package/dist/types/nodefony/interfaces/index.d.ts +3 -2
  91. package/dist/types/nodefony/service/ProfilerAdminApi.d.ts +3 -0
  92. package/dist/types/nodefony/service/audit-logger.d.ts +3 -11
  93. package/dist/types/nodefony/service/certificates.d.ts +55 -4
  94. package/dist/types/nodefony/service/error-renderer.d.ts +31 -0
  95. package/dist/types/nodefony/service/http-kernel.d.ts +96 -10
  96. package/dist/types/nodefony/service/servers/clientError.d.ts +16 -0
  97. package/dist/types/nodefony/service/servers/server-h2c.d.ts +51 -0
  98. package/dist/types/nodefony/service/servers/server-http.d.ts +3 -3
  99. package/dist/types/nodefony/service/servers/server-https.d.ts +4 -4
  100. package/dist/types/nodefony/service/servers/server-websocket-secure.d.ts +1 -3
  101. package/dist/types/nodefony/service/servers/server-websocket.d.ts +1 -3
  102. package/dist/types/nodefony/service/servers/serverShutdown.d.ts +30 -16
  103. package/dist/types/nodefony/src/context/Context.d.ts +38 -5
  104. package/dist/types/nodefony/src/context/http/HttpContext.d.ts +16 -2
  105. package/dist/types/nodefony/src/context/http/Request.d.ts +20 -15
  106. package/dist/types/nodefony/src/context/http/Response.d.ts +11 -0
  107. package/dist/types/nodefony/src/context/http/parser.d.ts +18 -2
  108. package/dist/types/nodefony/src/context/http2/Request.d.ts +1 -1
  109. package/dist/types/nodefony/src/context/http2/Response.d.ts +27 -5
  110. package/dist/types/nodefony/src/context/requestTrace.d.ts +64 -0
  111. package/dist/types/nodefony/src/context/timingMode.d.ts +23 -0
  112. package/dist/types/nodefony/src/context/transport.d.ts +53 -0
  113. package/dist/types/nodefony/src/context/unsentResponse.d.ts +16 -0
  114. package/dist/types/nodefony/src/context/websocket/Response.d.ts +18 -2
  115. package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +58 -8
  116. package/dist/types/nodefony/src/context/websocket/isWebsocketContext.d.ts +15 -0
  117. package/dist/types/nodefony/src/profiler/FrameProfile.d.ts +28 -12
  118. package/dist/types/nodefony/src/profiler/Profiler.d.ts +79 -111
  119. package/dist/types/nodefony/src/profiler/aggregate.d.ts +132 -0
  120. package/dist/types/nodefony/src/proxy/forward.d.ts +14 -2
  121. package/dist/types/nodefony/src/proxy/generateProxyConfig.d.ts +16 -0
  122. package/dist/types/nodefony/src/proxy/rules.d.ts +3 -0
  123. package/dist/types/nodefony/src/servers/http2Limits.d.ts +37 -0
  124. package/dist/types/nodefony/src/servers/kernelServers.d.ts +10 -1
  125. package/dist/types/nodefony/src/servers/portBinder.d.ts +29 -5
  126. package/dist/types/nodefony/src/servers/upgradeDispatch.d.ts +33 -3
  127. package/docs/cookies.md +85 -78
  128. package/docs/diagnostics-channel.md +303 -0
  129. package/docs/index.md +6 -1
  130. package/docs/observabilite.md +159 -81
  131. package/docs/rate-limit.md +83 -83
  132. package/docs/reverse-proxy.md +72 -70
  133. package/docs/servers.md +338 -224
  134. package/docs/session.md +179 -179
  135. package/docs/sse.md +24 -20
  136. package/docs/upload.md +120 -117
  137. package/package.json +8 -9
  138. package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
@@ -0,0 +1,442 @@
1
+ import { MODULE_PROXY_PREFIXES } from "../proxy/rules.js";
2
+ import { isConsolePath } from "nodefony";
3
+ // nodefony/src/profiler/aggregate.ts
4
+ /**
5
+ * Agrégation des profils de requêtes — règle PURE, sans horloge ni I/O.
6
+ *
7
+ * Une seule implémentation pour tout ce qui résume le profileur : l'outil MCP
8
+ * qui observe l'application (`nodefony_observe`), celui qui tire lui-même des
9
+ * requêtes sur une route, et demain la console d'administration. Elle est
10
+ * bornée par construction : un échantillon de durées par route, un nombre
11
+ * maximal de formes SQL retenues, quelques exemples d'erreurs — observer une
12
+ * application chargée ne fait jamais grossir la mémoire avec la fenêtre.
13
+ */
14
+ /**
15
+ * Répétitions d'une même forme SQL, dans UNE requête, à partir desquelles on
16
+ * signale un N+1. En dessous, ce sont des boucles courtes légitimes (quelques
17
+ * lectures de configuration, une pagination) qu'on signalerait à tort.
18
+ */
19
+ const N_PLUS_ONE_MIN_REPEATS = 5;
20
+ /** Durées retenues par route pour les percentiles (échantillonnage au-delà). */
21
+ const ROUTE_SAMPLE_CAP = 256;
22
+ /** Formes SQL distinctes retenues ; les suivantes ne sont plus que comptées. */
23
+ const SQL_SHAPE_CAP = 200;
24
+ /** Exemples d'erreurs retenus (statut ≥ 500 ou exception). */
25
+ const ERROR_SAMPLE_CAP = 10;
26
+ /**
27
+ * Préfixe de l'espace réservé au framework.
28
+ *
29
+ * @deprecated Ne dit pas ce qui est de l'outillage : une connexion
30
+ * d'utilisateur (`/nodefony/security/api/auth/…`) ou une route d'application
31
+ * (`/nodefony/test/index`) passent sous ce préfixe. La règle est
32
+ * `isConsolePath` (`nodefony`), que `profileLane` appelle.
33
+ */
34
+ const TOOLING_URL_PREFIX = "/nodefony/";
35
+ /**
36
+ * Range un profil par provenance, d'après son URL. L'outillage est reconnu par
37
+ * la règle du cœur, `isConsolePath` — la même que les Journaux, la barre de
38
+ * débogage et le Studio : une connexion d'utilisateur reste de l'application.
39
+ * Les anneaux du profileur et le filtre de l'agrégation la lisent tous deux.
40
+ *
41
+ * @param url - URL du profil, absolue ou chemin.
42
+ * @returns la provenance du profil.
43
+ */
44
+ function profileLane(url) {
45
+ const path = urlPath(url);
46
+ for (const prefix of MODULE_PROXY_PREFIXES) if (path.startsWith(prefix)) return "asset";
47
+ return isConsolePath(path) ? "tooling" : "app";
48
+ }
49
+ /**
50
+ * Le profil vient-il de l'outillage (routes `/nodefony/…` ou assets relayés)
51
+ * plutôt que de l'application.
52
+ *
53
+ * @param url - URL du profil, absolue ou chemin.
54
+ */
55
+ function isToolingUrl(url) {
56
+ return profileLane(url) !== "app";
57
+ }
58
+ /**
59
+ * Une requête de cette URL se profile-t-elle ? L'application toujours ;
60
+ * l'outillage et les assets relayés seulement sur demande (`timing.tooling`)
61
+ * — on ne mesure pas la façon dont on mesure.
62
+ *
63
+ * @param url - URL ou chemin de la requête (ou de la frame du pont)
64
+ * @param tooling - profiler aussi l'outillage
65
+ */
66
+ function isProfiledUrl(url, tooling) {
67
+ return tooling || profileLane(url) === "app";
68
+ }
69
+ /** Groupe des requêtes qu'aucune route n'a servies (404, statique, sondes). */
70
+ const NO_ROUTE = "(aucune route)";
71
+ /** Pseudo-phase : le temps de l'action hors phases imbriquées et hors SQL. */
72
+ const ACTION_SELF_PHASE = "action.self";
73
+ /** Longueur maximale d'un message d'erreur rapporté. */
74
+ const ERROR_MESSAGE_MAX = 200;
75
+ /**
76
+ * Réduit une requête SQL (ou le descripteur d'une commande NoSQL) à sa FORME :
77
+ * valeurs remplacées par `?`, listes de valeurs repliées, blancs compactés.
78
+ *
79
+ * Deux requêtes qui ne diffèrent que par leurs valeurs ont la même forme — c'est
80
+ * ce qui permet de reconnaître un N+1. Et aucune valeur ne survit : la forme
81
+ * peut sortir dans un rapport sans emporter une donnée personnelle.
82
+ *
83
+ * @param sql - le texte tel que l'adaptateur l'a poussé au profileur.
84
+ * @returns la forme normalisée.
85
+ */
86
+ function sqlShape(sql) {
87
+ return sql.replace(/:\s*"(?:[^"\\]|\\.)*"/g, ":?").replace(/:\s*(?:-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null)\b/g, ":?").replace(/\[[^[\]{}]+\]/g, "[?…]").replace(/'(?:[^']|'')*'/g, "?").replace(/\$\d+/g, "?").replace(/(?<![\w$])\d+(?:\.\d+)?/g, "?").replace(/\bin\s*\(\s*\?(?:\s*,\s*\?)*\s*\)/gi, "in (?…)").replace(/\bvalues\s*\([^()]*\)(?:\s*,\s*\([^()]*\))*/gi, "values (…)").replace(/\s+/g, " ").trim();
88
+ }
89
+ /** Phase `action` terminée : sa durée est connue. */
90
+ function isFinishedAction(p) {
91
+ return p.name === "action" && p.durationMs !== null;
92
+ }
93
+ /**
94
+ * Temps de l'action hors phases imbriquées et hors SQL : le code de l'action
95
+ * elle-même et des services qu'elle appelle.
96
+ *
97
+ * Les phases imbriquées (`initialize`, `render`, `send`…) ne sont pas
98
+ * nommées ici : est imbriquée toute phase terminée qui tient dans l'intervalle
99
+ * de l'action. Les intervalles (phases et SQL) sont fusionnés avant d'être
100
+ * retranchés — une requête SQL lancée pendant le rendu n'est pas comptée deux
101
+ * fois.
102
+ *
103
+ * @returns la durée propre en ms, ou `null` sans phase `action` terminée.
104
+ */
105
+ function actionSelfMs(entry) {
106
+ const action = entry.phases.find(isFinishedAction);
107
+ if (action === void 0) return null;
108
+ const start = action.startMs;
109
+ const end = start + action.durationMs;
110
+ const inner = [];
111
+ for (const p of entry.phases) {
112
+ if (p === action || p.durationMs === null) continue;
113
+ const pEnd = p.startMs + p.durationMs;
114
+ if (p.startMs >= start && pEnd <= end) inner.push([p.startMs, pEnd]);
115
+ }
116
+ for (const q of entry.queries ?? []) {
117
+ if (q.startMs === void 0) continue;
118
+ const qStart = Math.max(q.startMs, start);
119
+ const qEnd = Math.min(q.startMs + q.durationMs, end);
120
+ if (qEnd > qStart) inner.push([qStart, qEnd]);
121
+ }
122
+ inner.sort((a, b) => a[0] - b[0]);
123
+ let covered = 0;
124
+ let curStart = -Infinity;
125
+ let curEnd = -Infinity;
126
+ for (const [s, e] of inner) if (s > curEnd) {
127
+ if (curEnd > curStart) covered += curEnd - curStart;
128
+ curStart = s;
129
+ curEnd = e;
130
+ } else if (e > curEnd) curEnd = e;
131
+ if (curEnd > curStart) covered += curEnd - curStart;
132
+ return Math.max(0, action.durationMs - covered);
133
+ }
134
+ /**
135
+ * Une requête en ERREUR du serveur : statut ≥ 500, ou exception levée sans
136
+ * verdict client. Règle unique — le rapport, ses exemples et la lecture de leurs
137
+ * journaux en dépendent.
138
+ *
139
+ * Un statut 4xx n'en est JAMAIS une, même porteur d'une exception : un refus
140
+ * 401/403 (l'authentification lève une `AuthenticationError`) ou une ressource
141
+ * absente est le verdict rendu au client, compté à part — le lister parmi les
142
+ * erreurs ferait chercher une panne là où il manque une identité.
143
+ *
144
+ * @param entry - le profil (seuls statut et erreur sont lus).
145
+ */
146
+ function isFailedProfile(entry) {
147
+ if (entry.status === null) return entry.error !== null;
148
+ if (entry.status >= 500) return true;
149
+ return entry.status < 400 && entry.error !== null;
150
+ }
151
+ /**
152
+ * Le chemin d'une URL de profil. Le profileur enregistre l'URL ABSOLUE
153
+ * (`http://hôte:port/chemin`) : un filtre écrit sur le chemin ne mordrait
154
+ * jamais dessus. Sans `URL` à construire — appelée sur chaque profil lu.
155
+ */
156
+ function urlPath(url) {
157
+ if (url.startsWith("/")) return url;
158
+ const scheme = url.indexOf("://");
159
+ if (scheme === -1) return url;
160
+ const slash = url.indexOf("/", scheme + 3);
161
+ return slash === -1 ? "/" : url.slice(slash);
162
+ }
163
+ /** Percentile au rang le plus proche, sur un tableau TRIÉ non vide. */
164
+ function percentile(sorted, p) {
165
+ if (sorted.length === 0) return null;
166
+ const rank = Math.ceil(p / 100 * sorted.length) - 1;
167
+ return sorted[Math.min(Math.max(rank, 0), sorted.length - 1)] ?? null;
168
+ }
169
+ /** Arrondi au centième de milliseconde : au-delà, c'est du bruit de mesure. */
170
+ function round(ms) {
171
+ return Math.round(ms * 100) / 100;
172
+ }
173
+ function emptyStatus() {
174
+ return {
175
+ s2xx: 0,
176
+ s3xx: 0,
177
+ s4xx: 0,
178
+ s5xx: 0,
179
+ unauthorized: 0,
180
+ forbidden: 0
181
+ };
182
+ }
183
+ function countStatus(status, code) {
184
+ if (code === null) return;
185
+ if (code >= 500) status.s5xx += 1;
186
+ else if (code >= 400) {
187
+ status.s4xx += 1;
188
+ if (code === 401) status.unauthorized += 1;
189
+ else if (code === 403) status.forbidden += 1;
190
+ } else if (code >= 300) status.s3xx += 1;
191
+ else if (code >= 200) status.s2xx += 1;
192
+ }
193
+ function addPhase(phases, name, ms) {
194
+ const total = phases[name] ??= {
195
+ totalMs: 0,
196
+ count: 0
197
+ };
198
+ total.totalMs += ms;
199
+ total.count += 1;
200
+ }
201
+ /**
202
+ * Crée un accumulateur de profils.
203
+ *
204
+ * @param options - seuil N+1, prise en compte de l'outillage, aléa injecté.
205
+ * @returns l'accumulateur ; son `result()` peut être appelé à tout moment.
206
+ */
207
+ function createProfileAggregator(options = {}) {
208
+ const minRepeats = options.nPlusOneMinRepeats ?? 5;
209
+ const includeTooling = options.includeTooling ?? false;
210
+ const random = options.random ?? Math.random;
211
+ const routes = /* @__PURE__ */ new Map();
212
+ const nPlusOne = /* @__PURE__ */ new Map();
213
+ const shapes = /* @__PURE__ */ new Map();
214
+ const errors = [];
215
+ let requests = 0;
216
+ let http = 0;
217
+ let ws = 0;
218
+ let filteredTooling = 0;
219
+ let untimed = 0;
220
+ let sqlShapesDropped = 0;
221
+ const droppedShapes = /* @__PURE__ */ new Set();
222
+ function routeAcc(entry, route) {
223
+ const key = `${entry.kind}\u0000${route}`;
224
+ let acc = routes.get(key);
225
+ if (acc === void 0) {
226
+ acc = {
227
+ stats: {
228
+ route,
229
+ kind: entry.kind,
230
+ controller: entry.controller,
231
+ action: entry.action,
232
+ count: 0,
233
+ untimed: 0,
234
+ served: 0,
235
+ p50Ms: null,
236
+ p95Ms: null,
237
+ maxMs: null,
238
+ status: emptyStatus(),
239
+ errors: 0,
240
+ queries: {
241
+ total: 0,
242
+ perRequestMax: 0,
243
+ totalMs: 0
244
+ },
245
+ phases: {}
246
+ },
247
+ samples: [],
248
+ seen: 0
249
+ };
250
+ routes.set(key, acc);
251
+ }
252
+ return acc;
253
+ }
254
+ function sample(acc, ms) {
255
+ acc.seen += 1;
256
+ if (acc.samples.length < 256) {
257
+ acc.samples.push(ms);
258
+ return;
259
+ }
260
+ const slot = Math.floor(random() * acc.seen);
261
+ if (slot < 256) acc.samples[slot] = ms;
262
+ }
263
+ function countQueries(entry, stats) {
264
+ const queries = entry.queries;
265
+ if (queries === void 0 || queries.length === 0) return;
266
+ const q = stats.queries;
267
+ q.total += queries.length;
268
+ if (queries.length > q.perRequestMax) q.perRequestMax = queries.length;
269
+ for (const one of queries) q.totalMs += one.durationMs;
270
+ }
271
+ function addQueries(entry, route) {
272
+ const queries = entry.queries;
273
+ if (queries === void 0 || queries.length === 0) return;
274
+ const perShape = /* @__PURE__ */ new Map();
275
+ for (const q of queries) {
276
+ const shape = sqlShape(q.sql);
277
+ const local = perShape.get(shape);
278
+ if (local === void 0) perShape.set(shape, {
279
+ n: 1,
280
+ ms: q.durationMs
281
+ });
282
+ else {
283
+ local.n += 1;
284
+ local.ms += q.durationMs;
285
+ }
286
+ let global = shapes.get(shape);
287
+ if (global === void 0) {
288
+ if (shapes.size >= 200) {
289
+ if (!droppedShapes.has(shape) && droppedShapes.size < 200) droppedShapes.add(shape);
290
+ continue;
291
+ }
292
+ global = {
293
+ shape,
294
+ connector: q.connector ?? null,
295
+ count: 0,
296
+ totalMs: 0,
297
+ maxMs: 0,
298
+ routes: /* @__PURE__ */ new Set()
299
+ };
300
+ shapes.set(shape, global);
301
+ }
302
+ global.count += 1;
303
+ global.totalMs += q.durationMs;
304
+ if (q.durationMs > global.maxMs) global.maxMs = q.durationMs;
305
+ if (global.routes.size < 5) global.routes.add(route);
306
+ }
307
+ for (const [shape, { n, ms }] of perShape) {
308
+ if (n < minRepeats) continue;
309
+ const key = `${route}\u0000${shape}`;
310
+ const found = nPlusOne.get(key);
311
+ if (found === void 0) nPlusOne.set(key, {
312
+ route,
313
+ controller: entry.controller,
314
+ action: entry.action,
315
+ shape,
316
+ maxRepeats: n,
317
+ requests: 1,
318
+ totalMs: ms,
319
+ sampleRequestId: entry.requestId
320
+ });
321
+ else {
322
+ found.requests += 1;
323
+ found.totalMs += ms;
324
+ if (n > found.maxRepeats) {
325
+ found.maxRepeats = n;
326
+ found.sampleRequestId = entry.requestId;
327
+ }
328
+ }
329
+ }
330
+ }
331
+ return {
332
+ add(entry) {
333
+ if (!includeTooling && isToolingUrl(entry.url)) {
334
+ filteredTooling += 1;
335
+ return false;
336
+ }
337
+ requests += 1;
338
+ if (entry.kind === "ws") ws += 1;
339
+ else http += 1;
340
+ const route = entry.route ?? "(aucune route)";
341
+ const acc = routeAcc(entry, route);
342
+ const stats = acc.stats;
343
+ stats.count += 1;
344
+ countStatus(stats.status, entry.status);
345
+ const failed = isFailedProfile(entry);
346
+ if (failed) {
347
+ stats.errors += 1;
348
+ if (errors.length < 10) errors.push({
349
+ route,
350
+ status: entry.status,
351
+ error: entry.error === null ? null : entry.error.slice(0, ERROR_MESSAGE_MAX),
352
+ requestId: entry.requestId
353
+ });
354
+ }
355
+ const served = !failed && (entry.status === null || entry.status < 400);
356
+ if (entry.durationMs === null) {
357
+ stats.untimed += 1;
358
+ untimed += 1;
359
+ } else if (served) {
360
+ stats.served += 1;
361
+ sample(acc, entry.durationMs);
362
+ if (stats.maxMs === null || entry.durationMs > stats.maxMs) stats.maxMs = entry.durationMs;
363
+ for (const p of entry.phases) if (p.durationMs !== null) addPhase(stats.phases, p.name, p.durationMs);
364
+ const self = actionSelfMs(entry);
365
+ if (self !== null) addPhase(stats.phases, ACTION_SELF_PHASE, self);
366
+ countQueries(entry, stats);
367
+ }
368
+ addQueries(entry, route);
369
+ sqlShapesDropped = droppedShapes.size;
370
+ return true;
371
+ },
372
+ result() {
373
+ const routeList = [];
374
+ for (const acc of routes.values()) {
375
+ const sorted = [...acc.samples].sort((a, b) => a - b);
376
+ const p50 = percentile(sorted, 50);
377
+ const p95 = percentile(sorted, 95);
378
+ const phases = {};
379
+ for (const [name, t] of Object.entries(acc.stats.phases)) phases[name] = {
380
+ totalMs: round(t.totalMs),
381
+ count: t.count
382
+ };
383
+ routeList.push({
384
+ ...acc.stats,
385
+ p50Ms: p50 === null ? null : round(p50),
386
+ p95Ms: p95 === null ? null : round(p95),
387
+ maxMs: acc.stats.maxMs === null ? null : round(acc.stats.maxMs),
388
+ status: { ...acc.stats.status },
389
+ queries: {
390
+ ...acc.stats.queries,
391
+ totalMs: round(acc.stats.queries.totalMs)
392
+ },
393
+ phases
394
+ });
395
+ }
396
+ routeList.sort((a, b) => (b.p95Ms ?? -1) - (a.p95Ms ?? -1));
397
+ const nPlusOneList = [...nPlusOne.values()].map((n) => ({
398
+ route: n.route,
399
+ controller: n.controller,
400
+ action: n.action,
401
+ shape: n.shape,
402
+ maxRepeats: n.maxRepeats,
403
+ requests: n.requests,
404
+ totalMs: round(n.totalMs),
405
+ sampleRequestId: n.sampleRequestId
406
+ })).sort((a, b) => b.totalMs - a.totalMs);
407
+ const slowQueries = [...shapes.values()].sort((a, b) => b.totalMs - a.totalMs).slice(0, 10).map((s) => ({
408
+ shape: s.shape,
409
+ connector: s.connector,
410
+ count: s.count,
411
+ totalMs: round(s.totalMs),
412
+ maxMs: round(s.maxMs),
413
+ routes: [...s.routes]
414
+ }));
415
+ return {
416
+ requests,
417
+ http,
418
+ ws,
419
+ filteredTooling,
420
+ untimed,
421
+ routes: routeList,
422
+ nPlusOne: nPlusOneList,
423
+ slowQueries,
424
+ sqlShapesDropped,
425
+ errors: errors.slice()
426
+ };
427
+ }
428
+ };
429
+ }
430
+ /**
431
+ * Agrège une liste de profils en une fois.
432
+ *
433
+ * @param entries - les profils, dans n'importe quel ordre.
434
+ * @param options - mêmes réglages que {@link createProfileAggregator}.
435
+ * @returns le rapport.
436
+ */
437
+ function aggregateProfiles(entries, options = {}) {
438
+ const aggregator = createProfileAggregator(options);
439
+ for (const entry of entries) aggregator.add(entry);
440
+ return aggregator.result();
441
+ }
442
+ export { ACTION_SELF_PHASE, ERROR_SAMPLE_CAP, NO_ROUTE, N_PLUS_ONE_MIN_REPEATS, ROUTE_SAMPLE_CAP, SQL_SHAPE_CAP, TOOLING_URL_PREFIX, aggregateProfiles, createProfileAggregator, isFailedProfile, isProfiledUrl, isToolingUrl, profileLane, sqlShape, urlPath };
@@ -1,6 +1,6 @@
1
1
  import http from "node:http";
2
2
  import https from "node:https";
3
- //#region nodefony/src/proxy/forward.ts
3
+ // nodefony/src/proxy/forward.ts
4
4
  /**
5
5
  * En-têtes de CONNEXION (RFC 9110 §7.6.1) : ils décrivent un saut, jamais le
6
6
  * message — un intermédiaire ne les transmet pas. Les cinq premiers sont en
@@ -381,13 +381,38 @@ function rawHead(status, rawHeaders, skip = null, extra = []) {
381
381
  for (const line of extra) head += `${line}\r\n`;
382
382
  return `${head}\r\n`;
383
383
  }
384
- /** Réponse brute minimale sur un socket d'upgrade, puis fermeture. */
385
- function refuseUpgrade(socket, status, reason) {
384
+ /**
385
+ * Réponse brute minimale sur un socket d'upgrade, puis fermeture.
386
+ *
387
+ * Les en-têtes passent par la validation de `node:http` : un nom ou une valeur
388
+ * invalide — un saut de ligne surtout, qui forgerait un en-tête de plus — est
389
+ * OMIS, jamais écrit. `Connection` et `Content-Length` sont posés ici et ne se
390
+ * remplacent pas.
391
+ *
392
+ * @param socket - socket du client, encore en attente de sa réponse
393
+ * @param status - statut HTTP du refus
394
+ * @param reason - phrase de raison
395
+ * @param headers - en-têtes du refus (`WWW-Authenticate`, `Retry-After`…)
396
+ */
397
+ function refuseUpgrade(socket, status, reason, headers) {
386
398
  if (!socket.writable) {
387
399
  socket.destroy();
388
400
  return;
389
401
  }
390
- socket.end(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
402
+ let extra = "";
403
+ if (headers) for (const name of Object.keys(headers)) {
404
+ const lower = name.toLowerCase();
405
+ if (lower === "connection" || lower === "content-length") continue;
406
+ const value = String(headers[name]);
407
+ try {
408
+ http.validateHeaderName(name);
409
+ http.validateHeaderValue(name, value);
410
+ } catch {
411
+ continue;
412
+ }
413
+ extra += `${name}: ${value}\r\n`;
414
+ }
415
+ socket.end(`HTTP/1.1 ${status} ${reason}\r\n${extra}Connection: close\r\nContent-Length: 0\r\n\r\n`);
391
416
  }
392
417
  /** En-têtes d'une réponse d'amont REFUSANT l'upgrade, à ne pas recopier tels quels. */
393
418
  const REFUSAL_SKIP = /* @__PURE__ */ new Set([
@@ -472,5 +497,4 @@ function forwardUpgrade(req, socket, head, mount, target, from, relay) {
472
497
  });
473
498
  upstream.end();
474
499
  }
475
- //#endregion
476
500
  export { MAX_VIA_HOPS, bodyFramingOf, forwardRequest, forwardUpgrade, rawHead, refuseUpgrade, requestHeadersFor, responseHeadersFor, rewriteLocation, upstreamPath, viaLoops };
@@ -1,16 +1,18 @@
1
- //#region nodefony/src/proxy/generateProxyConfig.ts
1
+ // nodefony/src/proxy/generateProxyConfig.ts
2
2
  /** Valeurs par défaut d'un modèle d'introspection (complété par la commande). */
3
3
  const defaultIntrospection = {
4
4
  domains: [],
5
5
  backendHost: "127.0.0.1",
6
6
  httpPort: 5151,
7
7
  httpsPort: 5152,
8
+ h2cPort: null,
8
9
  staticRoots: [],
9
10
  mounts: [],
10
11
  listen: 80,
11
12
  reencrypt: false,
12
13
  maxBodyBytes: 0,
13
14
  keepaliveIntervalMs: 0,
15
+ readinessPath: "/readyz",
14
16
  tls: null
15
17
  };
16
18
  /**
@@ -123,8 +125,16 @@ function nginxServerBlock(intro, scheme, idleSeconds, tls) {
123
125
  */
124
126
  function generateHaproxyConfig(intro) {
125
127
  const backendPort = intro.reencrypt ? intro.httpsPort : intro.httpPort;
128
+ const h2cPort = intro.reencrypt ? null : intro.h2cPort ?? null;
126
129
  const idleSeconds = idleTimeoutSeconds(intro);
127
130
  const serverSsl = intro.reencrypt ? ` ssl ca-file /etc/haproxy/certs/ca.pem verify required verifyhost ${firstDomain(intro)} sni str(${firstDomain(intro)})` : "";
131
+ const readiness = intro.readinessPath ?? null;
132
+ const healthCheck = readiness === null ? "" : ` # Retire l'exemplaire dès que ${readiness} répond 503 (début de l'arrêt),
133
+ # pas seulement quand son port se ferme.
134
+ option httpchk GET ${readiness}
135
+ http-check expect status 200
136
+ `;
137
+ const h2cCheck = readiness === null ? "check" : `check port ${intro.httpPort} check-proto h1`;
128
138
  const staticNote = intro.staticRoots.length > 0 || intro.mounts.length > 0 ? " # NB : haproxy ne sert pas de fichiers — les statiques sont proxifiés au\n # backend. Pour les offloader, utiliser nginx (proxy:generate nginx) +\n # `statics.enabled: false` côté Nodefony.\n" : "";
129
139
  return `# Généré par \`nodefony proxy:generate haproxy\` — NE PAS éditer à la main.
130
140
  # Reverse-proxy dérivé de l'introspection Nodefony.
@@ -166,11 +176,10 @@ frontend fe_nodefony
166
176
  http-request set-header Forwarded "for=%[src];proto=https;host=%[req.hdr(host)]" if { ssl_fc }
167
177
  http-request set-header Forwarded "for=%[src];proto=http;host=%[req.hdr(host)]" unless { ssl_fc }
168
178
 
169
- default_backend be_nodefony
170
-
179
+ ${h2cPort === null ? " default_backend be_nodefony\n" : " # h2c (servers.h2c) : HTTP/2 en clair vers le backend, plusieurs flux par\n # connexion. WebSocket reste en HTTP/1.1 sur le port HTTP : le port h2c\n # n'annonce pas le CONNECT étendu (RFC 8441).\n use_backend be_nodefony if { hdr(upgrade) -i websocket }\n default_backend be_nodefony_h2c\n"}
171
180
  backend be_nodefony
172
- ${staticNote} server nodefony ${intro.backendHost}:${backendPort} check${serverSsl}
173
- `;
181
+ ${healthCheck}${staticNote} server nodefony ${intro.backendHost}:${backendPort} check${serverSsl}
182
+ ${h2cPort === null ? "" : `\nbackend be_nodefony_h2c\n${healthCheck} server nodefony_h2c ${intro.backendHost}:${h2cPort} ${h2cCheck} proto h2\n`}`;
174
183
  }
175
184
  /** Premier domaine non-IP (pour verifyhost/sni), défaut `localhost`. */
176
185
  function firstDomain(intro) {
@@ -180,5 +189,4 @@ function firstDomain(intro) {
180
189
  function ensureTrailingSlash(dir) {
181
190
  return dir.endsWith("/") ? dir : `${dir}/`;
182
191
  }
183
- //#endregion
184
192
  export { defaultIntrospection, generateHaproxyConfig, generateNginxConfig };
@@ -1,5 +1,5 @@
1
1
  import { isLoopbackHostname } from "nodefony";
2
- //#region nodefony/src/proxy/rules.ts
2
+ // nodefony/src/proxy/rules.ts
3
3
  /**
4
4
  * Règles d'un montage du proxy inverse — UNIQUE source, lue par le schéma de
5
5
  * configuration (`proxy.mounts`) ET par `ReverseProxy.mount()` : un montage
@@ -157,12 +157,19 @@ function isAmbiguousPath(url) {
157
157
  return false;
158
158
  }
159
159
  /** Clé de handshake : 16 octets en base64 (RFC 6455 §4.1, §4.2.1). */
160
- const WS_KEY = /^[A-Za-z0-9+/]{22}==$/;
160
+ const WS_KEY = /^\s*[A-Za-z0-9+/]{22}==\s*$/;
161
+ /** `Upgrade: websocket`, casse et blancs indifférents. */
162
+ const UPGRADE_WEBSOCKET = /^\s*websocket\s*$/i;
163
+ /** Le jeton `upgrade` dans une liste `Connection` (RFC 9110 §7.6.1). */
164
+ const CONNECTION_UPGRADE = /(?:^|,)\s*upgrade\s*(?:,|$)/i;
161
165
  /**
162
166
  * Un upgrade est-il un handshake WebSocket conforme (RFC 6455 §4.2.1) ? Seul
163
167
  * un tel handshake est relayé : un `Upgrade` quelconque (`h2c`…) raccordé
164
168
  * octet pour octet ouvrirait un tunnel que rien n'inspecte.
165
169
  *
170
+ * Appelé à CHAQUE handshake (proxy inverse et routes de Nodefony) : aucune
171
+ * allocation — des expressions testées, jamais de découpe de chaîne.
172
+ *
166
173
  * @param method - méthode de la requête
167
174
  * @param headers - en-têtes reçus
168
175
  * @returns la raison du refus, ou `null` si le handshake est conforme
@@ -170,13 +177,12 @@ const WS_KEY = /^[A-Za-z0-9+/]{22}==$/;
170
177
  function websocketHandshakeProblem(method, headers) {
171
178
  if (method !== "GET") return "méthode autre que GET";
172
179
  const upgrade = headers.upgrade;
173
- if (typeof upgrade !== "string" || upgrade.trim().toLowerCase() !== "websocket") return "Upgrade autre que websocket";
180
+ if (typeof upgrade !== "string" || !UPGRADE_WEBSOCKET.test(upgrade)) return "Upgrade autre que websocket";
174
181
  const connection = headers.connection;
175
- if (!(Array.isArray(connection) ? connection.join(",") : connection ?? "").toLowerCase().split(",").map((t) => t.trim()).includes("upgrade")) return "Connection sans upgrade";
182
+ if (!(typeof connection === "string" ? CONNECTION_UPGRADE.test(connection) : Array.isArray(connection) && connection.some((token) => CONNECTION_UPGRADE.test(token)))) return "Connection sans upgrade";
176
183
  if (headers["sec-websocket-version"] !== "13") return "Sec-WebSocket-Version ≠ 13";
177
184
  const key = headers["sec-websocket-key"];
178
- if (typeof key !== "string" || !WS_KEY.test(key.trim())) return "Sec-WebSocket-Key invalide";
185
+ if (typeof key !== "string" || !WS_KEY.test(key)) return "Sec-WebSocket-Key invalide";
179
186
  return null;
180
187
  }
181
- //#endregion
182
188
  export { MODULE_PROXY_PREFIXES, RESERVED_PROXY_PREFIXES, isAmbiguousPath, normalizePrefix, parseProxyOrigin, proxyMountProblems, websocketHandshakeProblem };
@@ -1,5 +1,5 @@
1
1
  import { assertPageQuery } from "nodefony";
2
- //#region nodefony/src/rateLimit/MemoryRateLimitStore.ts
2
+ // nodefony/src/rateLimit/MemoryRateLimitStore.ts
3
3
  /**
4
4
  * Store de rate-limit **en mémoire** — algorithme *fixed window* par clé (IP).
5
5
  *
@@ -142,5 +142,4 @@ var MemoryRateLimitStore = class {
142
142
  }
143
143
  }
144
144
  };
145
- //#endregion
146
145
  export { MemoryRateLimitStore };
@@ -1,4 +1,4 @@
1
- //#region nodefony/src/rateLimit/WsConnectionCounter.ts
1
+ // nodefony/src/rateLimit/WsConnectionCounter.ts
2
2
  /**
3
3
  * Compteur de connexions WebSocket CONCURRENTES par IP — backstop opt-in
4
4
  * (F6c, revue 0.6). Distinct du {@link MemoryRateLimitStore} : celui-ci compte un
@@ -60,5 +60,4 @@ var WsConnectionCounter = class {
60
60
  return this.#rejectedTotal;
61
61
  }
62
62
  };
63
- //#endregion
64
63
  export { WsConnectionCounter, WsConnectionCounter as default };
@@ -1,4 +1,4 @@
1
- //#region nodefony/src/rateLimit/rateLimitFilters.ts
1
+ // nodefony/src/rateLimit/rateLimitFilters.ts
2
2
  /**
3
3
  * **Le vocabulaire de filtre du registre de rate-limit**, en noms PUBLICS —
4
4
  * celui que la console écrit dans l'URL (`?limited=true`).
@@ -16,5 +16,4 @@
16
16
  const RATE_LIMIT_FILTERS = {
17
17
  /** `true` = seulement les clés au plafond, `false` = seulement les autres. */
18
18
  limited: "boolean" };
19
- //#endregion
20
19
  export { RATE_LIMIT_FILTERS };
@@ -0,0 +1,50 @@
1
+ // nodefony/src/servers/http2Limits.ts
2
+ /**
3
+ * Limites anti-DoS d'un serveur HTTP/2, lues dans la section `http2` du module :
4
+ * `maxSessionMemory` (amplification mémoire) et `maxConcurrentStreams` dans les
5
+ * SETTINGS (CVE-2023-44487, Rapid Reset). Règle UNIQUE des serveurs HTTP/2 — TLS
6
+ * (5152) comme h2c — : une limite oubliée sur l'un d'eux en ferait la porte de
7
+ * l'attaque.
8
+ *
9
+ * Une limite absente garde le défaut de Node (aucune régression).
10
+ *
11
+ * @param limits - `module.options.http2`.
12
+ * @returns les options à fusionner dans celles de `createServer`/`createSecureServer`.
13
+ */
14
+ function http2ServerLimits(limits) {
15
+ const options = {};
16
+ if (limits?.maxSessionMemory) options.maxSessionMemory = limits.maxSessionMemory;
17
+ if (limits?.maxConcurrentStreams) options.settings = { maxConcurrentStreams: limits.maxConcurrentStreams };
18
+ return options;
19
+ }
20
+ /**
21
+ * Borne l'inactivité des sessions d'un serveur HTTP/2 — TLS (5152) comme h2c —
22
+ * et contourne un défaut de Node qui garde chaque session fermée en vie jusqu'à
23
+ * l'échéance de ce délai.
24
+ *
25
+ * Le défaut (Node 24, 26) : `closeSession` (`internal/http2/core`) marque la
26
+ * session détruite PUIS appelle `session.setTimeout(0)` ; or `setStreamTimeout`
27
+ * sort aussitôt sur un objet détruit. Le minuteur n'est jamais désarmé et retient
28
+ * la session (~2,4 Ko) pendant `timeoutMs` : sous un flux de connexions neuves,
29
+ * connexions/s × délai × 2,4 Ko. Signalé à Node : https://github.com/nodejs/node/issues/66885
30
+ *
31
+ * Le contournement : désarmer à la réception du GOAWAY, AVANT la destruction —
32
+ * c'est ainsi que ferment un proxy et un client Node, même par `destroy()`. Rien
33
+ * n'est retiré à la protection : Node n'agit déjà plus à l'échéance sur une
34
+ * session fermée (`sessionOnTimeout` sort sur `closed`). Une coupure TCP sans
35
+ * GOAWAY garde la rétention, bornée au délai.
36
+ *
37
+ * @param server - le serveur HTTP/2.
38
+ * @param timeoutMs - délai d'inactivité ; 0 ou absent le laisse désactivé.
39
+ * @param onTimeout - appelé à l'échéance (événement `timeout` du serveur).
40
+ */
41
+ function setHttp2SessionTimeout(server, timeoutMs, onTimeout) {
42
+ if (!timeoutMs) return;
43
+ server.setTimeout(timeoutMs, onTimeout);
44
+ server.on("session", (session) => {
45
+ session.once("goaway", () => {
46
+ session.setTimeout(0);
47
+ });
48
+ });
49
+ }
50
+ export { http2ServerLimits, setHttp2SessionTimeout };