@nodefony/devkit 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.
- package/README.md +146 -4
- 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 +24 -11
- package/dist/nodefony/config/config.js +16 -137
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/controllers/DevkitController.js +7 -14
- package/dist/nodefony/controllers/McpController.js +25 -226
- package/dist/nodefony/controllers/ProfileSessionController.js +47 -0
- package/dist/nodefony/mcp/observe.js +235 -0
- package/dist/nodefony/mcp/profileReport.js +242 -0
- package/dist/nodefony/mcp/profileRoute.js +521 -0
- package/dist/nodefony/mcp/shotClient.js +84 -0
- package/dist/nodefony/mcp/shutdown.js +44 -0
- package/dist/nodefony/mcp/tools.js +696 -0
- package/dist/nodefony/service/DevkitService.js +93 -29
- package/dist/nodefony/src/errors/DevkitError.js +1 -2
- package/dist/nodefony/src/profileSession.js +14 -0
- package/dist/types/index.d.ts +15 -1
- package/dist/types/nodefony/config/config.d.ts +1 -1
- package/dist/types/nodefony/controllers/McpController.d.ts +11 -26
- package/dist/types/nodefony/controllers/ProfileSessionController.d.ts +31 -0
- package/dist/types/nodefony/interfaces/IDevkitService.d.ts +22 -1
- package/dist/types/nodefony/mcp/observe.d.ts +83 -0
- package/dist/types/nodefony/mcp/profileReport.d.ts +138 -0
- package/dist/types/nodefony/mcp/profileRoute.d.ts +87 -0
- package/dist/types/nodefony/mcp/shotClient.d.ts +53 -0
- package/dist/types/nodefony/mcp/shutdown.d.ts +25 -0
- package/dist/types/nodefony/mcp/tools.d.ts +109 -0
- package/dist/types/nodefony/service/DevkitService.d.ts +41 -4
- package/dist/types/nodefony/src/profileSession.d.ts +12 -0
- package/docs/index.md +160 -11
- package/package.json +16 -11
- package/skills/nodefony-add-crud/SKILL.md +10 -1
- package/skills/nodefony-add-service/SKILL.md +3 -3
- package/skills/nodefony-authentication/SKILL.md +5 -5
- package/skills/nodefony-browser/SKILL.md +20 -0
- package/skills/nodefony-browser/references/socket.md +13 -4
- package/skills/nodefony-browser/references/sondes.md +13 -1
- package/skills/nodefony-browser/scripts/inspect.mjs +55 -8
- package/skills/nodefony-browser/scripts/lib/probes.mjs +53 -0
- package/skills/nodefony-browser/scripts/socket.mjs +70 -4
- package/skills/nodefony-browser/scripts/watch.mjs +101 -0
- package/skills/nodefony-dev/SKILL.md +31 -1
- package/skills/nodefony-dev/scripts/docs.mjs +3 -3
- package/skills/nodefony-devops/SKILL.md +85 -64
- package/skills/nodefony-devops/references/dimensionnement.md +122 -0
- package/skills/nodefony-devops/references/frontal.md +277 -47
- package/skills/nodefony-devops/references/kubernetes.md +41 -14
- package/skills/nodefony-devops/references/secrets.md +13 -2
- package/skills/nodefony-devops/references/variables.md +14 -12
- package/skills/nodefony-protect-route/SKILL.md +6 -4
- package/skills/nodefony-security-review/scripts/review-diff.mjs +4 -4
- package/skills/nodefony-troubleshoot/SKILL.md +86 -12
- package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
import { renderProfileReport } from "./profileReport.js";
|
|
2
|
+
import { watchToolStop } from "./shutdown.js";
|
|
3
|
+
import { NOT_SERVING_MESSAGE } from "./observe.js";
|
|
4
|
+
import { SHOT_REQUEST_TIMEOUT_MS, createShotClient } from "./shotClient.js";
|
|
5
|
+
import { PROFILE_SESSION_PATH } from "../src/profileSession.js";
|
|
6
|
+
import { ADMIN_SCOPE_WRITE, EVENT_LOOP_RESOLUTION_MS, ProcessProbe } from "nodefony";
|
|
7
|
+
import { MCP_RESERVED_PREFIX, SESSION_TICKET_HEADER, mcpText, readTicketSessionVerdict } from "@nodefony/ai";
|
|
8
|
+
import { setTimeout } from "node:timers/promises";
|
|
9
|
+
import { N_PLUS_ONE_MIN_REPEATS, aggregateProfiles, isFailedProfile } from "@nodefony/http";
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
// nodefony/mcp/profileRoute.ts
|
|
12
|
+
/** Requêtes mesurées par défaut. */
|
|
13
|
+
const PROFILE_ROUTE_DEFAULT_COUNT = 50;
|
|
14
|
+
/**
|
|
15
|
+
* Requêtes mesurées au plus. Avec la chauffe, sous les 500 profils que garde le
|
|
16
|
+
* profileur : au-delà, les premiers seraient évincés avant d'être lus.
|
|
17
|
+
*/
|
|
18
|
+
const PROFILE_ROUTE_MAX_COUNT = 200;
|
|
19
|
+
/** Requêtes simultanées par défaut. */
|
|
20
|
+
const PROFILE_ROUTE_DEFAULT_CONCURRENCY = 4;
|
|
21
|
+
/** Requêtes simultanées au plus : mesurer une route, pas saturer le process. */
|
|
22
|
+
const PROFILE_ROUTE_MAX_CONCURRENCY = 8;
|
|
23
|
+
/** Requêtes de chauffe par défaut, tirées AVANT et hors des mesures. */
|
|
24
|
+
const PROFILE_ROUTE_DEFAULT_WARMUP = 3;
|
|
25
|
+
/** Requêtes de chauffe au plus. */
|
|
26
|
+
const PROFILE_ROUTE_MAX_WARMUP = 20;
|
|
27
|
+
/**
|
|
28
|
+
* Budget de temps d'un tir, en ms. Sous les 60 s qu'un client MCP attend par
|
|
29
|
+
* défaut : au-delà, l'agent couperait l'appel juste avant le rapport.
|
|
30
|
+
*/
|
|
31
|
+
const PROFILE_ROUTE_BUDGET_MS = 45e3;
|
|
32
|
+
/**
|
|
33
|
+
* Attente maximale, en ms, d'un profil après la réponse : le profileur le
|
|
34
|
+
* collecte au `close` de la réponse, émis en différé — rien ne garantit qu'il
|
|
35
|
+
* précède la fin de lecture côté client, dans le même process.
|
|
36
|
+
*/
|
|
37
|
+
const PROFILE_COLLECT_WAIT_MS = 500;
|
|
38
|
+
/**
|
|
39
|
+
* Périodes d'échantillonnage de la boucle d'événements en deçà desquelles la
|
|
40
|
+
* sonde n'a rien mesuré de fiable : un tir plus court rend « non mesurée »,
|
|
41
|
+
* jamais un 0 qui passerait pour une boucle libre.
|
|
42
|
+
*/
|
|
43
|
+
const EVENT_LOOP_MIN_PERIODS = 5;
|
|
44
|
+
/** Longueur maximale d'un chemin profilé. */
|
|
45
|
+
const PROFILE_PATH_MAX = 2048;
|
|
46
|
+
/** Instantanés de progression par tir, au plus. */
|
|
47
|
+
const PROGRESS_STEPS = 10;
|
|
48
|
+
const LOOPBACK_BASE = "http://127.0.0.1";
|
|
49
|
+
/**
|
|
50
|
+
* Le contrat de l'outil tel que l'agent le lit dans `tools/list`. Les bornes
|
|
51
|
+
* citées sont les constantes du code. Composée une fois, au chargement.
|
|
52
|
+
*/
|
|
53
|
+
const PROFILE_ROUTE_DESCRIPTION = `Mesure UNE route de l'application Nodefony qui tourne : l'outil lui envoie lui-même N requêtes, retrouve leurs profils par l'identifiant qu'il pose (x-request-id) et rend le même rapport que nodefony_observe — p50/p95, requêtes SQL par requête, N+1, où part le temps (action.self, SQL, render, firewall), erreurs. Ferme la boucle « je modifie → je mesure » en un appel.
|
|
54
|
+
|
|
55
|
+
QUAND : après avoir modifié le contrôleur, le service ou la requête d'une page ; pour comparer avant/après ; face à « cette page est lente » ou « trop de requêtes SQL ». Pour observer un trafic que l'on génère soi-même (tests, navigation), prendre nodefony_observe.
|
|
56
|
+
|
|
57
|
+
COMMENT :
|
|
58
|
+
1. path = un CHEMIN de l'application avec sa requête (\`/orders?page=2\`), jamais une URL : hôte, schéma et \`//\` sont refusés.
|
|
59
|
+
2. count (défaut 50, max 200), concurrency (défaut 4, max 8), warmup (défaut 3, max 20 : requêtes tirées avant, hors mesures — le premier appel compile et se connecte), method GET (défaut) ou HEAD.
|
|
60
|
+
3. IDENTITÉ — une page protégée vue sans compte ne rend que des refus. Par défaut l'outil joue le PROPRIÉTAIRE du jeton MCP si ce jeton porte le scope ${ADMIN_SCOPE_WRITE} : il ouvre une session à son nom (tous ses rôles), tire avec, puis la détruit. \`as: "<identifiant>"\` joue un autre compte (scope ${ADMIN_SCOPE_WRITE}, et ses rôles doivent être inclus dans ceux du propriétaire). \`anonymous: true\` force l'anonyme. Sans ce scope : anonyme, et le rapport dit comment obtenir le jeton.
|
|
61
|
+
4. Corriger la cause nommée, rappeler l'outil avec les mêmes arguments et comparer le bloc « Données (JSON) ».
|
|
62
|
+
Ne modifier AUCUN fichier pendant le tir : le serveur redémarre et le rapport rendu est partiel (il le dit).
|
|
63
|
+
|
|
64
|
+
LE RAPPORT : en tête, ce qui invalide la mesure — requêtes REFUSÉES (401/403), REDIRIGÉES (page de connexion), sans réponse, profils non retrouvés ; puis le tir (identité jouée, chauffe, statuts reçus), les anomalies (« N+1 » : même requête SQL ≥ ${N_PLUS_ONE_MIN_REPEATS} fois par requête ; « Lente » : p95 ≥ 500 ms ; « Erreurs » 5xx avec leur journal), le tableau par route et les requêtes SQL les plus coûteuses (valeurs masquées).
|
|
65
|
+
|
|
66
|
+
LIMITES : développement seulement ; GET et HEAD seulement ; HTTP en clair de l'application elle-même. Les zones à jeton (jwt, external-jwt) ne sont pas jouées : leurs requêtes rendent 401, et le rapport le dit. Tir borné à ${PROFILE_ROUTE_BUDGET_MS / 1e3} s, ${SHOT_REQUEST_TIMEOUT_MS / 1e3} s par requête. Un instantané de progression par lot.`;
|
|
67
|
+
/**
|
|
68
|
+
* Contrôle le chemin à profiler : un CHEMIN de l'application, jamais une URL —
|
|
69
|
+
* l'outil ne doit pouvoir viser qu'elle.
|
|
70
|
+
*
|
|
71
|
+
* @param raw - l'argument `path`, non fiable.
|
|
72
|
+
* @returns le chemin normalisé (fragment retiré), ou la raison du refus.
|
|
73
|
+
*/
|
|
74
|
+
function checkProfilePath(raw) {
|
|
75
|
+
if (typeof raw !== "string" || raw.length === 0) return {
|
|
76
|
+
ok: false,
|
|
77
|
+
reason: "path est requis : un chemin de l'application, ex. /orders?page=2"
|
|
78
|
+
};
|
|
79
|
+
if (raw.length > PROFILE_PATH_MAX) return {
|
|
80
|
+
ok: false,
|
|
81
|
+
reason: `path dépasse ${PROFILE_PATH_MAX} caractères`
|
|
82
|
+
};
|
|
83
|
+
if (!raw.startsWith("/") || raw.startsWith("//") || /[\\\s\u0000-\u001f\u007f]/u.test(raw)) return {
|
|
84
|
+
ok: false,
|
|
85
|
+
reason: "path doit être un CHEMIN commençant par un seul « / », sans antislash, espace ni caractère de contrôle — jamais une URL ni un hôte : l'outil ne vise que l'application"
|
|
86
|
+
};
|
|
87
|
+
const url = new URL(raw, LOOPBACK_BASE);
|
|
88
|
+
if (url.origin !== LOOPBACK_BASE) return {
|
|
89
|
+
ok: false,
|
|
90
|
+
reason: "path sort de l'application"
|
|
91
|
+
};
|
|
92
|
+
return {
|
|
93
|
+
ok: true,
|
|
94
|
+
path: url.pathname + url.search
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** Entier borné — et la remarque à faire s'il a été corrigé. */
|
|
98
|
+
function readBounded(raw, name, fallback, min, max) {
|
|
99
|
+
if (raw === void 0) return {
|
|
100
|
+
value: fallback,
|
|
101
|
+
note: null
|
|
102
|
+
};
|
|
103
|
+
if (typeof raw !== "number" || !Number.isFinite(raw)) return {
|
|
104
|
+
value: fallback,
|
|
105
|
+
note: `${name} illisible, ${fallback} retenu`
|
|
106
|
+
};
|
|
107
|
+
const value = Math.min(Math.max(Math.round(raw), min), max);
|
|
108
|
+
return {
|
|
109
|
+
value,
|
|
110
|
+
note: value === raw ? null : `${name} ramené à ${value} (bornes ${min}–${max})`
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** L'identité à jouer, ou le refus à rendre à l'agent. */
|
|
114
|
+
async function resolveIdentity(args, caller, deps) {
|
|
115
|
+
if (args.anonymous === true) return {
|
|
116
|
+
ok: true,
|
|
117
|
+
identity: {
|
|
118
|
+
kind: "anonymous",
|
|
119
|
+
why: null
|
|
120
|
+
},
|
|
121
|
+
account: null
|
|
122
|
+
};
|
|
123
|
+
const owner = caller.authenticated && typeof caller.subject === "string" && caller.subject.length > 0 && caller.scopes.includes(ADMIN_SCOPE_WRITE) ? caller.subject : null;
|
|
124
|
+
const as = typeof args.as === "string" && args.as.length > 0 ? args.as : null;
|
|
125
|
+
if (as !== null) {
|
|
126
|
+
if (owner === null) return {
|
|
127
|
+
ok: false,
|
|
128
|
+
message: `\`as\` exige un jeton MCP portant le scope ${ADMIN_SCOPE_WRITE} : jouer un compte, c'est agir en son nom. Jeton : \`npx nodefony security:mcp:token --scope "admin:read admin:write" --write\`.`
|
|
129
|
+
};
|
|
130
|
+
if (as !== owner) {
|
|
131
|
+
const verdict = deps.rolesWithin === void 0 ? {
|
|
132
|
+
ok: false,
|
|
133
|
+
reason: "comparaison des rôles indisponible"
|
|
134
|
+
} : await deps.rolesWithin(owner, as);
|
|
135
|
+
if (!verdict.ok) return {
|
|
136
|
+
ok: false,
|
|
137
|
+
message: `\`as: "${as}"\` refusé — ${verdict.reason}. Jouer un autre compte ne doit jamais monter en droits : ses rôles doivent être inclus dans ceux du propriétaire du jeton (\`${owner}\`).`
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
ok: true,
|
|
142
|
+
identity: {
|
|
143
|
+
kind: "anonymous",
|
|
144
|
+
why: null
|
|
145
|
+
},
|
|
146
|
+
account: as
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
if (owner !== null) return {
|
|
150
|
+
ok: true,
|
|
151
|
+
identity: {
|
|
152
|
+
kind: "anonymous",
|
|
153
|
+
why: null
|
|
154
|
+
},
|
|
155
|
+
account: owner
|
|
156
|
+
};
|
|
157
|
+
return {
|
|
158
|
+
ok: true,
|
|
159
|
+
account: null,
|
|
160
|
+
identity: {
|
|
161
|
+
kind: "anonymous",
|
|
162
|
+
why: caller.authenticated ? `le jeton MCP n'a pas le scope ${ADMIN_SCOPE_WRITE}` : "appel sans jeton MCP"
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/** Ouvre la session du tir par la route de connexion du profilage. */
|
|
167
|
+
async function openShotSession(client, issueTicket, account, signal) {
|
|
168
|
+
let ticket;
|
|
169
|
+
try {
|
|
170
|
+
ticket = issueTicket(account);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
return {
|
|
173
|
+
ok: false,
|
|
174
|
+
message: error instanceof Error ? error.message : String(error)
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
const res = await client.send({
|
|
178
|
+
method: "POST",
|
|
179
|
+
path: PROFILE_SESSION_PATH,
|
|
180
|
+
headers: { [SESSION_TICKET_HEADER]: ticket },
|
|
181
|
+
readBody: true
|
|
182
|
+
}, signal);
|
|
183
|
+
const read = readTicketSessionVerdict(res.body);
|
|
184
|
+
if (res.status !== 200 || "message" in read) {
|
|
185
|
+
const why = "message" in read ? read.message : `statut ${res.status}`;
|
|
186
|
+
return {
|
|
187
|
+
ok: false,
|
|
188
|
+
message: `session de profilage non ouverte pour \`${account}\` (${res.status} — ${why}). ` + (res.status === 403 ? "Compte inconnu, inactif ou verrouillé ?" : res.status === 503 ? "" : "La route de connexion du profilage répond-elle ?")
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
const cookie = res.setCookie.map((c) => c.split(";", 1)[0] ?? "").filter((c) => c.length > 0).join("; ");
|
|
192
|
+
if (cookie.length === 0) return {
|
|
193
|
+
ok: false,
|
|
194
|
+
message: `session ouverte pour \`${account}\`, mais aucun cookie reçu`
|
|
195
|
+
};
|
|
196
|
+
return {
|
|
197
|
+
ok: true,
|
|
198
|
+
session: {
|
|
199
|
+
cookie,
|
|
200
|
+
ref: read.ref,
|
|
201
|
+
identifier: read.identifier,
|
|
202
|
+
roles: read.roles
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/** Comptes du client, mis à jour réponse par réponse. */
|
|
207
|
+
function emptyClientStatus() {
|
|
208
|
+
return {
|
|
209
|
+
ok: 0,
|
|
210
|
+
redirected: 0,
|
|
211
|
+
unauthorized: 0,
|
|
212
|
+
forbidden: 0,
|
|
213
|
+
clientError: 0,
|
|
214
|
+
serverError: 0,
|
|
215
|
+
failed: 0,
|
|
216
|
+
location: null,
|
|
217
|
+
failure: null
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function countStatus(c, status, location) {
|
|
221
|
+
if (status === 401) c.unauthorized += 1;
|
|
222
|
+
else if (status === 403) c.forbidden += 1;
|
|
223
|
+
else if (status >= 500) c.serverError += 1;
|
|
224
|
+
else if (status >= 400) c.clientError += 1;
|
|
225
|
+
else if (status >= 300) {
|
|
226
|
+
c.redirected += 1;
|
|
227
|
+
c.location ??= location;
|
|
228
|
+
} else c.ok += 1;
|
|
229
|
+
}
|
|
230
|
+
/** Le tir proprement dit : un groupe de requêtes, `concurrency` à la fois. */
|
|
231
|
+
async function fire(client, ids, opts) {
|
|
232
|
+
const outcomes = [];
|
|
233
|
+
let next = 0;
|
|
234
|
+
let truncated = false;
|
|
235
|
+
const worker = async () => {
|
|
236
|
+
for (;;) {
|
|
237
|
+
if (opts.signal.aborted) return;
|
|
238
|
+
if (performance.now() >= opts.deadline) {
|
|
239
|
+
truncated = next < ids.length;
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const index = next;
|
|
243
|
+
next += 1;
|
|
244
|
+
const requestId = ids[index];
|
|
245
|
+
if (requestId === void 0) return;
|
|
246
|
+
try {
|
|
247
|
+
const res = await client.send({
|
|
248
|
+
method: opts.method,
|
|
249
|
+
path: opts.path,
|
|
250
|
+
headers: {
|
|
251
|
+
...opts.headers,
|
|
252
|
+
"x-request-id": requestId
|
|
253
|
+
}
|
|
254
|
+
}, opts.signal);
|
|
255
|
+
outcomes.push({
|
|
256
|
+
requestId,
|
|
257
|
+
status: res.status
|
|
258
|
+
});
|
|
259
|
+
if (opts.status !== null) countStatus(opts.status, res.status, res.location);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (error instanceof Error && error.name === "AbortError") return;
|
|
262
|
+
outcomes.push({
|
|
263
|
+
requestId,
|
|
264
|
+
status: null
|
|
265
|
+
});
|
|
266
|
+
if (opts.status !== null) {
|
|
267
|
+
opts.status.failed += 1;
|
|
268
|
+
opts.status.failure ??= error instanceof Error ? error.message : String(error);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
opts.onDone?.(outcomes.length);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
await Promise.all(Array.from({ length: opts.concurrency }, worker));
|
|
275
|
+
return {
|
|
276
|
+
outcomes,
|
|
277
|
+
truncated
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Retrouve les profils des requêtes tirées, par leur identifiant. Un profil
|
|
282
|
+
* peut arriver APRÈS la réponse : attente bornée, puis le reste est compté.
|
|
283
|
+
*/
|
|
284
|
+
async function collectProfiles(profiler, outcomes, waitMs) {
|
|
285
|
+
const entries = [];
|
|
286
|
+
let pending = [];
|
|
287
|
+
for (const o of outcomes) {
|
|
288
|
+
if (o.status === null) continue;
|
|
289
|
+
const entry = profiler.get(o.requestId);
|
|
290
|
+
if (entry === void 0) pending.push(o.requestId);
|
|
291
|
+
else entries.push(entry);
|
|
292
|
+
}
|
|
293
|
+
const deadline = performance.now() + waitMs;
|
|
294
|
+
while (pending.length > 0 && performance.now() < deadline) {
|
|
295
|
+
await setTimeout(10);
|
|
296
|
+
pending = pending.filter((id) => {
|
|
297
|
+
const entry = profiler.get(id);
|
|
298
|
+
if (entry === void 0) return true;
|
|
299
|
+
entries.push(entry);
|
|
300
|
+
return false;
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
entries,
|
|
305
|
+
missing: pending.length
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* L'outil `nodefony_profile_route` : envoie lui-même N requêtes à une route de
|
|
310
|
+
* l'application, au nom d'un compte s'il le faut, et rend leur profil agrégé.
|
|
311
|
+
*
|
|
312
|
+
* Rien n'est armé hors d'un appel : le client du tir (ses connexions), la
|
|
313
|
+
* session de profilage et l'écoute de l'arrêt du serveur naissent avec l'appel
|
|
314
|
+
* et sont libérés à sa sortie, quelle qu'elle soit.
|
|
315
|
+
*
|
|
316
|
+
* @param deps - profileur, adresse d'écoute, ouverture de session, journal.
|
|
317
|
+
*/
|
|
318
|
+
function profileRouteTool(deps) {
|
|
319
|
+
return {
|
|
320
|
+
name: `${MCP_RESERVED_PREFIX}profile_route`,
|
|
321
|
+
annotations: {
|
|
322
|
+
readOnlyHint: false,
|
|
323
|
+
destructiveHint: false,
|
|
324
|
+
idempotentHint: true,
|
|
325
|
+
openWorldHint: false
|
|
326
|
+
},
|
|
327
|
+
description: PROFILE_ROUTE_DESCRIPTION,
|
|
328
|
+
inputSchema: {
|
|
329
|
+
type: "object",
|
|
330
|
+
required: ["path"],
|
|
331
|
+
properties: {
|
|
332
|
+
path: {
|
|
333
|
+
type: "string",
|
|
334
|
+
description: "CHEMIN de l'application, requête comprise (`/orders?page=2`) — jamais une URL"
|
|
335
|
+
},
|
|
336
|
+
count: {
|
|
337
|
+
type: "integer",
|
|
338
|
+
minimum: 1,
|
|
339
|
+
maximum: 200,
|
|
340
|
+
description: `Requêtes mesurées (défaut 50, max 200)`
|
|
341
|
+
},
|
|
342
|
+
concurrency: {
|
|
343
|
+
type: "integer",
|
|
344
|
+
minimum: 1,
|
|
345
|
+
maximum: 8,
|
|
346
|
+
description: `Requêtes simultanées (défaut 4, max 8)`
|
|
347
|
+
},
|
|
348
|
+
warmup: {
|
|
349
|
+
type: "integer",
|
|
350
|
+
minimum: 0,
|
|
351
|
+
maximum: 20,
|
|
352
|
+
description: `Requêtes de chauffe, hors mesures (défaut 3, max 20)`
|
|
353
|
+
},
|
|
354
|
+
method: {
|
|
355
|
+
type: "string",
|
|
356
|
+
enum: ["GET", "HEAD"],
|
|
357
|
+
description: "Méthode (défaut GET)"
|
|
358
|
+
},
|
|
359
|
+
as: {
|
|
360
|
+
type: "string",
|
|
361
|
+
description: `Identifiant du compte à jouer (scope ${ADMIN_SCOPE_WRITE} ; rôles inclus dans ceux du propriétaire du jeton)`
|
|
362
|
+
},
|
|
363
|
+
anonymous: {
|
|
364
|
+
type: "boolean",
|
|
365
|
+
description: "Jouer en anonyme, même avec un jeton qui permettrait un compte"
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
scopesFor: (args) => args.anonymous !== true && typeof args.as === "string" && args.as !== "" ? [ADMIN_SCOPE_WRITE] : [],
|
|
370
|
+
handler: async (args, caller, run) => {
|
|
371
|
+
if (deps.servesTraffic?.() === false) return mcpText(NOT_SERVING_MESSAGE, true);
|
|
372
|
+
const profiler = deps.getProfiler();
|
|
373
|
+
if (profiler === void 0) return mcpText("Aucun profileur dans cette application : il n'existe qu'en développement, et seulement si `@nodefony/http` est chargé.", true);
|
|
374
|
+
const path = checkProfilePath(args.path);
|
|
375
|
+
if (!path.ok) return mcpText(`Refusé : ${path.reason}.`, true);
|
|
376
|
+
if (args.method !== void 0 && args.method !== "GET" && args.method !== "HEAD") return mcpText("Refusé : method vaut GET ou HEAD — les seules méthodes sûres et idempotentes (RFC 9110) ; l'outil ne déclenche aucune écriture.", true);
|
|
377
|
+
const target = deps.getTarget();
|
|
378
|
+
if (target === null) return mcpText("Aucun serveur HTTP en clair prêt dans cette application : rien à viser.", true);
|
|
379
|
+
const identity = await resolveIdentity(args, caller, deps);
|
|
380
|
+
if (!identity.ok) return mcpText(identity.message, true);
|
|
381
|
+
const count = readBounded(args.count, "count", 50, 1, 200);
|
|
382
|
+
const concurrency = readBounded(args.concurrency, "concurrency", 4, 1, 8);
|
|
383
|
+
const warmup = readBounded(args.warmup, "warmup", 3, 0, 20);
|
|
384
|
+
const notes = [
|
|
385
|
+
count.note,
|
|
386
|
+
concurrency.note,
|
|
387
|
+
warmup.note
|
|
388
|
+
].filter((n) => n !== null);
|
|
389
|
+
return profileRoute(profiler, target, deps, {
|
|
390
|
+
method: args.method === "HEAD" ? "HEAD" : "GET",
|
|
391
|
+
path: path.path,
|
|
392
|
+
count: count.value,
|
|
393
|
+
concurrency: concurrency.value,
|
|
394
|
+
warmup: warmup.value,
|
|
395
|
+
identity: identity.identity,
|
|
396
|
+
account: identity.account,
|
|
397
|
+
caller,
|
|
398
|
+
run,
|
|
399
|
+
notes
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
async function profileRoute(profiler, target, deps, opts) {
|
|
405
|
+
const stop = watchToolStop(opts.run, deps.onShutdown);
|
|
406
|
+
const client = (deps.createClient ?? createShotClient)(target, opts.concurrency);
|
|
407
|
+
const probe = (deps.createProbe ?? (() => new ProcessProbe()))();
|
|
408
|
+
const budgetMs = deps.budgetMs ?? 45e3;
|
|
409
|
+
const started = performance.now();
|
|
410
|
+
const deadline = started + budgetMs;
|
|
411
|
+
const runId = randomBytes(4).toString("hex");
|
|
412
|
+
const ids = (prefix, n) => Array.from({ length: n }, (_, i) => `nf-prof-${runId}-${prefix}${i + 1}`);
|
|
413
|
+
let session = null;
|
|
414
|
+
let identity = opts.identity;
|
|
415
|
+
try {
|
|
416
|
+
const headers = { "user-agent": "nodefony-profile-route" };
|
|
417
|
+
if (opts.account !== null) {
|
|
418
|
+
if (deps.issueTicket === void 0) return mcpText("Profilage authentifié indisponible : aucun émetteur de ticket.", true);
|
|
419
|
+
const opened = await openShotSession(client, deps.issueTicket, opts.account, stop.signal);
|
|
420
|
+
if (!opened.ok) return mcpText(opened.message, true);
|
|
421
|
+
session = opened.session;
|
|
422
|
+
headers.cookie = session.cookie;
|
|
423
|
+
identity = {
|
|
424
|
+
kind: "account",
|
|
425
|
+
identifier: session.identifier,
|
|
426
|
+
roles: session.roles,
|
|
427
|
+
impersonated: opts.caller.subject !== opts.account
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
probe.read();
|
|
431
|
+
const probeStart = performance.now();
|
|
432
|
+
const shared = {
|
|
433
|
+
method: opts.method,
|
|
434
|
+
path: opts.path,
|
|
435
|
+
headers,
|
|
436
|
+
concurrency: opts.concurrency,
|
|
437
|
+
signal: stop.signal,
|
|
438
|
+
deadline
|
|
439
|
+
};
|
|
440
|
+
const warm = await fire(client, ids("w", opts.warmup), {
|
|
441
|
+
...shared,
|
|
442
|
+
status: null
|
|
443
|
+
});
|
|
444
|
+
const status = emptyClientStatus();
|
|
445
|
+
const step = Math.max(1, Math.ceil(opts.count / PROGRESS_STEPS));
|
|
446
|
+
let ticks = 0;
|
|
447
|
+
opts.run?.progress(0, opts.count, `tir de ${opts.count} requête(s) ${opts.method} ${opts.path}`);
|
|
448
|
+
const shot = await fire(client, ids("", opts.count), {
|
|
449
|
+
...shared,
|
|
450
|
+
status,
|
|
451
|
+
onDone: (done) => {
|
|
452
|
+
if (done % step !== 0 && done !== opts.count) return;
|
|
453
|
+
ticks += 1;
|
|
454
|
+
opts.run?.progress(done, opts.count, `${done}/${opts.count} · 2xx ${status.ok} · refus ${status.unauthorized + status.forbidden} · 5xx ${status.serverError}`);
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
const health = probe.read();
|
|
458
|
+
const probedMs = performance.now() - probeStart;
|
|
459
|
+
const waitMs = deps.collectWaitMs ?? 500;
|
|
460
|
+
const warmed = await collectProfiles(profiler, warm.outcomes, waitMs);
|
|
461
|
+
const measured = await collectProfiles(profiler, shot.outcomes, waitMs);
|
|
462
|
+
const warmRoute = aggregateProfiles(warmed.entries, { includeTooling: true }).routes[0];
|
|
463
|
+
const report = aggregateProfiles(measured.entries, { includeTooling: true });
|
|
464
|
+
let errorLogs = null;
|
|
465
|
+
const read = deps.readRequestLogs;
|
|
466
|
+
if (read !== void 0) {
|
|
467
|
+
errorLogs = /* @__PURE__ */ new Map();
|
|
468
|
+
for (const entry of measured.entries) {
|
|
469
|
+
if (errorLogs.size >= 10) break;
|
|
470
|
+
if (!isFailedProfile(entry)) continue;
|
|
471
|
+
errorLogs.set(entry.requestId, await read(entry.requestId, opts.caller).catch(() => ({
|
|
472
|
+
kind: "unavailable",
|
|
473
|
+
reason: "la lecture du journal a échoué"
|
|
474
|
+
})));
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
const elapsedMs = performance.now() - started;
|
|
478
|
+
const text = renderProfileReport(report, {
|
|
479
|
+
windowMs: elapsedMs,
|
|
480
|
+
elapsedMs,
|
|
481
|
+
ticks,
|
|
482
|
+
lost: 0,
|
|
483
|
+
cancelled: stop.signal.aborted && !stop.interrupted,
|
|
484
|
+
interrupted: stop.interrupted,
|
|
485
|
+
errorLogs,
|
|
486
|
+
route: null,
|
|
487
|
+
filteredRoute: 0,
|
|
488
|
+
pid: process.pid,
|
|
489
|
+
uptimeSec: process.uptime(),
|
|
490
|
+
eventLoop: probedMs < EVENT_LOOP_RESOLUTION_MS * EVENT_LOOP_MIN_PERIODS ? null : {
|
|
491
|
+
meanMs: health.eventLoopMs,
|
|
492
|
+
maxMs: health.eventLoopMaxMs
|
|
493
|
+
},
|
|
494
|
+
heapDeltaBytes: null,
|
|
495
|
+
shot: {
|
|
496
|
+
method: opts.method,
|
|
497
|
+
path: opts.path,
|
|
498
|
+
requested: opts.count,
|
|
499
|
+
sent: shot.outcomes.length,
|
|
500
|
+
concurrency: opts.concurrency,
|
|
501
|
+
identity,
|
|
502
|
+
warmup: {
|
|
503
|
+
count: warm.outcomes.length,
|
|
504
|
+
p50Ms: warmRoute?.p50Ms ?? null,
|
|
505
|
+
maxMs: warmRoute?.maxMs ?? null
|
|
506
|
+
},
|
|
507
|
+
client: status,
|
|
508
|
+
missing: measured.missing,
|
|
509
|
+
truncated: shot.truncated || warm.truncated,
|
|
510
|
+
budgetMs
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
return mcpText(opts.notes.length === 0 ? text : `ℹ️ ${opts.notes.join(" · ")}.\n${text}`);
|
|
514
|
+
} finally {
|
|
515
|
+
client.close();
|
|
516
|
+
probe.dispose();
|
|
517
|
+
if (session !== null && deps.closeSession !== void 0) await deps.closeSession(session.ref).catch(() => void 0);
|
|
518
|
+
stop.release();
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
export { PROFILE_COLLECT_WAIT_MS, PROFILE_ROUTE_BUDGET_MS, PROFILE_ROUTE_DEFAULT_CONCURRENCY, PROFILE_ROUTE_DEFAULT_COUNT, PROFILE_ROUTE_DEFAULT_WARMUP, PROFILE_ROUTE_MAX_CONCURRENCY, PROFILE_ROUTE_MAX_COUNT, PROFILE_ROUTE_MAX_WARMUP, checkProfilePath, profileRouteTool };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
// nodefony/mcp/shotClient.ts
|
|
3
|
+
/** Délai d'une requête du tir, en ms : au-delà, elle compte « sans réponse ». */
|
|
4
|
+
const SHOT_REQUEST_TIMEOUT_MS = 15e3;
|
|
5
|
+
/**
|
|
6
|
+
* Client `node:http` d'un tir : connexions gardées ouvertes entre deux requêtes,
|
|
7
|
+
* comme un navigateur, au plus `concurrency` à la fois, toutes fermées par
|
|
8
|
+
* `close()`. Un `fetch` passerait par un pool global, qui survivrait à l'appel.
|
|
9
|
+
*
|
|
10
|
+
* @param target - l'adresse d'écoute de l'application.
|
|
11
|
+
* @param concurrency - connexions simultanées au plus.
|
|
12
|
+
* @returns le client, à fermer en fin de tir.
|
|
13
|
+
*/
|
|
14
|
+
function createShotClient(target, concurrency) {
|
|
15
|
+
const agent = new http.Agent({
|
|
16
|
+
keepAlive: true,
|
|
17
|
+
maxSockets: concurrency
|
|
18
|
+
});
|
|
19
|
+
return {
|
|
20
|
+
send(request, signal) {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const req = http.request({
|
|
23
|
+
host: target.host,
|
|
24
|
+
port: target.port,
|
|
25
|
+
method: request.method,
|
|
26
|
+
path: request.path,
|
|
27
|
+
agent,
|
|
28
|
+
signal,
|
|
29
|
+
timeout: SHOT_REQUEST_TIMEOUT_MS,
|
|
30
|
+
headers: {
|
|
31
|
+
...request.headers,
|
|
32
|
+
host: target.hostHeader
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
req.once("timeout", () => {
|
|
36
|
+
req.destroy(/* @__PURE__ */ new Error("délai de réponse dépassé"));
|
|
37
|
+
});
|
|
38
|
+
req.once("error", reject);
|
|
39
|
+
req.once("response", (res) => {
|
|
40
|
+
const chunks = request.readBody ? [] : null;
|
|
41
|
+
if (chunks === null) res.resume();
|
|
42
|
+
else res.on("data", (chunk) => chunks.push(chunk));
|
|
43
|
+
res.once("error", reject);
|
|
44
|
+
res.once("end", () => {
|
|
45
|
+
const location = res.headers.location;
|
|
46
|
+
resolve({
|
|
47
|
+
status: res.statusCode ?? 0,
|
|
48
|
+
location: typeof location === "string" ? location : null,
|
|
49
|
+
setCookie: res.headers["set-cookie"] ?? [],
|
|
50
|
+
body: chunks === null ? null : Buffer.concat(chunks).toString("utf8")
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
req.end();
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
close() {
|
|
58
|
+
agent.destroy();
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Lit l'adresse d'écoute du serveur HTTP en clair de l'application (service
|
|
64
|
+
* `server-http`), telle qu'il l'a réellement obtenue — port de repli compris.
|
|
65
|
+
*
|
|
66
|
+
* Une écoute sur toutes les interfaces (`0.0.0.0`, `::`) se joint par la boucle
|
|
67
|
+
* locale : c'est aussi ce qui rend l'appel LOCAL pour la route de connexion du
|
|
68
|
+
* profilage.
|
|
69
|
+
*
|
|
70
|
+
* @param server - ce que le conteneur rend pour `server-http`.
|
|
71
|
+
* @returns la cible, ou `null` si aucun serveur n'est prêt.
|
|
72
|
+
*/
|
|
73
|
+
function readShotTarget(server) {
|
|
74
|
+
if (typeof server !== "object" || server === null || !("ready" in server) || server.ready !== true || !("port" in server) || typeof server.port !== "number" || server.port <= 0) return null;
|
|
75
|
+
const address = "address" in server && typeof server.address === "string" ? server.address : "";
|
|
76
|
+
const host = address === "" || address === "0.0.0.0" ? "127.0.0.1" : address === "::" ? "::1" : address;
|
|
77
|
+
const domain = "domain" in server && typeof server.domain === "string" && server.domain !== "" ? server.domain : host.includes(":") ? `[${host}]` : host;
|
|
78
|
+
return {
|
|
79
|
+
host,
|
|
80
|
+
port: server.port,
|
|
81
|
+
hostHeader: `${domain}:${server.port}`
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
export { SHOT_REQUEST_TIMEOUT_MS, createShotClient, readShotTarget };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { setTimeout } from "node:timers/promises";
|
|
2
|
+
// nodefony/mcp/shutdown.ts
|
|
3
|
+
/**
|
|
4
|
+
* Délai maximal, en ms, pendant lequel l'arrêt du serveur attend que le
|
|
5
|
+
* rapport parte : le superviseur de développement tue le process 4 s après
|
|
6
|
+
* le lui avoir demandé.
|
|
7
|
+
*/
|
|
8
|
+
const SHUTDOWN_REPORT_MAX_MS = 1e3;
|
|
9
|
+
/** Marge, en ms, laissée à la porte pour écrire le rapport une fois rendu. */
|
|
10
|
+
const SHUTDOWN_REPORT_FLUSH_MS = 100;
|
|
11
|
+
/**
|
|
12
|
+
* Arme l'arrêt d'un outil long : l'agent qui abandonne, ou le serveur qui
|
|
13
|
+
* s'arrête (rechargement après une modification de fichier). Dans le second
|
|
14
|
+
* cas, l'arrêt attend — borné — que le rapport partiel soit parti.
|
|
15
|
+
*
|
|
16
|
+
* @param run - l'appel en cours, porteur du signal d'abandon de l'agent.
|
|
17
|
+
* @param onShutdown - abonnement à l'arrêt du serveur ; absent hors application.
|
|
18
|
+
* @returns le signal combiné, la cause, et la libération.
|
|
19
|
+
*/
|
|
20
|
+
function watchToolStop(run, onShutdown) {
|
|
21
|
+
const stop = new AbortController();
|
|
22
|
+
const signal = run === void 0 ? stop.signal : AbortSignal.any([run.signal, stop.signal]);
|
|
23
|
+
const delivered = Promise.withResolvers();
|
|
24
|
+
let interrupted = false;
|
|
25
|
+
const unsubscribe = onShutdown?.(async () => {
|
|
26
|
+
interrupted = true;
|
|
27
|
+
stop.abort();
|
|
28
|
+
await Promise.race([delivered.promise.then(() => setTimeout(SHUTDOWN_REPORT_FLUSH_MS, void 0, { ref: false })), setTimeout(SHUTDOWN_REPORT_MAX_MS, void 0, { ref: false })]);
|
|
29
|
+
}) ?? null;
|
|
30
|
+
let released = false;
|
|
31
|
+
return {
|
|
32
|
+
signal,
|
|
33
|
+
get interrupted() {
|
|
34
|
+
return interrupted;
|
|
35
|
+
},
|
|
36
|
+
release() {
|
|
37
|
+
if (released) return;
|
|
38
|
+
released = true;
|
|
39
|
+
delivered.resolve();
|
|
40
|
+
unsubscribe?.();
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export { watchToolStop };
|