@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.
Files changed (36) hide show
  1. package/README.md +5 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  4. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateParam.js +1 -1
  5. package/dist/index.js +2 -2
  6. package/dist/nodefony/controllers/DevkitController.js +2 -2
  7. package/dist/nodefony/controllers/McpController.js +71 -6
  8. package/dist/nodefony/service/DevkitService.js +2 -2
  9. package/dist/nodefony/src/card.js +1 -2
  10. package/dist/types/nodefony/controllers/McpController.d.ts +12 -1
  11. package/docs/index.md +62 -14
  12. package/package.json +9 -9
  13. package/skills/nodefony-add-realtime-channel/SKILL.md +66 -9
  14. package/skills/nodefony-add-service/SKILL.md +11 -0
  15. package/skills/nodefony-authentication/SKILL.md +234 -0
  16. package/skills/nodefony-browser/SKILL.md +45 -21
  17. package/skills/nodefony-browser/references/sondes.md +13 -0
  18. package/skills/nodefony-browser/scripts/audit.mjs +59 -10
  19. package/skills/nodefony-browser/scripts/inspect.mjs +122 -4
  20. package/skills/nodefony-browser/scripts/lib/browser.mjs +112 -6
  21. package/skills/nodefony-browser/scripts/lib/probes.mjs +166 -7
  22. package/skills/nodefony-dev/SKILL.md +47 -81
  23. package/skills/nodefony-dev/scripts/docs.mjs +21 -5
  24. package/skills/nodefony-devops/SKILL.md +16 -13
  25. package/skills/nodefony-devops/references/compose.md +8 -0
  26. package/skills/nodefony-devops/references/frontal.md +5 -1
  27. package/skills/nodefony-devops/references/secrets.md +18 -4
  28. package/skills/nodefony-devops/references/variables.md +52 -19
  29. package/skills/nodefony-migrate-schema/SKILL.md +5 -2
  30. package/skills/nodefony-migrate-schema/references/verdicts.md +20 -19
  31. package/skills/nodefony-protect-route/SKILL.md +118 -17
  32. package/skills/nodefony-security-review/SKILL.md +127 -0
  33. package/skills/nodefony-security-review/scripts/review-diff.mjs +1090 -0
  34. package/skills/nodefony-troubleshoot/SKILL.md +151 -0
  35. package/skills/nodefony-troubleshoot/scripts/log-errors.mjs +465 -0
  36. 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 · 8 inchangé(s)
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.152.0/helpers/esm/decorate.js
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.152.0/helpers/esm/decorateMetadata.js
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
  }
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.152.0/helpers/esm/decorateParam.js
1
+ //#region \0@oxc-project+runtime@0.153.0/helpers/esm/decorateParam.js
2
2
  function __decorateParam(paramIndex, decorator) {
3
3
  return function(target, key) {
4
4
  decorator(target, key, paramIndex);
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.152.0/helpers/esm/decorateMetadata.js";
5
- import __decorate from "./_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorate.js";
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.152.0/helpers/esm/decorateMetadata.js";
2
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorate.js";
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.152.0/helpers/esm/decorateMetadata.js";
2
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorate.js";
3
- import __decorateParam from "../../_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateParam.js";
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 === null || typeof body !== "object") return this.renderJson(jsonRpcFailure(null, JsonRpcError.PARSE_ERROR, "corps attendu : un message JSON-RPC"), 400);
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
- method: "POST"
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.152.0/helpers/esm/decorateMetadata.js";
4
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorate.js";
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
- import { buildCard, renderCard } from "nodefony";
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<import("@nodefony/http").Http2Response | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
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 huit **skills** au format [Agent Skills](https://agentskills.io) :
288
- un qui porte la conduite commune de toute tâche, et un par geste où l'invention
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-protect-route` | réserver une route à qui est habilité |
297
- | `nodefony-add-realtime-channel` | ouvrir un canal temps réel où le serveur pousse |
298
- | `nodefony-browser` | voir **et mesurer** un écran, sans navigateur sur le poste |
299
- | `nodefony-migrate-schema` | faire évoluer le schéma d'une base et le porter en production |
300
- | `nodefony-devops` | exploiter l'application en conteneur, sans réécrire son contrat |
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.2",
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.2",
29
- "@nodefony/http": "^10.0.0-beta.2",
30
- "nodefony": "^10.0.0-beta.2",
31
- "zod": "^4.6.5",
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.2",
37
- "@nodefony/http": "^10.0.0-beta.2",
38
- "nodefony": "^10.0.0-beta.2"
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.13.0",
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
- Une politique est indexée par le nom **EXACT** du canal. Un canal dont le nom se construit à
57
- l'exécution (`` `room:${id}` ``) **n'est couvert par aucune politique déclarée** — il naît public,
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
- Tant qu'un canal à membres n'existe pas dans le framework, le geste montrable est le canal
66
- **gardé** par rôle ou authentification, pas le canal dynamique.
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 { RealtimeClient } from "nodefony/client";
125
+ import { NodefonySocket } from "nodefony/client";
72
126
 
73
- const client = RealtimeClient.shared();
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 |