@nodefony/devkit 10.0.0-beta.2 → 10.0.0-beta.3
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 +5 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateParam.js +1 -1
- package/dist/index.js +2 -2
- package/dist/nodefony/controllers/DevkitController.js +2 -2
- package/dist/nodefony/controllers/McpController.js +71 -6
- package/dist/nodefony/service/DevkitService.js +2 -2
- package/dist/nodefony/src/card.js +1 -2
- package/dist/types/nodefony/controllers/McpController.d.ts +12 -1
- package/docs/index.md +62 -14
- package/package.json +9 -9
- package/skills/nodefony-add-realtime-channel/SKILL.md +66 -9
- package/skills/nodefony-add-service/SKILL.md +11 -0
- package/skills/nodefony-authentication/SKILL.md +234 -0
- package/skills/nodefony-browser/SKILL.md +45 -21
- package/skills/nodefony-browser/references/sondes.md +13 -0
- package/skills/nodefony-browser/scripts/audit.mjs +59 -10
- package/skills/nodefony-browser/scripts/inspect.mjs +122 -4
- package/skills/nodefony-browser/scripts/lib/browser.mjs +112 -6
- package/skills/nodefony-browser/scripts/lib/probes.mjs +166 -7
- package/skills/nodefony-dev/SKILL.md +47 -81
- package/skills/nodefony-dev/scripts/docs.mjs +21 -5
- package/skills/nodefony-devops/SKILL.md +16 -13
- package/skills/nodefony-devops/references/compose.md +8 -0
- package/skills/nodefony-devops/references/frontal.md +5 -1
- package/skills/nodefony-devops/references/secrets.md +18 -4
- package/skills/nodefony-devops/references/variables.md +52 -19
- package/skills/nodefony-migrate-schema/SKILL.md +5 -2
- package/skills/nodefony-migrate-schema/references/verdicts.md +20 -19
- package/skills/nodefony-protect-route/SKILL.md +118 -17
- package/skills/nodefony-security-review/SKILL.md +127 -0
- package/skills/nodefony-security-review/scripts/review-diff.mjs +1090 -0
- package/skills/nodefony-troubleshoot/SKILL.md +151 -0
- package/skills/nodefony-troubleshoot/scripts/log-errors.mjs +465 -0
- package/skills/nodefony-upgrade/SKILL.md +130 -0
package/README.md
CHANGED
|
@@ -228,13 +228,17 @@ npx nodefony ai:sync # --dry-run pour voir sans écrire, --json pour
|
|
|
228
228
|
= nodefony-add-crud @nodefony/devkit
|
|
229
229
|
= nodefony-add-realtime-channel @nodefony/devkit
|
|
230
230
|
= nodefony-add-service @nodefony/devkit
|
|
231
|
+
= nodefony-authentication @nodefony/devkit
|
|
231
232
|
= nodefony-browser @nodefony/devkit
|
|
232
233
|
= nodefony-dev @nodefony/devkit
|
|
233
234
|
= nodefony-devops @nodefony/devkit
|
|
234
235
|
= nodefony-migrate-schema @nodefony/devkit
|
|
235
236
|
= nodefony-protect-route @nodefony/devkit
|
|
237
|
+
= nodefony-security-review @nodefony/devkit
|
|
238
|
+
= nodefony-troubleshoot @nodefony/devkit
|
|
239
|
+
= nodefony-upgrade @nodefony/devkit
|
|
236
240
|
|
|
237
|
-
0 posé(s) · 0 mis à jour ·
|
|
241
|
+
0 posé(s) · 0 mis à jour · 12 inchangé(s)
|
|
238
242
|
```
|
|
239
243
|
|
|
240
244
|
**Le préfixe `nodefony-` vous laisse la place.** Ces pointeurs arrivent dans
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region \0@oxc-project+runtime@0.
|
|
1
|
+
//#region \0@oxc-project+runtime@0.153.0/helpers/esm/decorate.js
|
|
2
2
|
function __decorate(decorators, target, key, desc) {
|
|
3
3
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
4
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region \0@oxc-project+runtime@0.
|
|
1
|
+
//#region \0@oxc-project+runtime@0.153.0/helpers/esm/decorateMetadata.js
|
|
2
2
|
function __decorateMetadata(k, v) {
|
|
3
3
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
4
4
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import defaults, { devkitConfigSchema } from "./nodefony/config/config.js";
|
|
2
2
|
import { defineDevkitConfig } from "./nodefony/config/defineModuleConfig.js";
|
|
3
3
|
import { buildCard } from "./nodefony/src/card.js";
|
|
4
|
-
import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.
|
|
5
|
-
import __decorate from "./_virtual/_@oxc-project_runtime@0.
|
|
4
|
+
import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js";
|
|
5
|
+
import __decorate from "./_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorate.js";
|
|
6
6
|
import DevkitService_default from "./nodefony/service/DevkitService.js";
|
|
7
7
|
import DevkitController_default from "./nodefony/controllers/DevkitController.js";
|
|
8
8
|
import McpController_default from "./nodefony/controllers/McpController.js";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.
|
|
2
|
-
import __decorate from "../../_virtual/_@oxc-project_runtime@0.
|
|
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
3
|
import { Controller, controller, route } from "@nodefony/framework";
|
|
4
4
|
//#region nodefony/controllers/DevkitController.ts
|
|
5
5
|
/**
|
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.
|
|
2
|
-
import __decorate from "../../_virtual/_@oxc-project_runtime@0.
|
|
3
|
-
import __decorateParam from "../../_virtual/_@oxc-project_runtime@0.
|
|
4
|
-
import { ACCESS_TOKEN_VERIFIER, JsonRpcError, Nodefony, authorizeProtectedResource, checkMcpAccess, collectMcpTools, handleMcpMessage, jsonRpcFailure, mcpCallerRoles, protectedResourceMetadataUrl, readBearerHeader } from "nodefony";
|
|
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
5
|
import { Body, Controller, Headers, controller, route } from "@nodefony/framework";
|
|
6
6
|
//#region nodefony/controllers/McpController.ts
|
|
7
7
|
/**
|
|
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
|
+
/**
|
|
8
13
|
* Serveur **Model Context Protocol** de l'application — la porte par laquelle
|
|
9
14
|
* un agent externe l'interroge.
|
|
10
15
|
*
|
|
@@ -99,6 +104,29 @@ let McpController = class McpController extends Controller {
|
|
|
99
104
|
return read.kind === "token" && read.token.startsWith("${") && read.token.endsWith("}");
|
|
100
105
|
}
|
|
101
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
|
+
/**
|
|
102
130
|
* `POST /nodefony/mcp` — un message JSON-RPC entre, une réponse sort.
|
|
103
131
|
*
|
|
104
132
|
* Les trois statuts que rend cette route sont ceux que la spec impose, et pas
|
|
@@ -121,7 +149,8 @@ let McpController = class McpController extends Controller {
|
|
|
121
149
|
this.log(`MCP refusé — ${verdict.why}`, "WARNING");
|
|
122
150
|
return this.renderJson(jsonRpcFailure(null, JsonRpcError.INVALID_REQUEST, "origine refusée"), 403);
|
|
123
151
|
}
|
|
124
|
-
if (body
|
|
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);
|
|
125
154
|
const authz = settings.authorization;
|
|
126
155
|
const gateProtected = settings.authorization.authorizationServers.length > 0;
|
|
127
156
|
let caller = {
|
|
@@ -186,23 +215,50 @@ let McpController = class McpController extends Controller {
|
|
|
186
215
|
},
|
|
187
216
|
deps: this.#service().mcpToolDeps()
|
|
188
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
|
+
});
|
|
189
224
|
const reply = await handleMcpMessage(body, {
|
|
190
225
|
tools,
|
|
191
226
|
caller,
|
|
192
227
|
withheldCount,
|
|
228
|
+
transport: stream.transport,
|
|
193
229
|
serverInfo: {
|
|
194
230
|
name: kernel?.projectName ?? "nodefony",
|
|
195
231
|
version: Nodefony.version
|
|
196
232
|
}
|
|
197
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();
|
|
198
240
|
if (reply.body === null) return this.renderResponse("", void 0, reply.status);
|
|
199
241
|
return this.renderJson(reply.body, reply.status);
|
|
200
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
|
|
250
|
+
* (`/nodefony/{page}`) : `200` + HTML, que le client prenait pour un flux
|
|
251
|
+
* rompu et rouvrait à chaque seconde, indéfiniment.
|
|
252
|
+
*/
|
|
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" });
|
|
256
|
+
}
|
|
201
257
|
};
|
|
202
258
|
__decorate([
|
|
203
259
|
route("devkit-mcp", {
|
|
204
260
|
path: "/mcp",
|
|
205
|
-
|
|
261
|
+
requirements: { methods: ["POST"] }
|
|
206
262
|
}),
|
|
207
263
|
__decorateParam(0, Body()),
|
|
208
264
|
__decorateParam(1, Headers("origin")),
|
|
@@ -217,6 +273,15 @@ __decorate([
|
|
|
217
273
|
]),
|
|
218
274
|
__decorateMetadata("design:returntype", Promise)
|
|
219
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);
|
|
220
285
|
McpController = __decorate([controller("/nodefony"), __decorateMetadata("design:paramtypes", [Object])], McpController);
|
|
221
286
|
var McpController_default = McpController;
|
|
222
287
|
//#endregion
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import defaults from "../config/config.js";
|
|
2
2
|
import { buildCard } from "../src/card.js";
|
|
3
|
-
import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.
|
|
4
|
-
import __decorate from "../../_virtual/_@oxc-project_runtime@0.
|
|
3
|
+
import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js";
|
|
4
|
+
import __decorate from "../../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorate.js";
|
|
5
5
|
import { Module, Nodefony, Service, extend, injectable, mcpDeclaredScopes } from "nodefony";
|
|
6
6
|
//#region nodefony/service/DevkitService.ts
|
|
7
7
|
/**
|
|
@@ -1,2 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export { buildCard, renderCard };
|
|
1
|
+
export { buildCard, renderCard } from "nodefony";
|
|
@@ -65,6 +65,17 @@ declare class McpController extends Controller {
|
|
|
65
65
|
* inconnue (afin de la distinguer d'un serveur qui n'hébergerait pas
|
|
66
66
|
* d'endpoint MCP du tout).
|
|
67
67
|
*/
|
|
68
|
-
mcp(body: unknown, origin?: string, protocolVersion?: string, authorization?: string): Promise<
|
|
68
|
+
mcp(body: unknown, origin?: string, protocolVersion?: string, authorization?: string): Promise<void | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
|
|
69
|
+
/**
|
|
70
|
+
* `GET` et `DELETE /nodefony/mcp` — refusés en `405`, avec `Allow: POST`.
|
|
71
|
+
*
|
|
72
|
+
* Un client d'une révision antérieure du transport ouvre un flux SSE en `GET`
|
|
73
|
+
* et clôt sa session en `DELETE` ; la révision `2026-07-28` a retiré les deux
|
|
74
|
+
* et demande ce `405` (transports, « Earlier Streamable HTTP Revisions »).
|
|
75
|
+
* Sans cette route, la requête tombait dans le repli SPA du Studio
|
|
76
|
+
* (`/nodefony/{page}`) : `200` + HTML, que le client prenait pour un flux
|
|
77
|
+
* rompu et rouvrait à chaque seconde, indéfiniment.
|
|
78
|
+
*/
|
|
79
|
+
unsupported(): import("nodefony").MaybePromise<import("@nodefony/http").Http2Response | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
|
|
69
80
|
}
|
|
70
81
|
export default McpController;
|
package/docs/index.md
CHANGED
|
@@ -277,6 +277,50 @@ est absent de `tools/list` **et** inappelable en le nommant, et le refus dit
|
|
|
277
277
|
> `policy: "dev"`. Avant d'exposer une donnée par un outil, se demander si elle
|
|
278
278
|
> supporterait d'être lue **sans identification**, par qui a accès à la machine.
|
|
279
279
|
|
|
280
|
+
### Un outil LONG — progression et annulation
|
|
281
|
+
|
|
282
|
+
Le troisième paramètre du handler sert l'outil qui dure : `run.signal` est
|
|
283
|
+
abattu quand l'agent abandonne l'appel, `run.progress()` lui donne signe de vie.
|
|
284
|
+
Un outil court l'ignore ; un appel direct (un test) ne le fournit pas, d'où le
|
|
285
|
+
`run?.` :
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
{
|
|
289
|
+
name: "shop_reindex",
|
|
290
|
+
description: "Réindexe le catalogue, lot par lot. Long : signale chaque lot.",
|
|
291
|
+
inputSchema: { type: "object", properties: {} },
|
|
292
|
+
handler: async (_args, _caller, run) => {
|
|
293
|
+
const lots = await this.lots();
|
|
294
|
+
for (const [i, lot] of lots.entries()) {
|
|
295
|
+
if (run?.signal.aborted) return mcpText("interrompu", true);
|
|
296
|
+
await this.reindex(lot, run?.signal);
|
|
297
|
+
run?.progress(i + 1, lots.length, `lot ${i + 1}/${lots.length}`);
|
|
298
|
+
}
|
|
299
|
+
return mcpText(`${lots.length} lots réindexés`);
|
|
300
|
+
},
|
|
301
|
+
}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Ce que la porte fait du reste :
|
|
305
|
+
|
|
306
|
+
- **le flux ne s'ouvre qu'à la première progression.** Un agent qui envoie un
|
|
307
|
+
`progressToken` et accepte `text/event-stream` reçoit des
|
|
308
|
+
`notifications/progress`, puis la réponse, qui clôt le flux. Sans jeton, ou
|
|
309
|
+
pour un outil qui ne signale rien, la réponse reste du JSON ;
|
|
310
|
+
- **la norme est tenue pour vous** : une valeur qui ne croît pas est ignorée, la
|
|
311
|
+
cadence est bornée (une notification par 100 ms au plus), et plus rien ne
|
|
312
|
+
part une fois l'outil terminé ;
|
|
313
|
+
- **fermer le flux, c'est annuler** (transport HTTP, révision 2026-07-28) :
|
|
314
|
+
`run.signal` est abattu, à l'outil de s'arrêter.
|
|
315
|
+
|
|
316
|
+
Le journal `notifications/message` n'est **pas** proposé : la révision
|
|
317
|
+
2026-07-28 le déprécie (« New implementations SHOULD NOT adopt it »). Ce qu'un
|
|
318
|
+
outil veut dire en route passe par le `message` de la progression ; ce qui doit
|
|
319
|
+
atteindre le modèle va dans la réponse finale. `subscriptions/listen` est refusé
|
|
320
|
+
par une erreur explicite (`-32601`) : le catalogue ne change qu'au rechargement
|
|
321
|
+
de l'application, qui redémarre son process, si bien qu'un flux d'abonnement
|
|
322
|
+
casserait au lieu de notifier. Le `tools/list` suivant rend l'état à jour.
|
|
323
|
+
|
|
280
324
|
## Les skills d'agent — répondre à « comment fait-on ça, ici ? »
|
|
281
325
|
|
|
282
326
|
La carte dit **où aller**. Elle ne dit pas **comment faire**. Or c'est là qu'un
|
|
@@ -284,20 +328,24 @@ agent invente : faute d'une marche à suivre, il écrit un CRUD à la main, un
|
|
|
284
328
|
service à méthodes `static` que le conteneur ne voit pas, un contrôle de droits
|
|
285
329
|
dans le corps de l'action.
|
|
286
330
|
|
|
287
|
-
Le paquet livre donc
|
|
288
|
-
un qui porte la conduite commune de toute tâche,
|
|
289
|
-
coûte cher :
|
|
290
|
-
|
|
291
|
-
| Skill | Le besoin qu'il couvre
|
|
292
|
-
| ------------------------------- |
|
|
293
|
-
| `nodefony-dev` | conduire une tâche de bout en bout, avant toute modification
|
|
294
|
-
| `nodefony-add-crud` | exposer une ressource REST complète, entité comprise
|
|
295
|
-
| `nodefony-add-service` | ajouter de la logique métier réutilisable, vue du conteneur
|
|
296
|
-
| `nodefony-
|
|
297
|
-
| `nodefony-
|
|
298
|
-
| `nodefony-
|
|
299
|
-
| `nodefony-
|
|
300
|
-
| `nodefony-
|
|
331
|
+
Le paquet livre donc onze **skills** au format [Agent Skills](https://agentskills.io) :
|
|
332
|
+
un qui porte la conduite commune de toute tâche, un qui part du symptôme d'une panne,
|
|
333
|
+
et un par geste où l'invention coûte cher :
|
|
334
|
+
|
|
335
|
+
| Skill | Le besoin qu'il couvre |
|
|
336
|
+
| ------------------------------- | ---------------------------------------------------------------- |
|
|
337
|
+
| `nodefony-dev` | conduire une tâche de bout en bout, avant toute modification |
|
|
338
|
+
| `nodefony-add-crud` | exposer une ressource REST complète, entité comprise |
|
|
339
|
+
| `nodefony-add-service` | ajouter de la logique métier réutilisable, vue du conteneur |
|
|
340
|
+
| `nodefony-authentication` | connecter un utilisateur ou un programme, sans rien réécrire |
|
|
341
|
+
| `nodefony-protect-route` | réserver une route à qui est habilité |
|
|
342
|
+
| `nodefony-add-realtime-channel` | ouvrir un canal temps réel où le serveur pousse |
|
|
343
|
+
| `nodefony-browser` | voir **et mesurer** un écran, sans navigateur sur le poste |
|
|
344
|
+
| `nodefony-migrate-schema` | faire évoluer le schéma d'une base et le porter en production |
|
|
345
|
+
| `nodefony-devops` | exploiter l'application en conteneur, sans réécrire son contrat |
|
|
346
|
+
| `nodefony-troubleshoot` | partir d'un symptôme de panne pour trouver le geste qui la règle |
|
|
347
|
+
| `nodefony-security-review` | relire la sécurité d'un diff avant de le commiter |
|
|
348
|
+
| `nodefony-upgrade` | monter le framework de version sans rien laisser derrière |
|
|
301
349
|
|
|
302
350
|
Tous portent le préfixe `nodefony-` : leurs pointeurs arrivent dans le dossier où
|
|
303
351
|
vous écrivez aussi les vôtres, et sans namespace un skill maison du même nom
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nodefony/devkit",
|
|
3
|
-
"version": "10.0.0-beta.
|
|
3
|
+
"version": "10.0.0-beta.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Outillage de développement d'une application Nodefony : carte de visite du projet et portes de découverte pour un agent de développement",
|
|
6
6
|
"author": "Christophe CAMENSULI <ccamensuli@gmail.com>",
|
|
@@ -25,20 +25,20 @@
|
|
|
25
25
|
"coverage": "vitest run --coverage"
|
|
26
26
|
},
|
|
27
27
|
"peerDependencies": {
|
|
28
|
-
"@nodefony/framework": "^10.0.0-beta.
|
|
29
|
-
"@nodefony/http": "^10.0.0-beta.
|
|
30
|
-
"nodefony": "^10.0.0-beta.
|
|
31
|
-
"zod": "^4.6.
|
|
28
|
+
"@nodefony/framework": "^10.0.0-beta.3",
|
|
29
|
+
"@nodefony/http": "^10.0.0-beta.3",
|
|
30
|
+
"nodefony": "^10.0.0-beta.3",
|
|
31
|
+
"zod": "^4.6.7",
|
|
32
32
|
"playwright": "^1.50.0",
|
|
33
33
|
"lighthouse": "^13.0.0"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
|
-
"@nodefony/framework": "^10.0.0-beta.
|
|
37
|
-
"@nodefony/http": "^10.0.0-beta.
|
|
38
|
-
"nodefony": "^10.0.0-beta.
|
|
36
|
+
"@nodefony/framework": "^10.0.0-beta.3",
|
|
37
|
+
"@nodefony/http": "^10.0.0-beta.3",
|
|
38
|
+
"nodefony": "^10.0.0-beta.3"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"axe-core": "4.
|
|
41
|
+
"axe-core": "4.14.0",
|
|
42
42
|
"tslib": "2.8.1"
|
|
43
43
|
},
|
|
44
44
|
"peerDependenciesMeta": {
|
|
@@ -6,9 +6,11 @@ description: >
|
|
|
6
6
|
Porte la façon de fermer un canal à certains rôles, le piège du canal public par défaut, celui
|
|
7
7
|
du canal dont le nom est calculé, et la façade cliente à employer côté navigateur. À charger
|
|
8
8
|
AVANT d'écrire du code WebSocket, un canal, ou un abonnement client.
|
|
9
|
+
Dit aussi quand un flux SSE suffit, et sa façade.
|
|
9
10
|
Déclencheurs : "flux temps réel", "websocket", "canal", "push", "notifications en direct",
|
|
10
11
|
"abonnement", "RealtimeController", "@RealtimeChannel", "socket client", "temps réel privé",
|
|
11
|
-
"réserver un canal à un rôle", "mon canal est public", "diffuser à plusieurs onglets"
|
|
12
|
+
"réserver un canal à un rôle", "mon canal est public", "diffuser à plusieurs onglets",
|
|
13
|
+
"SSE", "server-sent events", "EventSource", "barre de progression", "réponse en streaming".
|
|
12
14
|
---
|
|
13
15
|
|
|
14
16
|
# add-realtime-channel — le temps réel par sa couche
|
|
@@ -37,6 +39,33 @@ câblage. **N'écris pas de `new WebSocket(...)` ni de `ws.on(...)` côté serve
|
|
|
37
39
|
existe, il est employé par le framework, et le reprendre à la main te prive du routage par canal,
|
|
38
40
|
de l'authentification partagée avec HTTP, et de la contre-pression.
|
|
39
41
|
|
|
42
|
+
## WebSocket, ou un simple flux SSE ?
|
|
43
|
+
|
|
44
|
+
| Besoin | Choisir |
|
|
45
|
+
| -------------------------------------------------------------- | ------------------------------------------- |
|
|
46
|
+
| Le serveur pousse, le client écoute (progression, journal) | **SSE** — une réponse HTTP qui ne finit pas |
|
|
47
|
+
| La réponse d'UNE requête arrive par morceaux (modèle, export) | **SSE** sur cette requête |
|
|
48
|
+
| Échange dans les deux sens, canaux, état partagé entre onglets | **WebSocket** — le reste de cette page |
|
|
49
|
+
|
|
50
|
+
SSE n'est pas une route d'un genre particulier : l'action d'une route ordinaire choisit de
|
|
51
|
+
répondre en flux.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
@route("progress-run", { path: "/run" })
|
|
55
|
+
async run() {
|
|
56
|
+
const sse = await this.renderSse(); // en-têtes, battement, contre-pression
|
|
57
|
+
await sse.send({ step: "calcul" }, { event: "step" });
|
|
58
|
+
return sse.close();
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`this.acceptsSse()` dit si le client a demandé `text/event-stream` (une même route peut répondre
|
|
63
|
+
en JSON ou en flux). Côté navigateur, `new NodefonySse(url)` (`nodefony/client`), qui se lit comme
|
|
64
|
+
`EventSource` ; les liaisons de front existent comme pour la socket. **N'écris jamais
|
|
65
|
+
`Content-Type: text/event-stream` à la main** : tu perdrais le battement qui empêche un proxy de
|
|
66
|
+
couper la connexion, et la contre-pression (`await sse.send()` freine si le client lit lentement).
|
|
67
|
+
Détail : `node_modules/@nodefony/http/docs/sse.md`.
|
|
68
|
+
|
|
40
69
|
## Fermer un canal
|
|
41
70
|
|
|
42
71
|
```ts
|
|
@@ -53,27 +82,55 @@ aucun rôle particulier n'est requis.
|
|
|
53
82
|
|
|
54
83
|
## 🔴 Le piège du canal dont le nom est calculé
|
|
55
84
|
|
|
56
|
-
|
|
57
|
-
l'exécution (`` `room:${id}` ``) **n'est couvert par
|
|
58
|
-
et rien ne le signale.
|
|
85
|
+
La politique d'un `@RealtimeChannel` est indexée par le nom **EXACT** du canal. Un canal dont le
|
|
86
|
+
nom se construit à l'exécution (`` `room:${id}` ``) **n'est couvert par aucun décorateur** — il
|
|
87
|
+
naît public, et rien ne le signale.
|
|
59
88
|
|
|
60
89
|
```ts
|
|
61
90
|
@RealtimeChannel("room:lobby", { authenticated: true }) // ✅ couvert
|
|
62
|
-
// `room:42` construit à la volée // 🔴 PAS couvert
|
|
91
|
+
// `room:42` construit à la volée // 🔴 PAS couvert par le décorateur
|
|
63
92
|
```
|
|
64
93
|
|
|
65
|
-
|
|
66
|
-
|
|
94
|
+
La parade est une règle **par préfixe** dans la configuration de sécurité, qui garde à la fois
|
|
95
|
+
les canaux ET les actions dont le nom commence ainsi :
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
use("@nodefony/security", {
|
|
99
|
+
realtimeChannels: [
|
|
100
|
+
{ pattern: "room:", authenticated: true, roles: ["ROLE_USER"] },
|
|
101
|
+
],
|
|
102
|
+
});
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Elle exprime une classe d'identité (connecté, rôles), jamais une liste de membres : « seuls les
|
|
106
|
+
participants de la salle 42 » ne s'écrit pas encore dans le framework. Ne le simule pas avec un
|
|
107
|
+
nom de canal difficile à deviner.
|
|
108
|
+
|
|
109
|
+
## Savoir QUI appelle une action
|
|
110
|
+
|
|
111
|
+
Les paramètres décorés d'une route valent aussi dans une `@RealtimeAction` : `@Body("clé")` lit la
|
|
112
|
+
charge de l'appel, `@CurrentUser()` l'utilisateur de la connexion (`undefined` pour un anonyme).
|
|
113
|
+
L'auteur d'un message se prend là, côté SERVEUR — jamais dans la charge, où le client peut mentir.
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
@RealtimeAction("chat:send")
|
|
117
|
+
send(@Body("text") text: string, @CurrentUser() user: IUser) {
|
|
118
|
+
return { author: user.identifier, text };
|
|
119
|
+
}
|
|
120
|
+
```
|
|
67
121
|
|
|
68
122
|
## Côté client — la façade, pas le socket
|
|
69
123
|
|
|
70
124
|
```ts
|
|
71
|
-
import {
|
|
125
|
+
import { NodefonySocket } from "nodefony/client";
|
|
72
126
|
|
|
73
|
-
const client =
|
|
127
|
+
const client = NodefonySocket.shared({ url: "/api/live/realtime" });
|
|
74
128
|
client.subscribe("ops:alerts", (payload) => { … });
|
|
75
129
|
```
|
|
76
130
|
|
|
131
|
+
L'adresse est **obligatoire** : `shared()` sans `url` lève une erreur, parce que la route dépend de
|
|
132
|
+
l'application. Une application générée monte `/api/live/realtime`.
|
|
133
|
+
|
|
77
134
|
En React, les hooks du paquet client font la même chose avec le cycle de vie du composant.
|
|
78
135
|
**Importe depuis `nodefony/client`**, jamais depuis la racine `nodefony` : côté navigateur, elle
|
|
79
136
|
n'expose pas ces symboles et le compilateur refuse.
|
|
@@ -88,6 +88,17 @@ dépend est refusé au démarrage. Pour **lire** une donnée de la requête depu
|
|
|
88
88
|
besoin de portée : `RequestContext.getScope()` ou `RequestContext.get()` au moment de l'appel.
|
|
89
89
|
Détail : `node_modules/nodefony/docs/service.md`.
|
|
90
90
|
|
|
91
|
+
**Qui est connecté, depuis un service** : `RequestContext.getAuthenticatedUser()` (l'utilisateur
|
|
92
|
+
connecté, ou `null`) et `RequestContext.getUserId()` (`string | null`). Jamais
|
|
93
|
+
`RequestContext.getUser()` pour décider : il rend l'utilisateur BRUT, anonyme compris.
|
|
94
|
+
|
|
95
|
+
**Le payload de la requête est une forme FERMÉE** (`RequestContextPayload`) :
|
|
96
|
+
`RequestContext.set("maCle", …)` sur une clé qu'il ne déclare pas ne compile pas. Ta clé s'ajoute
|
|
97
|
+
par augmentation — `declare module "nodefony" { interface RequestContextPayload { tenant?: string } }`
|
|
98
|
+
— mais `RequestContext.release()`, qui vide le payload à la fin de l'unité de travail, ne la
|
|
99
|
+
vide PAS d'office.
|
|
100
|
+
Détail : `node_modules/nodefony/docs/request-context.md`.
|
|
101
|
+
|
|
91
102
|
## Obtenir un service depuis un autre — deux voies, un choix
|
|
92
103
|
|
|
93
104
|
| Voie | Quand |
|