@nodefony/http 10.0.0-beta.3 → 10.0.0-beta.4
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/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorateParam.js +1 -2
- package/dist/index.js +12 -10
- package/dist/nodefony/command/assetsPublishCommand.js +5 -3
- package/dist/nodefony/command/certificatesCommand.js +5 -3
- package/dist/nodefony/command/networkCommand.js +5 -3
- package/dist/nodefony/command/proxyGenerateCommand.js +8 -3
- package/dist/nodefony/config/config.js +1 -2
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/service/HttpAdminApi.js +2 -2
- package/dist/nodefony/service/ProfilerAdminApi.js +1 -2
- package/dist/nodefony/service/audit-logger.js +20 -13
- package/dist/nodefony/service/certificates.js +97 -13
- package/dist/nodefony/service/error-renderer.js +29 -12
- package/dist/nodefony/service/http-kernel.js +118 -43
- package/dist/nodefony/service/pretty-request-logger.js +5 -4
- package/dist/nodefony/service/request-logger.js +24 -16
- package/dist/nodefony/service/reverse-proxy.js +3 -3
- package/dist/nodefony/service/servers/clientError.js +22 -3
- package/dist/nodefony/service/servers/server-h2c.js +126 -0
- package/dist/nodefony/service/servers/server-http.js +5 -8
- package/dist/nodefony/service/servers/server-https.js +12 -24
- package/dist/nodefony/service/servers/server-static.js +3 -3
- package/dist/nodefony/service/servers/server-websocket-secure.js +10 -18
- package/dist/nodefony/service/servers/server-websocket.js +10 -18
- package/dist/nodefony/service/servers/serverShutdown.js +78 -18
- package/dist/nodefony/service/servers/wsHeartbeat.js +1 -2
- package/dist/nodefony/service/sessions/sessions-service.js +7 -14
- package/dist/nodefony/service/trace.js +1 -2
- package/dist/nodefony/service/upload/upload-service.js +5 -8
- package/dist/nodefony/service/x509.js +1 -2
- package/dist/nodefony/src/assets/collectAssets.js +1 -2
- package/dist/nodefony/src/assets/prebuiltUi.js +1 -2
- package/dist/nodefony/src/context/Context.js +23 -31
- package/dist/nodefony/src/context/domainMatcher.js +1 -2
- package/dist/nodefony/src/context/forwarded.js +1 -2
- package/dist/nodefony/src/context/http/HttpContext.js +7 -6
- package/dist/nodefony/src/context/http/Request.js +33 -29
- package/dist/nodefony/src/context/http/Response.js +23 -6
- package/dist/nodefony/src/context/http/SseStream.js +1 -2
- package/dist/nodefony/src/context/http/parser.js +36 -10
- package/dist/nodefony/src/context/http/urlFastPath.js +1 -2
- package/dist/nodefony/src/context/http2/Request.js +4 -8
- package/dist/nodefony/src/context/http2/Response.js +22 -9
- package/dist/nodefony/src/context/metaData.js +1 -2
- package/dist/nodefony/src/context/requestId.js +1 -2
- package/dist/nodefony/src/context/responseEnded.js +1 -2
- package/dist/nodefony/src/context/transport.js +45 -0
- package/dist/nodefony/src/context/trustProxy.js +1 -2
- package/dist/nodefony/src/context/websocket/Response.js +33 -6
- package/dist/nodefony/src/context/websocket/WebsocketContext.js +92 -17
- package/dist/nodefony/src/context/websocket/isWebsocketContext.js +17 -0
- package/dist/nodefony/src/context/websocket/websocketUrl.js +1 -2
- package/dist/nodefony/src/context/websocket/wsBackpressure.js +1 -2
- package/dist/nodefony/src/context/websocket/wsCloseReason.js +1 -2
- package/dist/nodefony/src/context/websocket/wsLogContent.js +1 -2
- package/dist/nodefony/src/cookies/cookie.js +3 -7
- package/dist/nodefony/src/errors/httpError.js +1 -2
- package/dist/nodefony/src/errors/thrownMessage.js +1 -2
- package/dist/nodefony/src/profiler/FrameProfile.js +1 -2
- package/dist/nodefony/src/profiler/Profiler.js +102 -20
- package/dist/nodefony/src/profiler/aggregate.js +422 -0
- package/dist/nodefony/src/proxy/forward.js +29 -5
- package/dist/nodefony/src/proxy/generateProxyConfig.js +14 -6
- package/dist/nodefony/src/proxy/rules.js +12 -6
- package/dist/nodefony/src/rateLimit/MemoryRateLimitStore.js +1 -2
- package/dist/nodefony/src/rateLimit/WsConnectionCounter.js +1 -2
- package/dist/nodefony/src/rateLimit/rateLimitFilters.js +1 -2
- package/dist/nodefony/src/servers/http2Limits.js +50 -0
- package/dist/nodefony/src/servers/kernelServers.js +12 -3
- package/dist/nodefony/src/servers/portBinder.js +41 -10
- package/dist/nodefony/src/servers/upgradeDispatch.js +86 -7
- package/dist/nodefony/src/session/session.js +2 -3
- package/dist/nodefony/src/session/sessionStoreFailure.js +1 -2
- package/dist/nodefony/src/session/storage/MemorySessionStorage.js +1 -2
- package/dist/nodefony/src/session/storage/RevocationGuardStorage.js +1 -2
- package/dist/nodefony/src/session/storage/sessionFilters.js +1 -2
- package/dist/nodefony/src/session/storage/sessionSort.js +1 -2
- package/dist/types/index.d.ts +7 -3
- package/dist/types/nodefony/interfaces/IContext.d.ts +63 -4
- package/dist/types/nodefony/interfaces/IRequest.d.ts +9 -4
- package/dist/types/nodefony/interfaces/IRouting.d.ts +10 -0
- package/dist/types/nodefony/interfaces/index.d.ts +2 -2
- package/dist/types/nodefony/service/certificates.d.ts +55 -4
- package/dist/types/nodefony/service/error-renderer.d.ts +31 -0
- package/dist/types/nodefony/service/http-kernel.d.ts +46 -10
- package/dist/types/nodefony/service/servers/clientError.d.ts +16 -0
- package/dist/types/nodefony/service/servers/server-h2c.d.ts +51 -0
- package/dist/types/nodefony/service/servers/server-http.d.ts +3 -3
- package/dist/types/nodefony/service/servers/server-https.d.ts +4 -4
- package/dist/types/nodefony/service/servers/server-websocket-secure.d.ts +1 -3
- package/dist/types/nodefony/service/servers/server-websocket.d.ts +1 -3
- package/dist/types/nodefony/service/servers/serverShutdown.d.ts +30 -16
- package/dist/types/nodefony/src/context/Context.d.ts +13 -4
- package/dist/types/nodefony/src/context/http/HttpContext.d.ts +1 -1
- package/dist/types/nodefony/src/context/http/Request.d.ts +20 -15
- package/dist/types/nodefony/src/context/http/Response.d.ts +11 -0
- package/dist/types/nodefony/src/context/http/parser.d.ts +18 -2
- package/dist/types/nodefony/src/context/http2/Request.d.ts +1 -1
- package/dist/types/nodefony/src/context/http2/Response.d.ts +15 -5
- package/dist/types/nodefony/src/context/transport.d.ts +53 -0
- package/dist/types/nodefony/src/context/websocket/Response.d.ts +18 -2
- package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +57 -7
- package/dist/types/nodefony/src/context/websocket/isWebsocketContext.d.ts +15 -0
- package/dist/types/nodefony/src/profiler/FrameProfile.d.ts +13 -12
- package/dist/types/nodefony/src/profiler/Profiler.d.ts +68 -15
- package/dist/types/nodefony/src/profiler/aggregate.d.ts +215 -0
- package/dist/types/nodefony/src/proxy/forward.d.ts +14 -2
- package/dist/types/nodefony/src/proxy/generateProxyConfig.d.ts +16 -0
- package/dist/types/nodefony/src/proxy/rules.d.ts +3 -0
- package/dist/types/nodefony/src/servers/http2Limits.d.ts +37 -0
- package/dist/types/nodefony/src/servers/kernelServers.d.ts +10 -1
- package/dist/types/nodefony/src/servers/portBinder.d.ts +29 -5
- package/dist/types/nodefony/src/servers/upgradeDispatch.d.ts +33 -3
- package/docs/cookies.md +85 -78
- package/docs/observabilite.md +80 -80
- package/docs/rate-limit.md +83 -83
- package/docs/reverse-proxy.md +54 -54
- package/docs/servers.md +338 -224
- package/docs/session.md +179 -179
- package/docs/sse.md +24 -20
- package/docs/upload.md +120 -117
- package/package.json +8 -9
- package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { MODULE_PROXY_PREFIXES } from "../proxy/rules.js";
|
|
2
|
+
// nodefony/src/profiler/aggregate.ts
|
|
3
|
+
/**
|
|
4
|
+
* Agrégation des profils de requêtes — règle PURE, sans horloge ni I/O.
|
|
5
|
+
*
|
|
6
|
+
* Une seule implémentation pour tout ce qui résume le profileur : l'outil MCP
|
|
7
|
+
* qui observe l'application (`nodefony_observe`), celui qui tire lui-même des
|
|
8
|
+
* requêtes sur une route, et demain la console d'administration. Elle est
|
|
9
|
+
* bornée par construction : un échantillon de durées par route, un nombre
|
|
10
|
+
* maximal de formes SQL retenues, quelques exemples d'erreurs — observer une
|
|
11
|
+
* application chargée ne fait jamais grossir la mémoire avec la fenêtre.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Répétitions d'une même forme SQL, dans UNE requête, à partir desquelles on
|
|
15
|
+
* signale un N+1. En dessous, ce sont des boucles courtes légitimes (quelques
|
|
16
|
+
* lectures de configuration, une pagination) qu'on signalerait à tort.
|
|
17
|
+
*/
|
|
18
|
+
const N_PLUS_ONE_MIN_REPEATS = 5;
|
|
19
|
+
/** Durées retenues par route pour les percentiles (échantillonnage au-delà). */
|
|
20
|
+
const ROUTE_SAMPLE_CAP = 256;
|
|
21
|
+
/** Formes SQL distinctes retenues ; les suivantes ne sont plus que comptées. */
|
|
22
|
+
const SQL_SHAPE_CAP = 200;
|
|
23
|
+
/** Exemples d'erreurs retenus (statut ≥ 500 ou exception). */
|
|
24
|
+
const ERROR_SAMPLE_CAP = 10;
|
|
25
|
+
/** Préfixe des routes de l'outillage (console, API d'administration, porte MCP). */
|
|
26
|
+
const TOOLING_URL_PREFIX = "/nodefony/";
|
|
27
|
+
/**
|
|
28
|
+
* Range un profil par provenance, d'après son URL. Seule règle du partage —
|
|
29
|
+
* les anneaux du profileur et le filtre de l'agrégation la lisent tous deux.
|
|
30
|
+
*
|
|
31
|
+
* @param url - URL du profil, absolue ou chemin.
|
|
32
|
+
* @returns la provenance du profil.
|
|
33
|
+
*/
|
|
34
|
+
function profileLane(url) {
|
|
35
|
+
const path = urlPath(url);
|
|
36
|
+
if (path.startsWith("/nodefony/")) return "tooling";
|
|
37
|
+
for (const prefix of MODULE_PROXY_PREFIXES) if (path.startsWith(prefix)) return "asset";
|
|
38
|
+
return "app";
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Le profil vient-il de l'outillage (routes `/nodefony/…` ou assets relayés)
|
|
42
|
+
* plutôt que de l'application.
|
|
43
|
+
*
|
|
44
|
+
* @param url - URL du profil, absolue ou chemin.
|
|
45
|
+
*/
|
|
46
|
+
function isToolingUrl(url) {
|
|
47
|
+
return profileLane(url) !== "app";
|
|
48
|
+
}
|
|
49
|
+
/** Groupe des requêtes qu'aucune route n'a servies (404, statique, sondes). */
|
|
50
|
+
const NO_ROUTE = "(aucune route)";
|
|
51
|
+
/** Pseudo-phase : le temps de l'action hors phases imbriquées et hors SQL. */
|
|
52
|
+
const ACTION_SELF_PHASE = "action.self";
|
|
53
|
+
/** Longueur maximale d'un message d'erreur rapporté. */
|
|
54
|
+
const ERROR_MESSAGE_MAX = 200;
|
|
55
|
+
/**
|
|
56
|
+
* Réduit une requête SQL (ou le descripteur d'une commande NoSQL) à sa FORME :
|
|
57
|
+
* valeurs remplacées par `?`, listes de valeurs repliées, blancs compactés.
|
|
58
|
+
*
|
|
59
|
+
* Deux requêtes qui ne diffèrent que par leurs valeurs ont la même forme — c'est
|
|
60
|
+
* ce qui permet de reconnaître un N+1. Et aucune valeur ne survit : la forme
|
|
61
|
+
* peut sortir dans un rapport sans emporter une donnée personnelle.
|
|
62
|
+
*
|
|
63
|
+
* @param sql - le texte tel que l'adaptateur l'a poussé au profileur.
|
|
64
|
+
* @returns la forme normalisée.
|
|
65
|
+
*/
|
|
66
|
+
function sqlShape(sql) {
|
|
67
|
+
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();
|
|
68
|
+
}
|
|
69
|
+
/** Phase `action` terminée : sa durée est connue. */
|
|
70
|
+
function isFinishedAction(p) {
|
|
71
|
+
return p.name === "action" && p.durationMs !== null;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Temps de l'action hors phases imbriquées et hors SQL : le code de l'action
|
|
75
|
+
* elle-même et des services qu'elle appelle.
|
|
76
|
+
*
|
|
77
|
+
* Les phases imbriquées (`initialize`, `render`, `send`…) ne sont pas
|
|
78
|
+
* nommées ici : est imbriquée toute phase terminée qui tient dans l'intervalle
|
|
79
|
+
* de l'action. Les intervalles (phases et SQL) sont fusionnés avant d'être
|
|
80
|
+
* retranchés — une requête SQL lancée pendant le rendu n'est pas comptée deux
|
|
81
|
+
* fois.
|
|
82
|
+
*
|
|
83
|
+
* @returns la durée propre en ms, ou `null` sans phase `action` terminée.
|
|
84
|
+
*/
|
|
85
|
+
function actionSelfMs(entry) {
|
|
86
|
+
const action = entry.phases.find(isFinishedAction);
|
|
87
|
+
if (action === void 0) return null;
|
|
88
|
+
const start = action.startMs;
|
|
89
|
+
const end = start + action.durationMs;
|
|
90
|
+
const inner = [];
|
|
91
|
+
for (const p of entry.phases) {
|
|
92
|
+
if (p === action || p.durationMs === null) continue;
|
|
93
|
+
const pEnd = p.startMs + p.durationMs;
|
|
94
|
+
if (p.startMs >= start && pEnd <= end) inner.push([p.startMs, pEnd]);
|
|
95
|
+
}
|
|
96
|
+
for (const q of entry.queries ?? []) {
|
|
97
|
+
if (q.startMs === void 0) continue;
|
|
98
|
+
const qStart = Math.max(q.startMs, start);
|
|
99
|
+
const qEnd = Math.min(q.startMs + q.durationMs, end);
|
|
100
|
+
if (qEnd > qStart) inner.push([qStart, qEnd]);
|
|
101
|
+
}
|
|
102
|
+
inner.sort((a, b) => a[0] - b[0]);
|
|
103
|
+
let covered = 0;
|
|
104
|
+
let curStart = -Infinity;
|
|
105
|
+
let curEnd = -Infinity;
|
|
106
|
+
for (const [s, e] of inner) if (s > curEnd) {
|
|
107
|
+
if (curEnd > curStart) covered += curEnd - curStart;
|
|
108
|
+
curStart = s;
|
|
109
|
+
curEnd = e;
|
|
110
|
+
} else if (e > curEnd) curEnd = e;
|
|
111
|
+
if (curEnd > curStart) covered += curEnd - curStart;
|
|
112
|
+
return Math.max(0, action.durationMs - covered);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Une requête en ERREUR du serveur : statut ≥ 500, ou exception levée sans
|
|
116
|
+
* verdict client. Règle unique — le rapport, ses exemples et la lecture de leurs
|
|
117
|
+
* journaux en dépendent.
|
|
118
|
+
*
|
|
119
|
+
* Un statut 4xx n'en est JAMAIS une, même porteur d'une exception : un refus
|
|
120
|
+
* 401/403 (l'authentification lève une `AuthenticationError`) ou une ressource
|
|
121
|
+
* absente est le verdict rendu au client, compté à part — le lister parmi les
|
|
122
|
+
* erreurs ferait chercher une panne là où il manque une identité.
|
|
123
|
+
*
|
|
124
|
+
* @param entry - le profil (seuls statut et erreur sont lus).
|
|
125
|
+
*/
|
|
126
|
+
function isFailedProfile(entry) {
|
|
127
|
+
if (entry.status === null) return entry.error !== null;
|
|
128
|
+
if (entry.status >= 500) return true;
|
|
129
|
+
return entry.status < 400 && entry.error !== null;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Le chemin d'une URL de profil. Le profileur enregistre l'URL ABSOLUE
|
|
133
|
+
* (`http://hôte:port/chemin`) : un filtre écrit sur le chemin ne mordrait
|
|
134
|
+
* jamais dessus. Sans `URL` à construire — appelée sur chaque profil lu.
|
|
135
|
+
*/
|
|
136
|
+
function urlPath(url) {
|
|
137
|
+
if (url.startsWith("/")) return url;
|
|
138
|
+
const scheme = url.indexOf("://");
|
|
139
|
+
if (scheme === -1) return url;
|
|
140
|
+
const slash = url.indexOf("/", scheme + 3);
|
|
141
|
+
return slash === -1 ? "/" : url.slice(slash);
|
|
142
|
+
}
|
|
143
|
+
/** Percentile au rang le plus proche, sur un tableau TRIÉ non vide. */
|
|
144
|
+
function percentile(sorted, p) {
|
|
145
|
+
if (sorted.length === 0) return null;
|
|
146
|
+
const rank = Math.ceil(p / 100 * sorted.length) - 1;
|
|
147
|
+
return sorted[Math.min(Math.max(rank, 0), sorted.length - 1)] ?? null;
|
|
148
|
+
}
|
|
149
|
+
/** Arrondi au centième de milliseconde : au-delà, c'est du bruit de mesure. */
|
|
150
|
+
function round(ms) {
|
|
151
|
+
return Math.round(ms * 100) / 100;
|
|
152
|
+
}
|
|
153
|
+
function emptyStatus() {
|
|
154
|
+
return {
|
|
155
|
+
s2xx: 0,
|
|
156
|
+
s3xx: 0,
|
|
157
|
+
s4xx: 0,
|
|
158
|
+
s5xx: 0,
|
|
159
|
+
unauthorized: 0,
|
|
160
|
+
forbidden: 0
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function countStatus(status, code) {
|
|
164
|
+
if (code === null) return;
|
|
165
|
+
if (code >= 500) status.s5xx += 1;
|
|
166
|
+
else if (code >= 400) {
|
|
167
|
+
status.s4xx += 1;
|
|
168
|
+
if (code === 401) status.unauthorized += 1;
|
|
169
|
+
else if (code === 403) status.forbidden += 1;
|
|
170
|
+
} else if (code >= 300) status.s3xx += 1;
|
|
171
|
+
else if (code >= 200) status.s2xx += 1;
|
|
172
|
+
}
|
|
173
|
+
function addPhase(phases, name, ms) {
|
|
174
|
+
const total = phases[name] ??= {
|
|
175
|
+
totalMs: 0,
|
|
176
|
+
count: 0
|
|
177
|
+
};
|
|
178
|
+
total.totalMs += ms;
|
|
179
|
+
total.count += 1;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Crée un accumulateur de profils.
|
|
183
|
+
*
|
|
184
|
+
* @param options - seuil N+1, prise en compte de l'outillage, aléa injecté.
|
|
185
|
+
* @returns l'accumulateur ; son `result()` peut être appelé à tout moment.
|
|
186
|
+
*/
|
|
187
|
+
function createProfileAggregator(options = {}) {
|
|
188
|
+
const minRepeats = options.nPlusOneMinRepeats ?? 5;
|
|
189
|
+
const includeTooling = options.includeTooling ?? false;
|
|
190
|
+
const random = options.random ?? Math.random;
|
|
191
|
+
const routes = /* @__PURE__ */ new Map();
|
|
192
|
+
const nPlusOne = /* @__PURE__ */ new Map();
|
|
193
|
+
const shapes = /* @__PURE__ */ new Map();
|
|
194
|
+
const errors = [];
|
|
195
|
+
let requests = 0;
|
|
196
|
+
let http = 0;
|
|
197
|
+
let ws = 0;
|
|
198
|
+
let filteredTooling = 0;
|
|
199
|
+
let untimed = 0;
|
|
200
|
+
let sqlShapesDropped = 0;
|
|
201
|
+
const droppedShapes = /* @__PURE__ */ new Set();
|
|
202
|
+
function routeAcc(entry, route) {
|
|
203
|
+
const key = `${entry.kind}\u0000${route}`;
|
|
204
|
+
let acc = routes.get(key);
|
|
205
|
+
if (acc === void 0) {
|
|
206
|
+
acc = {
|
|
207
|
+
stats: {
|
|
208
|
+
route,
|
|
209
|
+
kind: entry.kind,
|
|
210
|
+
controller: entry.controller,
|
|
211
|
+
action: entry.action,
|
|
212
|
+
count: 0,
|
|
213
|
+
untimed: 0,
|
|
214
|
+
served: 0,
|
|
215
|
+
p50Ms: null,
|
|
216
|
+
p95Ms: null,
|
|
217
|
+
maxMs: null,
|
|
218
|
+
status: emptyStatus(),
|
|
219
|
+
errors: 0,
|
|
220
|
+
queries: {
|
|
221
|
+
total: 0,
|
|
222
|
+
perRequestMax: 0,
|
|
223
|
+
totalMs: 0
|
|
224
|
+
},
|
|
225
|
+
phases: {}
|
|
226
|
+
},
|
|
227
|
+
samples: [],
|
|
228
|
+
seen: 0
|
|
229
|
+
};
|
|
230
|
+
routes.set(key, acc);
|
|
231
|
+
}
|
|
232
|
+
return acc;
|
|
233
|
+
}
|
|
234
|
+
function sample(acc, ms) {
|
|
235
|
+
acc.seen += 1;
|
|
236
|
+
if (acc.samples.length < 256) {
|
|
237
|
+
acc.samples.push(ms);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const slot = Math.floor(random() * acc.seen);
|
|
241
|
+
if (slot < 256) acc.samples[slot] = ms;
|
|
242
|
+
}
|
|
243
|
+
function countQueries(entry, stats) {
|
|
244
|
+
const queries = entry.queries;
|
|
245
|
+
if (queries === void 0 || queries.length === 0) return;
|
|
246
|
+
const q = stats.queries;
|
|
247
|
+
q.total += queries.length;
|
|
248
|
+
if (queries.length > q.perRequestMax) q.perRequestMax = queries.length;
|
|
249
|
+
for (const one of queries) q.totalMs += one.durationMs;
|
|
250
|
+
}
|
|
251
|
+
function addQueries(entry, route) {
|
|
252
|
+
const queries = entry.queries;
|
|
253
|
+
if (queries === void 0 || queries.length === 0) return;
|
|
254
|
+
const perShape = /* @__PURE__ */ new Map();
|
|
255
|
+
for (const q of queries) {
|
|
256
|
+
const shape = sqlShape(q.sql);
|
|
257
|
+
const local = perShape.get(shape);
|
|
258
|
+
if (local === void 0) perShape.set(shape, {
|
|
259
|
+
n: 1,
|
|
260
|
+
ms: q.durationMs
|
|
261
|
+
});
|
|
262
|
+
else {
|
|
263
|
+
local.n += 1;
|
|
264
|
+
local.ms += q.durationMs;
|
|
265
|
+
}
|
|
266
|
+
let global = shapes.get(shape);
|
|
267
|
+
if (global === void 0) {
|
|
268
|
+
if (shapes.size >= 200) {
|
|
269
|
+
if (!droppedShapes.has(shape) && droppedShapes.size < 200) droppedShapes.add(shape);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
global = {
|
|
273
|
+
shape,
|
|
274
|
+
connector: q.connector ?? null,
|
|
275
|
+
count: 0,
|
|
276
|
+
totalMs: 0,
|
|
277
|
+
maxMs: 0,
|
|
278
|
+
routes: /* @__PURE__ */ new Set()
|
|
279
|
+
};
|
|
280
|
+
shapes.set(shape, global);
|
|
281
|
+
}
|
|
282
|
+
global.count += 1;
|
|
283
|
+
global.totalMs += q.durationMs;
|
|
284
|
+
if (q.durationMs > global.maxMs) global.maxMs = q.durationMs;
|
|
285
|
+
if (global.routes.size < 5) global.routes.add(route);
|
|
286
|
+
}
|
|
287
|
+
for (const [shape, { n, ms }] of perShape) {
|
|
288
|
+
if (n < minRepeats) continue;
|
|
289
|
+
const key = `${route}\u0000${shape}`;
|
|
290
|
+
const found = nPlusOne.get(key);
|
|
291
|
+
if (found === void 0) nPlusOne.set(key, {
|
|
292
|
+
route,
|
|
293
|
+
controller: entry.controller,
|
|
294
|
+
action: entry.action,
|
|
295
|
+
shape,
|
|
296
|
+
maxRepeats: n,
|
|
297
|
+
requests: 1,
|
|
298
|
+
totalMs: ms,
|
|
299
|
+
sampleRequestId: entry.requestId
|
|
300
|
+
});
|
|
301
|
+
else {
|
|
302
|
+
found.requests += 1;
|
|
303
|
+
found.totalMs += ms;
|
|
304
|
+
if (n > found.maxRepeats) {
|
|
305
|
+
found.maxRepeats = n;
|
|
306
|
+
found.sampleRequestId = entry.requestId;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
add(entry) {
|
|
313
|
+
if (!includeTooling && isToolingUrl(entry.url)) {
|
|
314
|
+
filteredTooling += 1;
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
requests += 1;
|
|
318
|
+
if (entry.kind === "ws") ws += 1;
|
|
319
|
+
else http += 1;
|
|
320
|
+
const route = entry.route ?? "(aucune route)";
|
|
321
|
+
const acc = routeAcc(entry, route);
|
|
322
|
+
const stats = acc.stats;
|
|
323
|
+
stats.count += 1;
|
|
324
|
+
countStatus(stats.status, entry.status);
|
|
325
|
+
const failed = isFailedProfile(entry);
|
|
326
|
+
if (failed) {
|
|
327
|
+
stats.errors += 1;
|
|
328
|
+
if (errors.length < 10) errors.push({
|
|
329
|
+
route,
|
|
330
|
+
status: entry.status,
|
|
331
|
+
error: entry.error === null ? null : entry.error.slice(0, ERROR_MESSAGE_MAX),
|
|
332
|
+
requestId: entry.requestId
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
const served = !failed && (entry.status === null || entry.status < 400);
|
|
336
|
+
if (entry.durationMs === null) {
|
|
337
|
+
stats.untimed += 1;
|
|
338
|
+
untimed += 1;
|
|
339
|
+
} else if (served) {
|
|
340
|
+
stats.served += 1;
|
|
341
|
+
sample(acc, entry.durationMs);
|
|
342
|
+
if (stats.maxMs === null || entry.durationMs > stats.maxMs) stats.maxMs = entry.durationMs;
|
|
343
|
+
for (const p of entry.phases) if (p.durationMs !== null) addPhase(stats.phases, p.name, p.durationMs);
|
|
344
|
+
const self = actionSelfMs(entry);
|
|
345
|
+
if (self !== null) addPhase(stats.phases, ACTION_SELF_PHASE, self);
|
|
346
|
+
countQueries(entry, stats);
|
|
347
|
+
}
|
|
348
|
+
addQueries(entry, route);
|
|
349
|
+
sqlShapesDropped = droppedShapes.size;
|
|
350
|
+
return true;
|
|
351
|
+
},
|
|
352
|
+
result() {
|
|
353
|
+
const routeList = [];
|
|
354
|
+
for (const acc of routes.values()) {
|
|
355
|
+
const sorted = [...acc.samples].sort((a, b) => a - b);
|
|
356
|
+
const p50 = percentile(sorted, 50);
|
|
357
|
+
const p95 = percentile(sorted, 95);
|
|
358
|
+
const phases = {};
|
|
359
|
+
for (const [name, t] of Object.entries(acc.stats.phases)) phases[name] = {
|
|
360
|
+
totalMs: round(t.totalMs),
|
|
361
|
+
count: t.count
|
|
362
|
+
};
|
|
363
|
+
routeList.push({
|
|
364
|
+
...acc.stats,
|
|
365
|
+
p50Ms: p50 === null ? null : round(p50),
|
|
366
|
+
p95Ms: p95 === null ? null : round(p95),
|
|
367
|
+
maxMs: acc.stats.maxMs === null ? null : round(acc.stats.maxMs),
|
|
368
|
+
status: { ...acc.stats.status },
|
|
369
|
+
queries: {
|
|
370
|
+
...acc.stats.queries,
|
|
371
|
+
totalMs: round(acc.stats.queries.totalMs)
|
|
372
|
+
},
|
|
373
|
+
phases
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
routeList.sort((a, b) => (b.p95Ms ?? -1) - (a.p95Ms ?? -1));
|
|
377
|
+
const nPlusOneList = [...nPlusOne.values()].map((n) => ({
|
|
378
|
+
route: n.route,
|
|
379
|
+
controller: n.controller,
|
|
380
|
+
action: n.action,
|
|
381
|
+
shape: n.shape,
|
|
382
|
+
maxRepeats: n.maxRepeats,
|
|
383
|
+
requests: n.requests,
|
|
384
|
+
totalMs: round(n.totalMs),
|
|
385
|
+
sampleRequestId: n.sampleRequestId
|
|
386
|
+
})).sort((a, b) => b.totalMs - a.totalMs);
|
|
387
|
+
const slowQueries = [...shapes.values()].sort((a, b) => b.totalMs - a.totalMs).slice(0, 10).map((s) => ({
|
|
388
|
+
shape: s.shape,
|
|
389
|
+
connector: s.connector,
|
|
390
|
+
count: s.count,
|
|
391
|
+
totalMs: round(s.totalMs),
|
|
392
|
+
maxMs: round(s.maxMs),
|
|
393
|
+
routes: [...s.routes]
|
|
394
|
+
}));
|
|
395
|
+
return {
|
|
396
|
+
requests,
|
|
397
|
+
http,
|
|
398
|
+
ws,
|
|
399
|
+
filteredTooling,
|
|
400
|
+
untimed,
|
|
401
|
+
routes: routeList,
|
|
402
|
+
nPlusOne: nPlusOneList,
|
|
403
|
+
slowQueries,
|
|
404
|
+
sqlShapesDropped,
|
|
405
|
+
errors: errors.slice()
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Agrège une liste de profils en une fois.
|
|
412
|
+
*
|
|
413
|
+
* @param entries - les profils, dans n'importe quel ordre.
|
|
414
|
+
* @param options - mêmes réglages que {@link createProfileAggregator}.
|
|
415
|
+
* @returns le rapport.
|
|
416
|
+
*/
|
|
417
|
+
function aggregateProfiles(entries, options = {}) {
|
|
418
|
+
const aggregator = createProfileAggregator(options);
|
|
419
|
+
for (const entry of entries) aggregator.add(entry);
|
|
420
|
+
return aggregator.result();
|
|
421
|
+
}
|
|
422
|
+
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, isToolingUrl, profileLane, sqlShape, urlPath };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
2
|
import https from "node:https";
|
|
3
|
-
|
|
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
|
-
/**
|
|
385
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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" ||
|
|
180
|
+
if (typeof upgrade !== "string" || !UPGRADE_WEBSOCKET.test(upgrade)) return "Upgrade autre que websocket";
|
|
174
181
|
const connection = headers.connection;
|
|
175
|
-
if (!(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|