@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
|
@@ -1,17 +1,10 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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
|
|
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** :
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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 };
|