@nodefony/realtime 10.0.0-beta.2 → 10.0.0-beta.4

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 (63) hide show
  1. package/README.md +2 -2
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  3. package/dist/index.js +18 -13
  4. package/dist/nodefony/config/config.js +7 -5
  5. package/dist/nodefony/config/configSchema.js +18 -0
  6. package/dist/nodefony/config/defineModuleConfig.js +43 -17
  7. package/dist/nodefony/decorators/realtimeDecorators.js +69 -19
  8. package/dist/nodefony/interfaces/IRealtimeConnectionState.js +1 -0
  9. package/dist/nodefony/src/backplane/ClusterBackplane.js +1 -2
  10. package/dist/nodefony/src/backplane/LoopbackBackplane.js +1 -2
  11. package/dist/nodefony/src/backplane/RedisBackplane.js +22 -6
  12. package/dist/nodefony/src/backplane/backplaneRegistry.js +1 -2
  13. package/dist/nodefony/src/backplane/bootNotices.js +42 -0
  14. package/dist/nodefony/src/backplane/envelope.js +1 -2
  15. package/dist/nodefony/src/backplane/originId.js +1 -2
  16. package/dist/nodefony/src/backplane/publishQueue.js +1 -5
  17. package/dist/nodefony/src/cluster/ClusterProbeClient.js +16 -6
  18. package/dist/nodefony/src/guards.js +40 -0
  19. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +2 -6
  20. package/dist/nodefony/src/server/RealtimeAdminApi.js +1 -2
  21. package/dist/nodefony/src/server/RealtimeController.js +226 -82
  22. package/dist/nodefony/src/server/RealtimeHub.js +32 -54
  23. package/dist/nodefony/src/server/ServerRealtimeSocket.js +30 -13
  24. package/dist/nodefony/src/server/deniedDetail.js +1 -2
  25. package/dist/nodefony/src/server/subscribedAck.js +29 -0
  26. package/dist/nodefony/src/server/syslogUplink.js +5 -4
  27. package/dist/nodefony/src/server/welcomeEnv.js +1 -2
  28. package/dist/nodefony/src/service/RealtimeService.js +4 -4
  29. package/dist/nodefony/src/transport/WsConnectionTransport.js +42 -2
  30. package/dist/nodefony/testing/index.js +118 -5
  31. package/dist/types/index.d.ts +1 -0
  32. package/dist/types/nodefony/config/configSchema.d.ts +12 -0
  33. package/dist/types/nodefony/config/defineModuleConfig.d.ts +29 -6
  34. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +19 -2
  35. package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +40 -0
  36. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +1 -1
  37. package/dist/types/nodefony/interfaces/IRealtimeHandshake.d.ts +1 -1
  38. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +13 -0
  39. package/dist/types/nodefony/interfaces/index.d.ts +1 -0
  40. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +7 -0
  41. package/dist/types/nodefony/src/backplane/bootNotices.d.ts +25 -0
  42. package/dist/types/nodefony/src/guards.d.ts +32 -0
  43. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +13 -0
  44. package/dist/types/nodefony/src/server/RealtimeController.d.ts +82 -4
  45. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +22 -16
  46. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +18 -8
  47. package/dist/types/nodefony/src/server/subscribedAck.d.ts +23 -0
  48. package/dist/types/nodefony/src/service/RealtimeService.d.ts +1 -1
  49. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +26 -1
  50. package/dist/types/nodefony/testing/index.d.ts +8 -3
  51. package/docs/actions.md +129 -45
  52. package/docs/architecture.md +143 -135
  53. package/docs/cadence-et-contre-pression.md +643 -0
  54. package/docs/configuration.md +126 -97
  55. package/docs/cookbook-chat.md +50 -43
  56. package/docs/index.md +63 -38
  57. package/docs/observabilite.md +58 -56
  58. package/docs/protocole.md +110 -76
  59. package/docs/securite.md +128 -117
  60. package/docs/vocabulaire.md +133 -129
  61. package/package.json +17 -13
  62. package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
  63. package/docs/couverture-bancs.md +0 -139
package/docs/actions.md CHANGED
@@ -117,7 +117,7 @@ attendre trop longtemps et repartir.
117
117
 
118
118
  Techniquement, ce numéro est le champ `id` de la frame JSON-RPC 2.0. Le pair l'attribue, garde
119
119
  l'appel en attente dans une table, arme une minuterie, et résout la `Promise` quand la réponse
120
- portant cet `id` revient (`JsonRpcPeer.handleResponse()`, `JsonRpcPeer.ts:556`). Une frame sans
120
+ portant cet `id` revient (`JsonRpcPeer.handleResponse()`, `JsonRpcPeer.ts#JsonRpcPeer.handleResponse`). Une frame sans
121
121
  `id` ne crée aucune de ces trois choses — c'est pourquoi une publication ne coûte rien au repos.
122
122
 
123
123
  ## La vision Nodefony — un nom, un handler, une découverte
@@ -126,18 +126,18 @@ Trois partis pris distinguent les actions Nodefony d'une couche RPC classique.
126
126
 
127
127
  **Il n'y a pas de couche RPC séparée.** Une action est une **méthode de contrôleur**, dans la même
128
128
  classe que tes canaux et — si tu le veux — que tes routes HTTP. Pas de service dédié, pas de
129
- schéma à compiler : `@RealtimeAction("orders:quote")` (`realtimeDecorators.ts:101`) suffit, et le
129
+ schéma à compiler : `@RealtimeAction("orders:quote")` (`realtimeDecorators.ts#RealtimeAction`) suffit, et le
130
130
  retour de la méthode devient le `result` de la réponse.
131
131
 
132
132
  **Le contrat du handler est minuscule, volontairement.** Une action reçoit **un seul argument**,
133
133
  les paramètres bruts du client, et rend une valeur — `RpcActionHandler`
134
- (`JsonRpcPeer.ts:118`). Pas de contexte injecté dans la signature : le `this` est lié à
134
+ (`JsonRpcPeer.ts#RpcActionHandler`). Pas de contexte injecté dans la signature : le `this` est lié à
135
135
  l'instance du contrôleur au handshake, ce qui donne accès au noyau, aux services et à la
136
136
  connexion sans élargir le contrat.
137
137
 
138
138
  **L'endpoint s'annonce lui-même.** La liste des actions exposées voyage dans la frame d'accueil —
139
- `IRealtimeWelcome` (`RealtimeController.ts:12`) — et se lit côté client par
140
- `RealtimeClient.serverMethods` (`RealtimeClient.ts:597`). Une interface n'écrit donc jamais un nom d'action en dur : elle
139
+ `IRealtimeWelcome` (`RealtimeController.ts`) — et se lit côté client par
140
+ `NodefonySocket.serverMethods` (`NodefonySocket.ts#NodefonySocket.serverMethods`). Une interface n'écrit donc jamais un nom d'action en dur : elle
141
141
  n'active un bouton que si le serveur a déclaré savoir le servir.
142
142
 
143
143
  **Le compromis, dit franchement** : une action est **un aller-retour**, point. Elle ne diffuse pas,
@@ -199,7 +199,7 @@ export default OrdersRealtimeController;
199
199
 
200
200
  ```ts
201
201
  // frontend/src/orders.ts
202
- import { RealtimeClient } from "nodefony/client";
202
+ import { NodefonySocket } from "nodefony/client";
203
203
 
204
204
  interface IQuote {
205
205
  orderId: string;
@@ -207,7 +207,7 @@ interface IQuote {
207
207
  currency: string;
208
208
  }
209
209
 
210
- const socket = RealtimeClient.shared({
210
+ const socket = NodefonySocket.shared({
211
211
  url: "wss://127.0.0.1:5152/orders/realtime",
212
212
  });
213
213
 
@@ -274,20 +274,20 @@ Le trajet complet d'une requête, des étapes qu'elle traverse aux branches par
274
274
 
275
275
  ### Deux voies, un même registre
276
276
 
277
- | Voie | Quand la choisir |
278
- | ------------------------------------------------------ | ----------------------------------------------------------- |
279
- | `@RealtimeAction("nom")` (`realtimeDecorators.ts:101`) | le cas courant — un nom fixe, une méthode, déclaratif |
280
- | `realtimeActions()` (`RealtimeController.ts:236`) | la table est **calculée** (noms dynamiques, boucle, config) |
277
+ | Voie | Quand la choisir |
278
+ | -------------------------------------------------------------------------------- | ----------------------------------------------------------- |
279
+ | `@RealtimeAction("nom")` (`realtimeDecorators.ts#RealtimeAction`) | le cas courant — un nom fixe, une méthode, déclaratif |
280
+ | `realtimeActions()` (`RealtimeController.ts#RealtimeController.realtimeActions`) | la table est **calculée** (noms dynamiques, boucle, config) |
281
281
 
282
282
  Les deux sont fusionnées au handshake, et **l'override gagne** en cas de conflit de nom : une
283
283
  classe peut ainsi remplacer une action héritée sans toucher au parent
284
- (`RealtimeController.ts:453`). Un endpoint sans aucune action ne paie rien — la table du pair est
284
+ (`RealtimeController.ts#RealtimeController.onHandshake`). Un endpoint sans aucune action ne paie rien — la table du pair est
285
285
  allouée au premier enregistrement seulement.
286
286
 
287
287
  ### Ce que ton `return` et ton `throw` deviennent sur le fil
288
288
 
289
289
  Le retour du handler est envoyé tel quel en `result`. Les erreurs, elles, suivent une règle Zero
290
- Trust stricte, appliquée dans `JsonRpcPeer.handleRequest()` (`JsonRpcPeer.ts:499`) :
290
+ Trust stricte, appliquée dans `JsonRpcPeer.handleRequest()` (`JsonRpcPeer.ts#JsonRpcPeer.handleRequest`) :
291
291
 
292
292
  | Côté serveur | Ce que reçoit le client | Pourquoi |
293
293
  | ------------------------------- | ---------------------------------------- | ----------------------------------------------- |
@@ -299,7 +299,7 @@ Trust stricte, appliquée dans `JsonRpcPeer.handleRequest()` (`JsonRpcPeer.ts:49
299
299
 
300
300
  > [!WARNING]
301
301
  > **Le temps réel n'a qu'UNE erreur, et elle vient du cœur.** Pour choisir ce que voit le client,
302
- > c'est `RpcError` (`JsonRpcPeer.ts:71`), importée depuis `nodefony` — son `code` est un entier
302
+ > c'est `RpcError` (`JsonRpcPeer.ts#RpcError`), importée depuis `nodefony` — son `code` est un entier
303
303
  > JSON-RPC et son `data` traverse le fil. Toute autre exception donne un `-32603` opaque. Ne pas
304
304
  > chercher d'erreur maison dans `@nodefony/realtime` : il n'y en a pas, et
305
305
  > `tests/unit/errorSurface.test.ts` refuse d'en exporter une que personne ne lève.
@@ -313,8 +313,8 @@ Trust stricte, appliquée dans `JsonRpcPeer.handleRequest()` (`JsonRpcPeer.ts:49
313
313
 
314
314
  Cette défense est le **verrou de frame**, posé par `@nodefony/security`. Il examine chaque frame
315
315
  entrante et, pour une action, résout une politique **par son nom** — exactement le mécanisme des
316
- canaux (`buildFrameAuthorizer()`, `frameAuthorizer.ts:388` ; branche des méthodes,
317
- `frameAuthorizer.ts:388`). Trois situations, à connaître dans cet ordre :
316
+ canaux (`buildFrameAuthorizer()`, `frameAuthorizer.ts#buildFrameAuthorizer` ; branche des méthodes,
317
+ `frameAuthorizer.ts#buildFrameAuthorizer`). Trois situations, à connaître dans cet ordre :
318
318
 
319
319
  ### Situation 1 — une action est FERMÉE par défaut
320
320
 
@@ -345,42 +345,76 @@ homonyme), elle est conservée : le défaut ne rétrograde jamais une règle plu
345
345
  ### Situation 2 — les namespaces réservés portent un plancher
346
346
 
347
347
  Un nom qui commence par `nodefony:` hérite d'un plancher non contournable : authentifié **et**
348
- `ROLE_NODEFONY_ADMIN` (`SYSTEM_CHANNEL_POLICY`, `frameAuthorizer.ts:89`) — un rôle de la plateforme,
348
+ `ROLE_NODEFONY_ADMIN` (`SYSTEM_CHANNEL_POLICY`, `frameAuthorizer.ts#SYSTEM_CHANNEL_POLICY`) — un rôle de la plateforme,
349
349
  pas le `ROLE_ADMIN` d'une organisation. Un seul préfixe couvre donc tout
350
- ce que la plateforme expose (`DEFAULT_SYSTEM_PREFIXES`, `frameAuthorizer.ts:106`). C'est ce qui
350
+ ce que la plateforme expose (`DEFAULT_SYSTEM_PREFIXES`, `frameAuthorizer.ts#DEFAULT_SYSTEM_PREFIXES`). C'est ce qui
351
351
  protège `nodefony:kernel:gc` : le nom **est** la garde, et la comparaison est insensible à la casse
352
352
  pour qu'un `NODEFONY:kernel:gc` ne passe pas à côté.
353
353
 
354
354
  Ce plancher **prime sur ta déclaration** — y compris sur `{ authenticated: false }`. Et il ne
355
355
  s'arrête pas aux préfixes : tout nom **contenant** `:health` ou `:stats` est traité comme un canal
356
- d'observabilité (`matchSystemPolicy`, `frameAuthorizer.ts:243`). Une action `public:health` reste
356
+ d'observabilité (`matchSystemPolicy`, `frameAuthorizer.ts#matchSystemPolicy`). Une action `public:health` reste
357
357
  donc réservée, quoi qu'en dise son décorateur. À savoir avant de nommer une action « ouverte ».
358
358
 
359
359
  ### Situation 3 — couvrir SES actions par la configuration
360
360
 
361
361
  Pour exiger un rôle sur tes propres actions, on déclare une règle de préfixe dans la
362
362
  configuration de sécurité — la même liste que pour les canaux
363
- (`realtimeChannels`, `security/nodefony/config/config.ts:1130`) :
363
+ (`realtimeChannels`, `security/nodefony/config/config.ts#securityConfigSchema`) :
364
364
 
365
365
  ```ts ignore
366
366
  use("@nodefony/security", {
367
367
  realtimeChannels: [
368
368
  // Couvre AUSSI bien le canal `orders:feed` que l'action `orders:quote` :
369
369
  // le verrou résout une politique par NOM, sans savoir ce qu'il garde.
370
- {
371
- prefix: "orders:",
372
- policy: { authenticated: true, roles: ["ROLE_USER"] },
373
- },
370
+ { pattern: "orders:", authenticated: true, roles: ["ROLE_USER"] },
374
371
  ],
375
372
  });
376
373
  ```
377
374
 
375
+ ### Savoir QUI appelle — `@CurrentUser` et les paramètres décorés
376
+
377
+ Une action authentifiée sait que l'appelant est identifié ; elle peut aussi savoir **qui** il est.
378
+ Les décorateurs de paramètres d'une route y prennent le même sens
379
+ (`actionParamsWrapper`, `RealtimeController.ts#RealtimeController.actionParamsWrapper`) :
380
+
381
+ ```ts ignore
382
+ import { Body, CurrentUser } from "@nodefony/framework";
383
+ import type { IUser } from "@nodefony/user";
384
+
385
+ // Le client : socket.request("chat:send", { text: "salut" })
386
+ @RealtimeAction("chat:send")
387
+ send(@Body("text") text: string, @CurrentUser() user: IUser) {
388
+ // L'auteur vient du SERVEUR : le client ne peut pas mentir dessus.
389
+ return { author: user.identifier, text };
390
+ }
391
+ ```
392
+
393
+ | Décorateur | Ce qu'il reçoit dans une action |
394
+ | ----------------------------------------- | ----------------------------------------------------------- |
395
+ | `@Body()` / `@Body("clé")` | la charge de l'appel (`params`), ou l'une de ses clés |
396
+ | `@CurrentUser()` | l'utilisateur de la connexion, `undefined` pour un anonyme |
397
+ | `@Query()` / `@Param()` | un objet vide — une action n'a ni URL ni variables de route |
398
+ | `@Headers()` / `@Cookie()` / `@Session()` | ceux de la requête d'ouverture de la socket |
399
+
400
+ Une action **sans** paramètre décoré reçoit la charge brute en premier argument, comme avant —
401
+ rien ne change pour elle, ni comportement ni coût.
402
+
403
+ > [!IMPORTANT]
404
+ > **L'identité est celle de l'ouverture de la socket.** Elle n'est pas relue à chaque appel : le
405
+ > hub la revalide toutes les 30 secondes (`REVOCATION_REVALIDATE_MS`, `RealtimeHub.ts#channelFamily`) et
406
+ > ferme la socket en `4001` si la session est morte (`RealtimeHub.ts#RealtimeHub.probe`). Après une
407
+ > déconnexion, une action peut donc encore voir l'ancien utilisateur pendant cette fenêtre. Pour
408
+ > une écriture sensible — paiement, droits, suppression — passe par une route
409
+ > `POST + WEBSOCKET` appelée par `socket.mutate()` : ce pont revalide l'identité à **chaque**
410
+ > appel et porte la clé d'idempotence.
411
+
378
412
  ### La règle DEV-only
379
413
 
380
414
  Une action réservée au développement se contrôle **dans le handler, côté serveur** — jamais par un
381
415
  drapeau d'interface. Cacher un bouton n'empêche personne de forger la frame. Le modèle est
382
416
  `nodefony:scaffold:run` dans Studio, qui refuse net hors développement avant même de lire ses paramètres
383
- (`StudioRealtimeController.ts:133`).
417
+ (`StudioRealtimeController.ts#StudioRealtimeController.actionScaffoldRun`).
384
418
 
385
419
  > [!CAUTION]
386
420
  > Le défaut fermé protège de l'oubli, pas de la sur-ouverture. Une action mutable reste la surface
@@ -394,21 +428,21 @@ drapeau d'interface. Cacher un bouton n'empêche personne de forger la frame. Le
394
428
  ### Le délai d'expiration est la seule libération automatique
395
429
 
396
430
  `request()` prend le délai en **troisième argument positionnel**, en millisecondes — il n'y a pas
397
- d'objet d'options (`RealtimeClient.request()`, `RealtimeClient.ts:727`) :
431
+ d'objet d'options (`NodefonySocket.request()`, `NodefonySocket.ts#NodefonySocket.request`) :
398
432
 
399
433
  ```ts ignore
400
434
  await socket.request("orders:quote", { orderId }); // 30 000 ms par défaut
401
435
  await socket.request("orders:export", { scope }, 120_000); // action longue
402
436
  ```
403
437
 
404
- | Défaut | Valeur | Ancrage |
405
- | --------- | --------- | -------------------- |
406
- | `request` | 30 000 ms | `JsonRpcPeer.ts:319` |
438
+ | Défaut | Valeur | Ancrage |
439
+ | --------- | --------- | ------------------------------------ |
440
+ | `request` | 30 000 ms | `JsonRpcPeer.ts#JsonRpcPeer.request` |
407
441
 
408
442
  À l'expiration, l'entrée en attente est **retirée** et la `Promise` rejetée avec
409
- `RPC timeout: <méthode>` (`JsonRpcPeer.ts:455`). Conséquence à connaître : une réponse qui
443
+ `RPC timeout: <méthode>` (`JsonRpcPeer.ts#JsonRpcPeer.startCall`). Conséquence à connaître : une réponse qui
410
444
  arriverait après coup ne trouve plus personne et est ignorée en silence
411
- (`JsonRpcPeer.ts:539`) — pas de résolution tardive, pas de fuite.
445
+ (`JsonRpcPeer.ts#JsonRpcPeer.handleResponse`) — pas de résolution tardive, pas de fuite.
412
446
 
413
447
  ### Abandonner : ce qui existe vraiment
414
448
 
@@ -422,11 +456,11 @@ Trois leviers existent, et ils ne font pas la même chose :
422
456
 
423
457
  1. **Le délai d'expiration** — libère le client, laisse le serveur travailler.
424
458
  2. **La fermeture de la connexion** — `dispose()` rejette **tous** les appels en attente d'un
425
- coup (`JsonRpcPeer.ts:453`), appelé au nettoyage de la socket
426
- (`RealtimeController.ts:553`). Là encore : côté client seulement.
459
+ coup (`JsonRpcPeer.ts#JsonRpcPeer.dispose`), appelé au nettoyage de la socket
460
+ (`RealtimeController.ts#RealtimeController.onHandshake`). Là encore : côté client seulement.
427
461
  3. **Une action compagnon** — la seule vraie annulation. On expose une seconde action qui prend
428
462
  l'identifiant du travail et l'interrompt côté serveur. Le modèle du dépôt est
429
- `nodefony:scaffold:cancel` (`StudioRealtimeController.ts:159`), pendant de `nodefony:scaffold:run`.
463
+ `nodefony:scaffold:cancel` (`StudioRealtimeController.ts#StudioRealtimeController.actionScaffoldRun`), pendant de `nodefony:scaffold:run`.
430
464
 
431
465
  ### Rejouer : idempotence par action
432
466
 
@@ -444,8 +478,8 @@ fois ? »**.
444
478
  | une mutation (`socket.mutate`) | **non** par nature | oui, **avec une clé d'idempotence** |
445
479
 
446
480
  Pour les mutations passant par le pont API, la clé n'est pas une convention : elle est **exigée
447
- par la signature** de `mutate()` (`RealtimeClient.ts:797`), et c'est la garde `@Idempotent`
448
- (`routerDecorators.ts:1171`) qui, côté serveur, reconnaît le rejeu et rend la réponse déjà calculée
481
+ par la signature** de `mutate()` (`NodefonySocket.ts#NodefonySocket.mutate`), et c'est la garde `@Idempotent`
482
+ (`routerDecorators.ts#Idempotent`) qui, côté serveur, reconnaît le rejeu et rend la réponse déjà calculée
449
483
  au lieu de refaire l'effet.
450
484
 
451
485
  ```ts ignore
@@ -471,11 +505,11 @@ contraire des résultats **au fil de l'eau**, pendant des minutes.
471
505
  Le remède tient en trois gestes, et il est déjà en production dans Studio :
472
506
 
473
507
  1. **L'action lance et rend un identifiant**, sans attendre — `nodefony:scaffold:run`
474
- (`StudioRealtimeController.ts:133`) démarre le travail et rend son état, dont son `id`.
508
+ (`StudioRealtimeController.ts#StudioRealtimeController.actionScaffoldRun`) démarre le travail et rend son état, dont son `id`.
475
509
  2. **Le flux se diffuse sur un canal dédié**, nommé d'après ce travail — `nodefony:scaffold:job@<id>`,
476
- servi par le contrôleur (`StudioRealtimeController.ts:190`).
510
+ servi par le contrôleur (`StudioRealtimeController.ts#StudioRealtimeController.actionScaffoldCancel`).
477
511
  3. **Le client s'abonne à ce canal** et regarde le travail se faire, ligne après ligne
478
- (`Create.tsx:323`).
512
+ (`Create.tsx#Create`).
479
513
 
480
514
  ### La course, et comment le dépôt la neutralise
481
515
 
@@ -485,7 +519,7 @@ prudente est donc de **s'abonner avant** de lancer l'appel.
485
519
 
486
520
  Nodefony a choisi la garantie côté serveur plutôt que la discipline côté client : le producteur du
487
521
  canal **rejoue son historique** au nouvel abonné, de sorte qu'un arrivant tardif voit tout depuis
488
- le début (`ScaffoldService.subscribe()`, `ScaffoldService.ts:456`). C'est ce qui autorise le front
522
+ le début (`ScaffoldService.subscribe()`, `ScaffoldService.ts#ScaffoldService.subscribe`). C'est ce qui autorise le front
489
523
  à faire l'appel d'abord et à s'abonner ensuite, sans rien perdre.
490
524
 
491
525
  > [!TIP]
@@ -495,7 +529,7 @@ le début (`ScaffoldService.subscribe()`, `ScaffoldService.ts:456`). C'est ce qu
495
529
 
496
530
  ### Et le streaming du protocole ?
497
531
 
498
- **Il n'y en a pas.** Une action rend **une** valeur (`RpcActionHandler`, `JsonRpcPeer.ts:118`), que
532
+ **Il n'y en a pas.** Une action rend **une** valeur (`RpcActionHandler`, `JsonRpcPeer.ts#RpcActionHandler`), que
499
533
  le pair emballe en une réponse unique. Pour tout ce qui progresse — une réponse mot à mot d'un
500
534
  modèle de langage, un export qui avance, un traitement long — la voie est le motif
501
535
  **« travail + canal »** : l'action accuse réception, et la progression arrive sur un canal.
@@ -510,15 +544,15 @@ il sera conçu avec son premier consommateur réel.
510
544
  Un cas particulier mérite d'être connu avant d'écrire une action : **elle existe peut-être déjà en
511
545
  HTTP**. Le pont API expose la méthode `api.request`, qui rejoue une route de contrôleur sur la
512
546
  socket, avec la même garde et le même résultat qu'en REST — `invokeApiRequest()`
513
- (`RealtimeController.ts:949`). Il est **désactivé par défaut** et s'active en surchargeant
514
- `realtimeApiRequest()` (`RealtimeController.ts:276`).
547
+ (`RealtimeController.ts#RealtimeController.invokeApiRequest`). Il est **désactivé par défaut** et s'active en surchargeant
548
+ `realtimeApiRequest()` (`RealtimeController.ts#RealtimeController.realtimeApiRequest`).
515
549
 
516
550
  ```ts ignore
517
551
  const modules = await socket.request("/nodefony/kernel/api/modules");
518
552
  ```
519
553
 
520
554
  La forme se discrimine toute seule : un chemin commence par `/`, jamais un nom d'action —
521
- `RealtimeClient.request()` (`RealtimeClient.ts:727`). Écris une action RPC pour ce qui n'a de sens **que** sur la socket ;
555
+ `NodefonySocket.request()` (`NodefonySocket.ts#NodefonySocket.request`). Écris une action RPC pour ce qui n'a de sens **que** sur la socket ;
522
556
  passe par le pont pour tout ce qui est déjà une route. Le détail du pont vit dans le
523
557
  [vocabulaire](./vocabulaire.md) et l'[architecture](./architecture.md).
524
558
 
@@ -530,6 +564,7 @@ passe par le pont pour tout ce qui est déjà une route. Le détail du pont vit
530
564
  | Le client attend indéfiniment, aucune erreur | le handler ne rend jamais (il publie au lieu de retourner) — pas de frame `result` | toujours `return` une valeur ; publier **en plus**, jamais **à la place** |
531
565
  | `-32601 method not found` | nom mal orthographié, ou action déclarée sur un **autre** endpoint | vérifier `socket.serverMethods` — c'est la liste réelle de CETTE connexion |
532
566
  | `-32603 internal error` sans détail | un `throw` ordinaire est rendu opaque au client (Zero Trust) | lever une `RpcError` avec un code et un message publiables |
567
+ | `api.request` rejeté avec `data.status: 422` | la route invoquée valide son entrée (`schema`, ou une `ZodError` de l'action) | lire `data.fields` : chaque champ fautif, sa partie (`in`) et son message |
533
568
  | `-32001 unauthorized` sur une action légitime | le nom commence par `nodefony:`, le namespace réservé à la plateforme | renommer hors de `nodefony:`, ou obtenir `ROLE_NODEFONY_ADMIN` |
534
569
  | Une action est refusée à un anonyme alors qu'on la croyait publique | une action est fermée par défaut (`{ authenticated: true }`) | déclarer `{ authenticated: false }` pour l'ouvrir ; un rôle se pose par une règle de préfixe |
535
570
  | Un travail relancé crée deux jobs | action non idempotente rejouée après une reconnexion | action compagnon d'annulation, ou identifiant fourni par l'appelant + mémorisation |
@@ -548,7 +583,7 @@ vivent dans la carte de l'aperçu, régénérée depuis les résultats réels, j
548
583
  - **Unitaires, déclaration** (`realtimeDecorators.test.ts`) : `@RealtimeAction` enregistre bien le
549
584
  nom et lie le `this` ; (`RealtimeController.test.ts`) : fusion décorateurs + override et
550
585
  annonce dans l'accueil.
551
- - **Unitaires, client** (`RealtimeClientCoverage.test.ts`) : formes de `request`, contrat de
586
+ - **Unitaires, client** (`NodefonySocketCoverage.test.ts`) : formes de `request`, contrat de
552
587
  streaming, rejet des appels en attente au `dispose`.
553
588
  - **Attaque, autorisation** (`realtimeFrameLock.test.ts`) : `nodefony:kernel:ping` et `nodefony:kernel:gc` refusés à
554
589
  l'anonyme **et** à l'utilisateur authentifié, acceptés à l'administrateur — la preuve que le
@@ -559,6 +594,55 @@ vivent dans la carte de l'aperçu, régénérée depuis les résultats réels, j
559
594
  fan-out, pas celui des appels corrélés). Pour en monter un, voir le skill
560
595
  `nodefony-load-test` ; pour la mémoire, `nodefony-check-memory-health`.
561
596
 
597
+ ### Éprouver SON action — le harnais, identité comprise
598
+
599
+ Une application teste ses actions sans serveur ni navigateur avec `createRealtimeHarness`
600
+ (`@nodefony/realtime/testing`, `nodefony/testing/index.ts#createRealtimeHarness`) : il monte le contrôleur sur une
601
+ fausse connexion, envoie les frames qu'un client enverrait et rend celles qui sortent. Sans option,
602
+ la connexion est **anonyme** — le repli Zero Trust. Une action qui lit `@CurrentUser()` s'éprouve
603
+ donc avec l'identité qu'aurait posée le pare-feu au handshake, passée en `identity`
604
+ (`nodefony/testing/index.ts#IRealtimeHarnessOptions.identity`) :
605
+
606
+ ```ts ignore
607
+ import { describe, it, expect, afterEach } from "vitest";
608
+ import { getRealtimeHub } from "@nodefony/realtime";
609
+ import type { IRealtimeToken } from "@nodefony/realtime";
610
+ import { createRealtimeHarness } from "@nodefony/realtime/testing";
611
+ import ChatController from "../nodefony/controllers/ChatController";
612
+
613
+ describe("ChatController — actions", () => {
614
+ afterEach(() => getRealtimeHub().clear());
615
+
616
+ it("l'action reçoit son appelant par @CurrentUser", async () => {
617
+ const alice = { identifier: "alice" };
618
+ const attributes = new Map<string, unknown>([["user", alice]]);
619
+ const identity: IRealtimeToken = {
620
+ type: "session",
621
+ getUserIdentifier: () => alice.identifier,
622
+ isAuthenticated: () => true,
623
+ getRoles: () => ["ROLE_USER"],
624
+ getScopes: () => [],
625
+ getAttribute: <T>(key: string) => attributes.get(key) as T | undefined,
626
+ };
627
+ const h = createRealtimeHarness((ctx) => new ChatController(ctx), {
628
+ identity,
629
+ });
630
+ await h.connect();
631
+ const me = await h.call<{ identifier: string }>("chat:whoami");
632
+ expect(me.identifier).toBe("alice");
633
+ h.dispose();
634
+ });
635
+ });
636
+ ```
637
+
638
+ C'est l'attribut `user` du jeton que `@CurrentUser()` rend. Pour éprouver un **refus** (`@IsGranted`,
639
+ `policy` de canal), il faut en plus le verrou de frame de `@nodefony/security`, passé en
640
+ `frameAuthorizer` : `@nodefony/realtime` ne connaît pas la sécurité, et sans ce verrou une règle
641
+ déclarée n'est appliquée par personne — en test comme en production. La table des membres du
642
+ harnais et l'exemple de refus sont dans le `README.md` du paquet, section « Tester un controller ».
643
+ `nodefony create controller --kind realtime` génère ce test, identité comprise, quand l'application
644
+ porte la sécurité.
645
+
562
646
  Couverture : `npm run coverage` dans `@nodefony/realtime`.
563
647
 
564
648
  ## 🔗 Pour aller plus loin