@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.
Files changed (55) hide show
  1. package/README.md +146 -4
  2. package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  3. package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorateParam.js +1 -2
  4. package/dist/index.js +24 -11
  5. package/dist/nodefony/config/config.js +16 -137
  6. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  7. package/dist/nodefony/controllers/DevkitController.js +7 -14
  8. package/dist/nodefony/controllers/McpController.js +25 -226
  9. package/dist/nodefony/controllers/ProfileSessionController.js +47 -0
  10. package/dist/nodefony/mcp/observe.js +235 -0
  11. package/dist/nodefony/mcp/profileReport.js +242 -0
  12. package/dist/nodefony/mcp/profileRoute.js +521 -0
  13. package/dist/nodefony/mcp/shotClient.js +84 -0
  14. package/dist/nodefony/mcp/shutdown.js +44 -0
  15. package/dist/nodefony/mcp/tools.js +696 -0
  16. package/dist/nodefony/service/DevkitService.js +93 -29
  17. package/dist/nodefony/src/errors/DevkitError.js +1 -2
  18. package/dist/nodefony/src/profileSession.js +14 -0
  19. package/dist/types/index.d.ts +15 -1
  20. package/dist/types/nodefony/config/config.d.ts +1 -1
  21. package/dist/types/nodefony/controllers/McpController.d.ts +11 -26
  22. package/dist/types/nodefony/controllers/ProfileSessionController.d.ts +31 -0
  23. package/dist/types/nodefony/interfaces/IDevkitService.d.ts +22 -1
  24. package/dist/types/nodefony/mcp/observe.d.ts +83 -0
  25. package/dist/types/nodefony/mcp/profileReport.d.ts +138 -0
  26. package/dist/types/nodefony/mcp/profileRoute.d.ts +87 -0
  27. package/dist/types/nodefony/mcp/shotClient.d.ts +53 -0
  28. package/dist/types/nodefony/mcp/shutdown.d.ts +25 -0
  29. package/dist/types/nodefony/mcp/tools.d.ts +109 -0
  30. package/dist/types/nodefony/service/DevkitService.d.ts +41 -4
  31. package/dist/types/nodefony/src/profileSession.d.ts +12 -0
  32. package/docs/index.md +160 -11
  33. package/package.json +16 -11
  34. package/skills/nodefony-add-crud/SKILL.md +10 -1
  35. package/skills/nodefony-add-service/SKILL.md +3 -3
  36. package/skills/nodefony-authentication/SKILL.md +5 -5
  37. package/skills/nodefony-browser/SKILL.md +20 -0
  38. package/skills/nodefony-browser/references/socket.md +13 -4
  39. package/skills/nodefony-browser/references/sondes.md +13 -1
  40. package/skills/nodefony-browser/scripts/inspect.mjs +55 -8
  41. package/skills/nodefony-browser/scripts/lib/probes.mjs +53 -0
  42. package/skills/nodefony-browser/scripts/socket.mjs +70 -4
  43. package/skills/nodefony-browser/scripts/watch.mjs +101 -0
  44. package/skills/nodefony-dev/SKILL.md +31 -1
  45. package/skills/nodefony-dev/scripts/docs.mjs +3 -3
  46. package/skills/nodefony-devops/SKILL.md +85 -64
  47. package/skills/nodefony-devops/references/dimensionnement.md +122 -0
  48. package/skills/nodefony-devops/references/frontal.md +277 -47
  49. package/skills/nodefony-devops/references/kubernetes.md +41 -14
  50. package/skills/nodefony-devops/references/secrets.md +13 -2
  51. package/skills/nodefony-devops/references/variables.md +14 -12
  52. package/skills/nodefony-protect-route/SKILL.md +6 -4
  53. package/skills/nodefony-security-review/scripts/review-diff.mjs +4 -4
  54. package/skills/nodefony-troubleshoot/SKILL.md +86 -12
  55. package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
@@ -1,17 +1,10 @@
1
- import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js";
2
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorate.js";
3
- import __decorateParam from "../../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateParam.js";
4
- import { ACCESS_TOKEN_VERIFIER, JsonRpcError, Nodefony, authorizeProtectedResource, checkMcpAccess, collectMcpTools, createMcpResponseStream, handleMcpMessage, jsonRpcFailure, mcpCallerRoles, protectedResourceMetadataUrl, readBearerHeader } from "nodefony";
5
- import { Body, Controller, Headers, controller, route } from "@nodefony/framework";
6
- //#region nodefony/controllers/McpController.ts
1
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.155.0/helpers/esm/decorate.js";
2
+ import __decorateParam from "../../_virtual/_@oxc-project_runtime@0.155.0/helpers/esm/decorateParam.js";
3
+ import { Body, Controller, controller, route } from "@nodefony/framework";
4
+ // nodefony/controllers/McpController.ts
7
5
  /**
8
- * Plafond d'un événement sur le flux de réponse MCP, octets UTF-8 — celui de la
9
- * réponse finale, qui porte le résultat entier de l'outil (16 Mio).
10
- */
11
- const MCP_STREAM_MAX_EVENT_BYTES = 16777216;
12
- /**
13
- * Serveur **Model Context Protocol** de l'application — la porte par laquelle
14
- * un agent externe l'interroge.
6
+ * Porte **Model Context Protocol** de DÉVELOPPEMENT — celle par laquelle
7
+ * l'agent de code du développeur interroge l'application.
15
8
  *
16
9
  * ## Pourquoi une route, et pas un process
17
10
  *
@@ -28,7 +21,7 @@ const MCP_STREAM_MAX_EVENT_BYTES = 16777216;
28
21
  *
29
22
  * **Par défaut, le périmètre — et lui seul.** Ce module est `policy: "dev"`,
30
23
  * donc cette route n'existe pas en production ; restent les deux gardes que la
31
- * spec impose au transport, portées par {@link checkMcpAccess} : l'en-tête
24
+ * spec impose au transport (`checkMcpAccess`, `@nodefony/ai`) : l'en-tête
32
25
  * `Origin` et la localité de l'appelant. Elles visent le seul vecteur réel
33
26
  * contre un serveur local — une page web ouverte dans le navigateur du
34
27
  * développeur.
@@ -55,12 +48,10 @@ const MCP_STREAM_MAX_EVENT_BYTES = 16777216;
55
48
  * Rôle déclaré + aucun vérificateur = `503` et journal `CRITIC`, jamais une
56
49
  * porte qui laisse passer les porteurs sans les lire.
57
50
  *
58
- * **Mince par design** : tout le protocole vit AU CŒUR (`nodefony`, en fonctions
59
- * pures) ; ce controller ne fait que traduire HTTP ↔ JSON-RPC et fournir au
60
- * collecteur ce que lui seul connaît — le service du module, le broker
61
- * d'administration, la racine du projet. C'est ce qui permet à un autre module
62
- * d'ouvrir la même porte ailleurs (en production, sous authentification) sans
63
- * réécrire une ligne de protocole.
51
+ * **Mince par design** : la porte est définie par `defineMcpGate` (`@nodefony/ai`)
52
+ * dans `DevkitService.mcpGate()`, à partir de `devkit.mcp.*` ; ce controller
53
+ * ne fait que la servir. Une application ouvre SA porte, en développement ou en
54
+ * production, exactement de la même façon.
64
55
  */
65
56
  let McpController = class McpController extends Controller {
66
57
  constructor(context) {
@@ -73,216 +64,24 @@ let McpController = class McpController extends Controller {
73
64
  return svc;
74
65
  }
75
66
  /**
76
- * Ce qui, dans cette application, sait valider un jeton — ou rien.
77
- *
78
- * Résolu par NOM dans le conteneur, et non importé : ce module est
79
- * `policy: "dev"` et ne peut pas dépendre de `@nodefony/security`, qui porte
80
- * la cryptographie. Une application peut donc fournir sa propre
81
- * implémentation du contrat sous ce nom, sans que cette porte en sache quoi
82
- * que ce soit.
83
- *
84
- * L'absence est un cas NORMAL (rôle éteint) ou une faute de configuration
85
- * (rôle allumé) — c'est l'appelant qui tranche, pas cette méthode.
86
- */
87
- #tokenVerifier() {
88
- return this.get(ACCESS_TOKEN_VERIFIER) ?? void 0;
89
- }
90
- /**
91
- * L'en-tête porte-t-il un gabarit d'interpolation jamais substitué ?
92
- *
93
- * Un `.mcp.json` déclare son jeton par `${NF_MCP_TOKEN}` : le client
94
- * développe la variable, ou transmet la chaîne telle quelle si elle manque.
95
- * Le second cas ne se distingue d'un jeton invalide par AUCUN symptôme
96
- * observable de l'extérieur, et c'est pourtant le plus fréquent des deux —
97
- * une session lancée depuis un terminal où la variable n'était pas posée.
98
- *
99
- * @param header - la valeur brute de l'en-tête `Authorization`
100
- * @returns vrai si ce qui est présenté est resté un gabarit
101
- */
102
- #isUnsubstitutedTemplate(header) {
103
- const read = readBearerHeader(header);
104
- return read.kind === "token" && read.token.startsWith("${") && read.token.endsWith("}");
105
- }
106
- /**
107
- * Le corps reçu est-il un texte qui ne se PARSE pas en JSON ?
108
- *
109
- * Le parseur HTTP ignore un JSON malformé et laisse `@Body()` à `{}` : seul
110
- * le brut (`request.data`) permet alors de distinguer un `{}` envoyé d'un
111
- * texte illisible. Relu uniquement quand le corps n'a aucune clé.
112
- *
113
- * @param body - corps tel que `@Body()` le rend
114
- * @returns `true` si le texte reçu n'est pas du JSON
115
- */
116
- #unparsableBody(body) {
117
- if (body === null || typeof body !== "object" || Array.isArray(body) || Object.keys(body).length > 0) return false;
118
- const request = this.request;
119
- if (!request || !("data" in request)) return false;
120
- const text = request.data.toString(request.charset).trim();
121
- if (text === "") return false;
122
- try {
123
- JSON.parse(text);
124
- return false;
125
- } catch {
126
- return true;
127
- }
128
- }
129
- /**
130
- * `POST /nodefony/mcp` — un message JSON-RPC entre, une réponse sort.
131
- *
132
- * Les trois statuts que rend cette route sont ceux que la spec impose, et pas
133
- * un de plus : `202` sans corps pour une notification acceptée, `403` pour un
134
- * appel dont l'origine ou l'adresse est refusée, `404` pour une méthode
135
- * inconnue (afin de la distinguer d'un serveur qui n'hébergerait pas
136
- * d'endpoint MCP du tout).
137
- */
138
- async mcp(body, origin, protocolVersion, authorization) {
139
- const settings = this.#service().mcpSettings();
140
- if (!settings.enabled) return this.renderJson({ error: "MCP désactivé" }, 404);
141
- const verdict = checkMcpAccess({
142
- origin,
143
- remoteAddress: this.context?.remoteAddress ?? void 0
144
- }, {
145
- allowedOrigins: settings.allowedOrigins,
146
- allowRemote: settings.allowRemote
147
- });
148
- if (!verdict.allowed) {
149
- this.log(`MCP refusé — ${verdict.why}`, "WARNING");
150
- return this.renderJson(jsonRpcFailure(null, JsonRpcError.INVALID_REQUEST, "origine refusée"), 403);
151
- }
152
- if (this.#unparsableBody(body)) return this.renderJson(jsonRpcFailure(null, JsonRpcError.PARSE_ERROR, "corps illisible : JSON invalide"), 400);
153
- if (body === null || typeof body !== "object") return this.renderJson(jsonRpcFailure(null, JsonRpcError.INVALID_REQUEST, "corps attendu : un message JSON-RPC (objet)"), 400);
154
- const authz = settings.authorization;
155
- const gateProtected = settings.authorization.authorizationServers.length > 0;
156
- let caller = {
157
- authenticated: false,
158
- scopes: [],
159
- roles: mcpCallerRoles({
160
- protected: gateProtected,
161
- authenticated: false,
162
- scopes: []
163
- })
164
- };
165
- if (gateProtected) {
166
- const authVerdict = await authorizeProtectedResource(authorization, {
167
- resource: authz.resource,
168
- acceptedResources: authz.additionalResources,
169
- metadataUrl: protectedResourceMetadataUrl(authz.resource),
170
- scopes: this.#service().declaredMcpScopes(),
171
- allowAnonymous: authz.anonymous
172
- }, this.#tokenVerifier());
173
- switch (authVerdict.outcome) {
174
- case "unverifiable":
175
- this.log(authVerdict.why ? `MCP — la vérification du jeton a ÉCHOUÉ, le jeton n'est donc ni accepté ni refusé : la porte refuse de servir (503). Cause — ${authVerdict.why}` : "MCP — `mcp.authorization` déclare un serveur d'autorisation, mais aucun service du conteneur ne sait vérifier un jeton (`accessTokenVerifier`). La porte refuse de servir : accepter les porteurs sans les valider serait pire que rester anonyme.", "CRITIC");
176
- return this.renderJson(jsonRpcFailure(null, JsonRpcError.INTERNAL_ERROR, "autorisation indisponible"), 503);
177
- case "challenge":
178
- if (this.#isUnsubstitutedTemplate(authorization)) this.log("MCP — l'en-tête `Authorization` porte un GABARIT non substitué : la variable d'environnement du jeton n'est pas posée dans le process du client. Émettre le jeton (`nodefony security:token`) et le poser, ou rendre la porte anonyme pour ce client (`nodefony ai:mcp --no-auth`) — sans en-tête, les outils publics sont servis.", "WARNING");
179
- return this.renderJson(jsonRpcFailure(null, JsonRpcError.INVALID_REQUEST, "autorisation requise"), authVerdict.status, { "WWW-Authenticate": authVerdict.wwwAuthenticate });
180
- case "authenticated":
181
- caller = {
182
- authenticated: true,
183
- scopes: authVerdict.principal.scopes,
184
- subject: authVerdict.principal.subject,
185
- roles: mcpCallerRoles({
186
- protected: true,
187
- authenticated: true,
188
- scopes: authVerdict.principal.scopes
189
- })
190
- };
191
- break;
192
- case "anonymous":
193
- if (authVerdict.rejected) this.log("MCP — jeton REJETÉ (expiré, mauvaise audience ou signature invalide) : la requête est servie en ANONYME, les outils réservés restent retenus. Renouveler le jeton (`nodefony security:token`) pour les retrouver.", "WARNING");
194
- caller = {
195
- authenticated: false,
196
- scopes: [],
197
- roles: mcpCallerRoles({
198
- protected: true,
199
- authenticated: false,
200
- scopes: []
201
- })
202
- };
203
- }
204
- }
205
- const kernel = Nodefony.getKernel();
206
- let withheldCount = 0;
207
- const tools = collectMcpTools({
208
- builtins: settings.tools,
209
- modules: kernel?.modules,
210
- caller,
211
- onSkip: (why) => this.log(`MCP — ${why}`, "WARNING"),
212
- onWithheld: (name, why) => {
213
- withheldCount += 1;
214
- this.log(`MCP — outil « ${name} » retenu : ${why}`, "DEBUG");
215
- },
216
- deps: this.#service().mcpToolDeps()
217
- });
218
- const context = this.context;
219
- const stream = createMcpResponseStream({
220
- signal: context?.signal,
221
- open: this.acceptsSse() ? () => this.renderSse({ maxEventBytes: MCP_STREAM_MAX_EVENT_BYTES }) : void 0,
222
- onError: (error, final) => this.log(`MCP — ${final ? "réponse finale" : "notification"} perdue sur le flux : ${error instanceof Error ? error.message : String(error)}`, final ? "WARNING" : "DEBUG")
223
- });
224
- const reply = await handleMcpMessage(body, {
225
- tools,
226
- caller,
227
- withheldCount,
228
- transport: stream.transport,
229
- serverInfo: {
230
- name: kernel?.projectName ?? "nodefony",
231
- version: Nodefony.version
232
- }
233
- }, { protocolVersion });
234
- if (reply.failure) {
235
- const { tool, error } = reply.failure;
236
- this.log(`MCP — l'outil « ${tool} » a levé : ${error instanceof Error ? error.stack ?? error.message : String(error)}`, "ERROR");
237
- }
238
- const sse = await stream.finish(reply.body);
239
- if (sse !== null) return sse.close();
240
- if (reply.body === null) return this.renderResponse("", void 0, reply.status);
241
- return this.renderJson(reply.body, reply.status);
242
- }
243
- /**
244
- * `GET` et `DELETE /nodefony/mcp` — refusés en `405`, avec `Allow: POST`.
245
- *
246
- * Un client d'une révision antérieure du transport ouvre un flux SSE en `GET`
247
- * et clôt sa session en `DELETE` ; la révision `2026-07-28` a retiré les deux
248
- * et demande ce `405` (transports, « Earlier Streamable HTTP Revisions »).
249
- * Sans cette route, la requête tombait dans le repli SPA du Studio
67
+ * `/nodefony/mcp` — `POST` sert un message JSON-RPC ; `GET` et `DELETE`
68
+ * rendent `405` + `Allow: POST` (verbes retirés par la révision
69
+ * `2026-07-28`). Sans eux, la requête tombait dans le repli SPA du Studio
250
70
  * (`/nodefony/{page}`) : `200` + HTML, que le client prenait pour un flux
251
71
  * rompu et rouvrait à chaque seconde, indéfiniment.
252
72
  */
253
- unsupported() {
254
- if (!this.#service().mcpSettings().enabled) return this.renderJson({ error: "MCP désactivé" }, 404);
255
- return this.renderResponse("", void 0, 405, { allow: "POST" });
73
+ mcp(body) {
74
+ return this.#service().mcpGate().serve(this, body);
256
75
  }
257
76
  };
258
- __decorate([
259
- route("devkit-mcp", {
260
- path: "/mcp",
261
- requirements: { methods: ["POST"] }
262
- }),
263
- __decorateParam(0, Body()),
264
- __decorateParam(1, Headers("origin")),
265
- __decorateParam(2, Headers("mcp-protocol-version")),
266
- __decorateParam(3, Headers("authorization")),
267
- __decorateMetadata("design:type", Function),
268
- __decorateMetadata("design:paramtypes", [
269
- Object,
270
- String,
271
- String,
272
- String
273
- ]),
274
- __decorateMetadata("design:returntype", Promise)
275
- ], McpController.prototype, "mcp", null);
276
- __decorate([
277
- route("devkit-mcp-unsupported", {
278
- path: "/mcp",
279
- requirements: { methods: ["GET", "DELETE"] }
280
- }),
281
- __decorateMetadata("design:type", Function),
282
- __decorateMetadata("design:paramtypes", []),
283
- __decorateMetadata("design:returntype", void 0)
284
- ], McpController.prototype, "unsupported", null);
285
- McpController = __decorate([controller("/nodefony"), __decorateMetadata("design:paramtypes", [Object])], McpController);
77
+ __decorate([route("devkit-mcp", {
78
+ path: "/mcp",
79
+ requirements: { methods: [
80
+ "POST",
81
+ "GET",
82
+ "DELETE"
83
+ ] }
84
+ }), __decorateParam(0, Body())], McpController.prototype, "mcp", null);
85
+ McpController = __decorate([controller("/nodefony")], McpController);
286
86
  var McpController_default = McpController;
287
- //#endregion
288
87
  export { McpController_default as default };
@@ -0,0 +1,47 @@
1
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.155.0/helpers/esm/decorate.js";
2
+ import { Controller, controller, route } from "@nodefony/framework";
3
+ // nodefony/controllers/ProfileSessionController.ts
4
+ /**
5
+ * Ouvre la session au nom de laquelle `nodefony_profile_route` tire ses
6
+ * requêtes.
7
+ *
8
+ * **Mince par design** : la mécanique (ticket à usage unique, localité,
9
+ * ouverture par le flux de session de l'application, verdict uniforme) est la
10
+ * porte `defineTicketSessionGate` de `@nodefony/ai`, définie dans
11
+ * `DevkitService.profileSessionGate()`. Ce controller ne fait que DÉCLARER la
12
+ * route, comme le ferait une application pour ses propres outils.
13
+ *
14
+ * Ce qui protège cette route : le ticket (émis dans le process, valable dix
15
+ * secondes, jamais affiché ni journalisé, identité fixée à l'émission), la
16
+ * localité de l'appelant (défaut de la porte), et la `policy: "dev"` du module.
17
+ *
18
+ * Elle vit sous `/nodefony/devkit/`, hors du motif de la zone d'administration
19
+ * (`^/nodefony/[^/]+/api`), qui exigerait une session déjà ouverte.
20
+ */
21
+ let ProfileSessionController = class ProfileSessionController extends Controller {
22
+ constructor(context) {
23
+ super("devkit-profile-session", context);
24
+ }
25
+ /** Résout le service du module depuis le conteneur partagé. */
26
+ #service() {
27
+ const svc = this.get("devkit");
28
+ if (!svc) throw new Error("DevkitService non enregistré");
29
+ return svc;
30
+ }
31
+ /**
32
+ * `POST /nodefony/devkit/profile-session` — rachète le ticket et ouvre la
33
+ * session. `200` rend le compte et le pseudonyme de session ; tout refus est
34
+ * un `403` au message unique ; `503` quand l'application n'a pas de flux de
35
+ * session.
36
+ */
37
+ async profileSession() {
38
+ return this.#service().profileSessionGate().serve(this);
39
+ }
40
+ };
41
+ __decorate([route("devkit-profile-session", {
42
+ path: "/profile-session",
43
+ requirements: { methods: ["POST"] }
44
+ })], ProfileSessionController.prototype, "profileSession", null);
45
+ ProfileSessionController = __decorate([controller("/nodefony/devkit")], ProfileSessionController);
46
+ var ProfileSessionController_default = ProfileSessionController;
47
+ export { ProfileSessionController_default as default };
@@ -0,0 +1,235 @@
1
+ import { progressLine, renderProfileReport } from "./profileReport.js";
2
+ import { watchToolStop } from "./shutdown.js";
3
+ import { ProcessProbe } from "nodefony";
4
+ import { MCP_RESERVED_PREFIX, mcpText } from "@nodefony/ai";
5
+ import { setTimeout } from "node:timers/promises";
6
+ import { N_PLUS_ONE_MIN_REPEATS, createProfileAggregator, isFailedProfile } from "@nodefony/http";
7
+ // nodefony/mcp/observe.ts
8
+ /** Fenêtre d'observation par défaut, en secondes. */
9
+ const OBSERVE_DEFAULT_SEC = 10;
10
+ /**
11
+ * Fenêtre maximale, en secondes. Sous les 60 s qu'un client MCP attend par
12
+ * défaut avant d'abandonner une requête : au-delà, l'agent couperait l'appel
13
+ * juste avant le rapport.
14
+ */
15
+ const OBSERVE_MAX_SEC = 50;
16
+ /** Intervalle entre deux relevés, en ms : un instantané de progression par relevé. */
17
+ const OBSERVE_TICK_MS = 2e3;
18
+ /**
19
+ * Requêtes en erreur dont on lit le journal, au plus — les mêmes que les
20
+ * exemples d'erreurs du rapport, dans le même ordre.
21
+ */
22
+ const OBSERVE_ERROR_LOGS_MAX = 10;
23
+ /**
24
+ * Le contrat de l'outil tel que l'agent le lit dans `tools/list` — le seul
25
+ * texte qu'il voit à coup sûr. Les seuils cités sont les constantes du code :
26
+ * la description ne peut pas promettre autre chose que ce que l'outil fait.
27
+ * Composée une fois, au chargement du module.
28
+ */
29
+ const OBSERVE_DESCRIPTION = `Mesure l'application Nodefony qui tourne, pendant une fenêtre de quelques secondes, à partir de son profileur de développement : où part le temps de chaque route, les requêtes SQL, les erreurs et la charge du process. C'est la seule façon de SAVOIR qu'une page est lente ou interroge la base dans une boucle — ni le code ni les tests ne le montrent.
30
+
31
+ QUAND : après avoir modifié un contrôleur, un service ou une requête ; face à « c'est lent », « trop de requêtes SQL », « une erreur 500 de temps en temps ».
32
+
33
+ COMMENT :
34
+ 1. Appeler avec durationSec (défaut 10, max 50).\n2. PENDANT la fenêtre, solliciter le code : ouvrir la page, jouer les tests, envoyer des requêtes. L'outil n'en génère AUCUNE : sans trafic, il n'a rien à juger.
35
+ 3. Corriger la cause nommée, puis rappeler l'outil sur le même trafic et comparer le bloc « Données (JSON) » des deux rapports.
36
+ Ne modifier AUCUN fichier pendant la fenêtre : le serveur redémarre et le rapport rendu est partiel (il le dit).
37
+
38
+ LE RAPPORT, dans l'ordre : un verdict (ok, anomalies, INCOMPLÈTE avec la raison) ; les anomalies, nommées par route et contrôleur — « N+1 » (même requête SQL répétée au moins ${N_PLUS_ONE_MIN_REPEATS} fois dans une requête HTTP : charger d'un coup, \`relations\` ou \`\$in\`), « Erreurs » (5xx avec leur requestId et leurs lignes de journal WARNING/ERROR), « Lente » (p95 ≥ 500 ms, avec ce qui coûte par requête : action.self = le code de l'action et de ses services, SQL (cumul), render, firewall), « Boucle d'événements bloquée » (un traitement synchrone de 100 ms ou plus) ; puis un tableau par route, les requêtes SQL les plus coûteuses (valeurs masquées par ?), et les données JSON. Les refus 401/403 sont comptés à part, hors des durées.
39
+
40
+ LIMITES : développement seulement. Le journal des erreurs exige un appel authentifié (scope admin:read) ; sans jeton le rapport le dit. Les routes /nodefony/… et les assets relayés /_vite/… (outillage) sont écartés sauf includeTooling. Un instantané est envoyé en progression à chaque relevé (toutes les ${OBSERVE_TICK_MS / 1e3} s).`;
41
+ /**
42
+ * Ce que rend un outil de MESURE dans un process qui ne sert aucune requête —
43
+ * l'application démarrée en console, sous la porte stdio (`nodefony ai:stdio`).
44
+ * Sans ce refus, `nodefony_observe` y attendrait un trafic qui n'arrive jamais
45
+ * et rendrait « aucune requête » avec aplomb.
46
+ */
47
+ const NOT_SERVING_MESSAGE = "Ce process ne sert aucune requête : l'application y est démarrée en console (porte stdio `nodefony ai:stdio`), sans serveur HTTP — rien à mesurer ici. Pour observer ou profiler le trafic réel : démarrer l'application (`nodefony dev`) et passer par sa porte HTTP (`/nodefony/mcp`, déclarée à l'agent par `nodefony ai:mcp`).";
48
+ /**
49
+ * Reconnaît un profileur par sa FORME : le conteneur le rend sous le nom
50
+ * `"profiler"`, et ce module ne dépend pas de sa classe.
51
+ *
52
+ * @param value - ce que le conteneur a rendu.
53
+ * @returns le lecteur, ou `undefined` s'il ne tient pas le contrat.
54
+ */
55
+ function asProfilerReader(value) {
56
+ return isProfilerReader(value) ? value : void 0;
57
+ }
58
+ function isProfilerReader(value) {
59
+ return typeof value === "object" && value !== null && "cursor" in value && typeof value.cursor === "number" && "since" in value && typeof value.since === "function" && "get" in value && typeof value.get === "function";
60
+ }
61
+ /** Durée demandée, bornée — et la remarque à faire si elle a été corrigée. */
62
+ function readDuration(raw) {
63
+ if (raw === void 0) return {
64
+ sec: 10,
65
+ note: null
66
+ };
67
+ if (typeof raw !== "number" || !Number.isFinite(raw)) return {
68
+ sec: 10,
69
+ note: `durationSec illisible, fenêtre par défaut de 10 s`
70
+ };
71
+ const sec = Math.min(Math.max(Math.round(raw), 1), 50);
72
+ return {
73
+ sec,
74
+ note: sec === raw ? null : `durationSec ramenée à ${sec} s (bornes 1–50)`
75
+ };
76
+ }
77
+ /**
78
+ * L'outil `nodefony_observe` : observe l'application qui tourne pendant une
79
+ * fenêtre bornée et rend ce que le profileur de développement a mesuré —
80
+ * routes lentes et où part leur temps, N+1, requêtes SQL coûteuses, erreurs,
81
+ * charge du process.
82
+ *
83
+ * C'est un outil LONG : un relevé toutes les deux secondes, chacun envoyé à
84
+ * l'agent en progression ; un client qui l'annule l'arrête au relevé suivant,
85
+ * qu'il ferme le flux de la réponse ou envoie `notifications/cancelled`. Rien
86
+ * n'est armé en dehors d'un appel : un minuteur par relevé,
87
+ * libéré par l'annulation elle-même, et la sonde du process libérée en sortie.
88
+ *
89
+ * @param deps - le profileur et, pour les tests, la cadence et la sonde.
90
+ */
91
+ function observeTool(deps) {
92
+ const tickMs = deps.tickMs ?? 2e3;
93
+ const createProbe = deps.createProbe ?? (() => new ProcessProbe());
94
+ return {
95
+ name: `${MCP_RESERVED_PREFIX}observe`,
96
+ annotations: {
97
+ readOnlyHint: true,
98
+ openWorldHint: false
99
+ },
100
+ description: OBSERVE_DESCRIPTION,
101
+ inputSchema: {
102
+ type: "object",
103
+ properties: {
104
+ durationSec: {
105
+ type: "integer",
106
+ minimum: 1,
107
+ maximum: 50,
108
+ description: `Durée de la fenêtre, en secondes (défaut 10, max 50) : assez pour solliciter le code pendant qu'elle court`
109
+ },
110
+ route: {
111
+ type: "string",
112
+ description: "Restreindre à UNE route, par son NOM — celui que rend `nodefony_inspect` sujet `routes` (ex. `blog-index`, `OrdersController::list`), jamais son chemin"
113
+ },
114
+ includeTooling: {
115
+ type: "boolean",
116
+ description: "Inclure les requêtes de l'outillage (`/nodefony/…` : console, API d'administration, porte MCP ; `/_vite/…` : assets relayés). Défaut : non"
117
+ }
118
+ }
119
+ },
120
+ handler: async (args, caller, run) => {
121
+ if (deps.servesTraffic?.() === false) return mcpText(NOT_SERVING_MESSAGE, true);
122
+ const profiler = deps.getProfiler();
123
+ 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é. Rien à observer.", true);
124
+ const { sec, note } = readDuration(args.durationSec);
125
+ const route = typeof args.route === "string" && args.route.length > 0 ? args.route : null;
126
+ return observe(profiler, {
127
+ windowMs: sec * 1e3,
128
+ route,
129
+ includeTooling: args.includeTooling === true,
130
+ tickMs,
131
+ createProbe,
132
+ run,
133
+ note,
134
+ caller,
135
+ readRequestLogs: deps.readRequestLogs,
136
+ onShutdown: deps.onShutdown
137
+ });
138
+ }
139
+ };
140
+ }
141
+ async function observe(profiler, opts) {
142
+ const aggregator = createProfileAggregator({ includeTooling: opts.includeTooling });
143
+ const read = opts.readRequestLogs;
144
+ const logs = /* @__PURE__ */ new Map();
145
+ const stop = watchToolStop(opts.run, opts.onShutdown);
146
+ const signal = stop.signal;
147
+ const windowSec = opts.windowMs / 1e3;
148
+ let cursor = profiler.cursor;
149
+ let lost = 0;
150
+ let filteredRoute = 0;
151
+ let ticks = 0;
152
+ let cancelled = false;
153
+ let loopSum = 0;
154
+ let loopMax = 0;
155
+ let loopReads = 0;
156
+ const drain = () => {
157
+ const batch = profiler.since(cursor);
158
+ cursor = batch.cursor;
159
+ lost += batch.lost;
160
+ for (const entry of batch.entries) {
161
+ if (opts.route !== null && entry.route !== opts.route) {
162
+ filteredRoute += 1;
163
+ continue;
164
+ }
165
+ if (aggregator.add(entry) && read !== void 0 && logs.size < 10 && isFailedProfile(entry)) logs.set(entry.requestId, read(entry.requestId, opts.caller).catch(() => ({
166
+ kind: "unavailable",
167
+ reason: "la lecture du journal a échoué"
168
+ })));
169
+ }
170
+ };
171
+ const probe = opts.createProbe();
172
+ const started = performance.now();
173
+ const deadline = started + opts.windowMs;
174
+ let heapStart = 0;
175
+ let heapEnd = null;
176
+ try {
177
+ heapStart = probe.read().heapUsed;
178
+ opts.run?.progress(0, windowSec, `observation démarrée pour ${windowSec} s` + (opts.route === null ? "" : ` sur la route ${opts.route}`));
179
+ for (;;) {
180
+ const remaining = deadline - performance.now();
181
+ if (remaining <= 0) break;
182
+ if (signal.aborted) {
183
+ cancelled = !stop.interrupted;
184
+ break;
185
+ }
186
+ try {
187
+ await setTimeout(Math.min(opts.tickMs, remaining), void 0, { signal });
188
+ } catch {
189
+ cancelled = !stop.interrupted;
190
+ break;
191
+ }
192
+ drain();
193
+ ticks += 1;
194
+ const health = probe.read();
195
+ heapEnd = health.heapUsed;
196
+ loopSum += health.eventLoopMs;
197
+ loopReads += 1;
198
+ if (health.eventLoopMaxMs > loopMax) loopMax = health.eventLoopMaxMs;
199
+ const elapsedSec = (performance.now() - started) / 1e3;
200
+ opts.run?.progress(Math.min(elapsedSec, windowSec), windowSec, progressLine(aggregator.result(), elapsedSec, windowSec, lost));
201
+ }
202
+ drain();
203
+ } catch (error) {
204
+ stop.release();
205
+ throw error;
206
+ } finally {
207
+ probe.dispose();
208
+ }
209
+ let errorLogs = null;
210
+ if (read !== void 0) {
211
+ errorLogs = /* @__PURE__ */ new Map();
212
+ for (const [requestId, pending] of logs) errorLogs.set(requestId, await pending);
213
+ }
214
+ const text = renderProfileReport(aggregator.result(), {
215
+ windowMs: opts.windowMs,
216
+ elapsedMs: performance.now() - started,
217
+ ticks,
218
+ lost,
219
+ cancelled,
220
+ interrupted: stop.interrupted,
221
+ errorLogs,
222
+ route: opts.route,
223
+ filteredRoute,
224
+ pid: process.pid,
225
+ uptimeSec: process.uptime(),
226
+ eventLoop: loopReads === 0 ? null : {
227
+ meanMs: loopSum / loopReads,
228
+ maxMs: loopMax
229
+ },
230
+ heapDeltaBytes: heapEnd === null ? null : heapEnd - heapStart
231
+ });
232
+ stop.release();
233
+ return mcpText(opts.note === null ? text : `ℹ️ ${opts.note}.\n${text}`);
234
+ }
235
+ export { NOT_SERVING_MESSAGE, OBSERVE_DEFAULT_SEC, OBSERVE_ERROR_LOGS_MAX, OBSERVE_MAX_SEC, OBSERVE_TICK_MS, asProfilerReader, observeTool };