@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/protocole.md CHANGED
@@ -31,7 +31,7 @@ source: "src/packages/@nodefony/realtime/docs/protocole.md"
31
31
  > **frames** dont la forme est normée. Trois formes existent, et une seule règle les distingue —
32
32
  > **la nature d'une frame se lit sur `method`, jamais sur `id`**. Cette page donne la grammaire
33
33
  > champ par champ, les méthodes que le serveur comprend nativement, et ce qui revient exactement
34
- > quand ça rate. Le moteur est `JsonRpcPeer` (`JsonRpcPeer.ts:271`), écrit une fois et branché des
34
+ > quand ça rate. Le moteur est `JsonRpcPeer` (`JsonRpcPeer.ts#JsonRpcPeer`), écrit une fois et branché des
35
35
  > deux côtés du fil.
36
36
 
37
37
  📍 [Documentation](../../../../../docs/index.md) › [Realtime](index.md) › **Protocole**
@@ -52,12 +52,12 @@ cœur du protocole : il décide s'il faut répondre, à qui, et si une réponse
52
52
 
53
53
  ```mermaid
54
54
  flowchart TD
55
- IN["Frame entrante (JSON déjà parsé)"] --> V{"jsonrpc = 2.0 ?"}
55
+ IN["Frame entrante (JSON déjà parsé)"] --> V{"frame JSON-RPC 2.0 valide ?"}
56
56
  V -->|non| INV["invalid — auditée, AUCUNE réponse"]
57
57
  V -->|oui| M{"method présent ?"}
58
58
  M -->|"method + id"| REQ["requête — un handler doit rendre result ou error"]
59
59
  M -->|"method seul"| NOT["notification — pub/sub, aucune réponse"]
60
- M -->|"id seul"| RES["réponse — résout une requête sortante en attente"]
60
+ M -->|"id + result ou error"| RES["réponse — résout une requête sortante en attente"]
61
61
  REQ --> OUT["result · error"]
62
62
  NOT --> HUB["subscribe · unsubscribe · canal entrant"]
63
63
  RES --> PROM["la Promise de l'appelant"]
@@ -79,7 +79,7 @@ Deux conséquences que le reste de la page décline :
79
79
  | Pair (peer) | Le moteur de protocole d'une connexion — `JsonRpcPeer`, identique client et serveur. |
80
80
  | Requête | Frame avec `method` **et** `id` — appelle exactement une réponse. |
81
81
  | Notification | Frame avec `method` seul — aucune réponse, jamais. |
82
- | Réponse | Frame avec `id` seul, portant `result` **ou** `error`. |
82
+ | Réponse | Frame avec `id` sans `method`, portant `result` **ou** `error` bien formé. |
83
83
  | Dispatch | Classer une frame entrante puis la router vers son handler. |
84
84
  | Enveloppe | Réponse qui joint des métadonnées serveur dans un champ **frère** du `result`. |
85
85
  | Accueil, welcome | La 1ʳᵉ notification poussée par le serveur : protocole, canaux, actions, identité. |
@@ -106,7 +106,7 @@ Nodefony ne l'a pas inventée. Le calcul, mis à plat :
106
106
 
107
107
  > [!NOTE]
108
108
  > **Le batch est une propriété de la norme, pas du moteur Nodefony.** `JsonRpcPeer.receive()`
109
- > (`JsonRpcPeer.ts:390`) attend un **objet** portant `jsonrpc: "2.0"` ; un tableau de frames n'en
109
+ > (`JsonRpcPeer.ts#JsonRpcPeer.receive`) attend un **objet** portant `jsonrpc: "2.0"` ; un tableau de frames n'en
110
110
  > porte pas et tombe donc en `invalid`. Une frame = un objet. Le multiplexage rend le batch peu
111
111
  > utile ici : les canaux voyagent déjà dans la même connexion.
112
112
 
@@ -118,21 +118,25 @@ d'une action. La norme fournit la grammaire ; le vocabulaire est nodefonien.
118
118
 
119
119
  Trois partis pris distinguent cette implémentation d'un simple « serveur WebSocket qui parle JSON ».
120
120
 
121
- **Le même fichier tourne dans le navigateur et dans Node.** `JsonRpcPeer` (`JsonRpcPeer.ts:271`)
121
+ **Le même fichier tourne dans le navigateur et dans Node.** `JsonRpcPeer` (`JsonRpcPeer.ts#JsonRpcPeer`)
122
122
  n'a aucune dépendance Node — seulement `setTimeout`. Classer, router, corréler les `id` : ce travail
123
123
  est identique des deux côtés, il est donc écrit **une seule fois**. Chaque côté l'entoure de son
124
124
  transport et de ses handlers. Historiquement, cette discrimination vivait à deux endroits qui
125
- divergeaient ; c'est précisément la classe de bug que l'isomorphisme supprime.
125
+ divergeaient ; c'est précisément la classe de bug que l'isomorphisme supprime. Le classement
126
+ lui-même est `classifyJsonRpcFrame` (`jsonrpc/index.ts#classifyJsonRpcFrame`), la brique que partage aussi le
127
+ serveur MCP du framework : les deux portes ne peuvent plus diverger sur ce qu'est une frame valide.
126
128
 
127
129
  **Le rôle se lit sur `method`, pas sur `id`.** La lecture naïve (« `id` présent = requête ») casse
128
130
  sur les réponses, qui portent un `id` sans être des appels. `JsonRpcPeer.receive()`
129
- (`JsonRpcPeer.ts:390`) teste donc `method` d'abord, puis la présence d'un `id`. Une frame `id` seul
130
- est une **réponse** et ne déclenche jamais `-32601` à tort.
131
+ (`JsonRpcPeer.ts#JsonRpcPeer.receive`) teste donc `method` d'abord, puis la présence d'un `id` — chaîne ou nombre :
132
+ un `id` `null`, objet ou booléen rend la frame `invalid`, il n'en fait pas une notification. Une
133
+ frame `id` sans `method`, portant `result` ou une `error` bien formée, est une **réponse** et ne
134
+ déclenche jamais `-32601` à tort ; sans l'un ni l'autre, elle est `invalid`.
131
135
 
132
136
  **Un échec ne raconte rien par défaut.** Un handler qui lève une exception ordinaire produit un
133
- `-32603 "internal error"` **générique** (`JsonRpcPeer.ts:528`) : ni message d'origine, ni pile
137
+ `-32603 "internal error"` **générique** (`JsonRpcPeer.ts#JsonRpcPeer.handleRequest`) : ni message d'origine, ni pile
134
138
  d'appels. Pour exposer volontairement un refus au pair — un 404, un droit manquant — il existe une
135
- porte explicite, `RpcError` (`JsonRpcPeer.ts:70`), et elle seule.
139
+ porte explicite, `RpcError` (`JsonRpcPeer.ts#RpcError`), et elle seule.
136
140
 
137
141
  ## 🚀 Démarrage rapide
138
142
 
@@ -202,14 +206,14 @@ export default ChatController;
202
206
 
203
207
  ```ts
204
208
  // frontend/src/chat.ts
205
- import { RealtimeClient, RpcError } from "nodefony/client";
209
+ import { NodefonySocket, RpcError } from "nodefony/client";
206
210
 
207
211
  interface ChatHistory {
208
212
  messages: string[];
209
213
  }
210
214
 
211
215
  export async function joinChat(): Promise<void> {
212
- const socket = new RealtimeClient({
216
+ const socket = new NodefonySocket({
213
217
  url: "wss://127.0.0.1:5152/chat/realtime",
214
218
  });
215
219
  await socket.connect();
@@ -265,10 +269,10 @@ Sur le fil, dans l'inspecteur réseau du navigateur, exactement cinq frames :
265
269
  ```
266
270
 
267
271
  L'accueil porte cinq champs — `ts`, `protocol`, `channels`, `methods`, `identity`
268
- (`IRealtimeWelcome`, `RealtimeEventMap.ts:231`) — plus un sixième, `env`, **uniquement hors
269
- production** (`welcomeEnv()`, `welcomeEnv.ts:19`) : c'est lui que porte l'exemple ci-dessus, capturé
272
+ (`IRealtimeWelcome`, `RealtimeEventMap.ts#IRealtimeWelcome`) — plus un sixième, `env`, **uniquement hors
273
+ production** (`welcomeEnv()`, `welcomeEnv.ts#welcomeEnv`) : c'est lui que porte l'exemple ci-dessus, capturé
270
274
  en développement. Émis par le contrôleur au handshake (`peer.notify("realtime:welcome")`,
271
- `RealtimeController.ts:708`). Il n'y a pas de champ `version`.
275
+ `RealtimeController.ts#RealtimeController.onHandshake`). Il n'y a pas de champ `version`.
272
276
 
273
277
  ## 🔌 Anatomie d'une frame, champ par champ
274
278
 
@@ -283,17 +287,17 @@ en développement. Émis par le contrôleur au handshake (`peer.notify("realtime
283
287
  }
284
288
  ```
285
289
 
286
- | Champ | Obligatoire | Type accepté | Rôle exact |
287
- | --------- | ------------------------- | -------------------- | ------------------------------------------------------------------------- |
288
- | `jsonrpc` | oui, toujours | `"2.0"` littéral | Sans lui, `JsonRpcPeer.receive()` classe `invalid` (`JsonRpcPeer.ts:371`) |
289
- | `method` | sur un appel | chaîne | Décide de la nature ET de l'aiguillage (canal, action, verbe pub/sub) |
290
- | `params` | non | tout JSON | Charge applicative. Vient du réseau : **jamais** digne de confiance |
291
- | `id` | sur une requête / réponse | nombre **ou** chaîne | Corrèle l'aller et le retour |
290
+ | Champ | Obligatoire | Type accepté | Rôle exact |
291
+ | --------- | ------------------------- | -------------------- | ----------------------------------------------------------------------------------------- |
292
+ | `jsonrpc` | oui, toujours | `"2.0"` littéral | Sans lui, `JsonRpcPeer.receive()` classe `invalid` (`JsonRpcPeer.ts#JsonRpcPeer.receive`) |
293
+ | `method` | sur un appel | chaîne | Décide de la nature ET de l'aiguillage (canal, action, verbe pub/sub) |
294
+ | `params` | non | tout JSON | Charge applicative. Vient du réseau : **jamais** digne de confiance |
295
+ | `id` | sur une requête / réponse | nombre **ou** chaîne | Corrèle l'aller et le retour |
292
296
 
293
297
  > [!IMPORTANT]
294
298
  > Le pair accepte un `id` **chaîne** en entrée (conforme à la norme), mais il n'attribue jamais que
295
299
  > des `id` **numériques** à ses propres appels sortants. Une réponse portant un `id` chaîne est donc
296
- > ignorée sans erreur (`JsonRpcPeer.handleResponse()`, `JsonRpcPeer.ts:556`) : elle ne peut, par
300
+ > ignorée sans erreur (`JsonRpcPeer.handleResponse()`, `JsonRpcPeer.ts#JsonRpcPeer.handleResponse`) : elle ne peut, par
297
301
  > construction, correspondre à aucune requête émise par ce pair.
298
302
 
299
303
  ### La réponse — `result` ou `error`, jamais les deux
@@ -312,9 +316,9 @@ distinguer un 404 d'un refus d'autorisation sans analyser un message de texte.
312
316
 
313
317
  ### L'enveloppe — joindre une méta sans polluer le `result`
314
318
 
315
- Un handler peut rendre une `RpcEnvelope` (`JsonRpcPeer.ts:104`) au lieu d'une valeur nue. Le pair la
319
+ Un handler peut rendre une `RpcEnvelope` (`JsonRpcPeer.ts#RpcEnvelope`) au lieu d'une valeur nue. Le pair la
316
320
  déballe : le `result` reste **exactement** la valeur, et la méta voyage dans un champ frère
317
- `meta` (`RpcMeta`, `JsonRpcPeer.ts:90`).
321
+ `meta` (`RpcMeta`, `JsonRpcPeer.ts#RpcMeta`).
318
322
 
319
323
  ```jsonc
320
324
  {
@@ -335,37 +339,38 @@ rétro-compatible par construction.
335
339
  Ce que le serveur comprend **sans qu'aucun contrôleur ne l'ait déclaré**, plus les deux formes
336
340
  ouvertes à l'application. Colonne `id` : présent = requête (réponse due), absent = notification.
337
341
 
338
- | Méthode | Direction | `id` ? | Rôle | Ancrage |
339
- | ------------------ | ------------- | :-----: | --------------------------------------------------------------------- | --------------------------- |
340
- | `subscribe` | client→server | non | « pousse-moi ce canal » — `params.channel` | `RealtimeController.ts:522` |
341
- | `unsubscribe` | client→server | non | « arrête » — dernier abonné, le producteur est libéré | `RealtimeController.ts:775` |
342
- | `ping` | client→server | non | Battement de cœur — **no-op serveur**, aucun pong | `RealtimeClient.ts:882` |
343
- | `<canal>` | server→client | non | Push d'un message : le **nom du canal est la `method`** | `RealtimeController.ts:972` |
344
- | `<canal entrant>` | client→server | non | Le client pousse sur un canal déclaré entrant | `RealtimeController.ts:736` |
345
- | `realtime:welcome` | server→client | non | L'accueil : 5 champs, dont l'identité résolue | `RealtimeController.ts:693` |
346
- | `realtime:denied` | server→client | non | Rend OBSERVABLE le refus d'une notification | `RealtimeController.ts:496` |
347
- | `api.request` | client→server | **oui** | Pont API — rejoue une route HTTP sur la socket (désactivé par défaut) | `RealtimeController.ts:582` |
348
- | `<action>` | client→server | **oui** | Toute action déclarée par `@RealtimeAction` | `realtimeDecorators.ts:101` |
342
+ | Méthode | Direction | `id` ? | Rôle | Ancrage |
343
+ | --------------------- | ------------- | :-----: | --------------------------------------------------------------------- | ------------------------------------------------------------------ |
344
+ | `subscribe` | client→server | non | « pousse-moi ce canal » — `params.channel` | `RealtimeController.ts#RealtimeController.onHandshake` |
345
+ | `unsubscribe` | client→server | non | « arrête » — dernier abonné, le producteur est libéré | `RealtimeController.ts#RealtimeController.onRealtimeNotification` |
346
+ | `ping` | client→server | non | Battement de cœur — **no-op serveur**, aucun pong | `NodefonySocket.ts#NodefonySocket.ping` |
347
+ | `<canal>` | server→client | non | Push d'un message : le **nom du canal est la `method`** du `notify` | `RealtimeController.ts#RealtimeController.startChannel` |
348
+ | `<canal entrant>` | client→server | non | Le client pousse sur un canal déclaré entrant | `RealtimeController.ts#RealtimeController.onRealtimeNotification` |
349
+ | `realtime:welcome` | server→client | non | L'accueil : 5 champs, dont l'identité résolue | `RealtimeController.ts#RealtimeController.onHandshake` |
350
+ | `realtime:denied` | server→client | non | Rend OBSERVABLE le refus d'une notification | `RealtimeController.ts#RealtimeController.onHandshake` |
351
+ | `realtime:subscribed` | server→client | non | Acquitte un `subscribe` ACCEPTÉ — le flux est actif | `RealtimeController.ts#RealtimeController.acknowledgeSubscription` |
352
+ | `api.request` | client→server | **oui** | Pont API — rejoue une route HTTP sur la socket (désactivé par défaut) | `RealtimeController.ts#RealtimeController.onHandshake` |
353
+ | `<action>` | client→server | **oui** | Toute action déclarée par `@RealtimeAction` | `realtimeDecorators.ts#RealtimeAction` |
349
354
 
350
355
  > [!TIP]
351
356
  > **L'accueil est ta carte du territoire.** `channels` ne liste que les canaux que ce visiteur
352
357
  > pourrait obtenir (filtrés par le même verrou que `subscribe`), `methods` les actions de l'endpoint
353
- > (`RealtimeController.ts:710`) : un client peut activer ou griser
358
+ > (`RealtimeController.ts#RealtimeController.onHandshake`) : un client peut activer ou griser
354
359
  > ses commandes sans rien coder en dur. Côté navigateur, ils se lisent en `socket.serverMethods` et
355
360
  > `socket.serverChannels`.
356
361
 
357
362
  Les quatre formes de frame circulent en permanence sous tes yeux — ce schéma les montre sur la
358
363
 
359
364
  > [!WARNING]
360
- > `nodefony:kernel:ping` et `nodefony:kernel:gc` (`StudioRealtimeController.ts:114`) sont des exemples d'actions,
365
+ > `nodefony:kernel:ping` et `nodefony:kernel:gc` (`StudioRealtimeController.ts#StudioRealtimeController.realtimeActions`) sont des exemples d'actions,
361
366
  > **pas des méthodes du cœur temps réel** : elles sont déclarées par le contrôleur
362
367
  > d'administration de `@nodefony/studio`. Un endpoint applicatif ne les expose pas. Le helper
363
- > `RealtimeClient.ping()` (`RealtimeClient.ts:878`) mesure le RTT en les appelant — il suppose donc
368
+ > `NodefonySocket.ping()` (`NodefonySocket.ts#NodefonySocket.ping`) mesure le RTT en les appelant — il suppose donc
364
369
  > un endpoint qui les déclare, contrairement à la notification `ping` du battement de cœur, qui
365
370
  > n'attend jamais de réponse.
366
371
 
367
372
  `subscribe` et `unsubscribe` ne sont **pas** des actions enregistrées : elles sont traitées dans
368
- `onRealtimeNotification()` (`RealtimeController.ts:784`). Envoyées avec un `id`, elles seraient
373
+ `onRealtimeNotification()` (`RealtimeController.ts#RealtimeController.onRealtimeNotification`). Envoyées avec un `id`, elles seraient
369
374
  classées « requête », ne trouveraient aucun handler et récolteraient un `-32601`.
370
375
 
371
376
  ## Une conversation type, de bout en bout
@@ -397,52 +402,79 @@ Voici **tout** ce que le moteur produit. Les codes de la plage `-32000`…`-3209
397
402
  la norme aux erreurs applicatives serveur ; Nodefony y place son refus d'autorisation et le défaut
398
403
  de `RpcError`.
399
404
 
400
- | Code | Émis par | Déclencheur | Ce que voit le pair |
401
- | -------- | ---------------------------------------------------------- | --------------------------------------------------- | ---------------------------------------------- |
402
- | `-32601` | `JsonRpcPeer.handleRequest()` (`JsonRpcPeer.ts:499`) | requête vers une action non enregistrée | `method not found: <nom>` |
403
- | `-32603` | même méthode (`JsonRpcPeer.ts:528`) | le handler a levé une exception **ordinaire** | `internal error` — générique, rien d'autre |
404
- | `-32001` | le refus du verrou de frame (`JsonRpcPeer.ts:400`) | `beforeDispatch` a dit non **sur une requête** | `unauthorized`, sans jamais dire pourquoi |
405
- | `-32000` | défaut du constructeur de `RpcError` (`JsonRpcPeer.ts:74`) | le handler expose volontairement son refus | le message ET le `data` choisis par le handler |
406
- | `-32602` | le pont API, via `RpcError` (`RealtimeController.ts:787`) | `api.request` appelé avec un `params.path` invalide | message explicite (l'appel est malformé) |
405
+ | Code | Émis par | Déclencheur | Ce que voit le pair |
406
+ | -------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------- |
407
+ | `-32601` | `JsonRpcPeer.handleRequest()` (`JsonRpcPeer.ts#JsonRpcPeer.handleRequest`) | requête vers une action non enregistrée | `method not found: <nom>` |
408
+ | `-32603` | même méthode (`JsonRpcPeer.ts#JsonRpcPeer.handleRequest`) | le handler a levé une exception **ordinaire** | `internal error` — générique, rien d'autre |
409
+ | `-32001` | le refus du verrou de frame (`JsonRpcPeer.ts#JsonRpcPeer.receive`) | `beforeDispatch` a dit non **sur une requête** | `unauthorized`, sans jamais dire pourquoi |
410
+ | `-32000` | défaut du constructeur de `RpcError` (`JsonRpcPeer.ts#RpcError`) | le handler expose volontairement son refus | le message ET le `data` choisis par le handler |
411
+ | `-32602` | le pont API, via `RpcError` (`RealtimeController.ts#RealtimeController.actionParamsWrapper`) | `api.request` appelé avec un `params.path` invalide | message explicite (l'appel est malformé) |
412
+ | `-32000` | le pont API (`RealtimeController.ts#RealtimeController.invokeApiRequest`) | la route invoquée REFUSE : 401, 403, 404, 405, **422** | le message, `data.status` — et `data.fields` sur un 422 |
413
+
414
+ Un refus de la route invoquée par le pont garde la forme d'un `fetch` dont `ok` est faux : le statut HTTP voyage dans `data.status`. Une erreur de validation — `ZodError` levée par l'action, ou entrée refusée par le `schema` de la route — est un **422** dont `data.fields` nomme chaque champ et sa partie (`{ field, message, in }`), exactement le `error.fields` du même appel en HTTP. Tout autre `throw` reste opaque (`-32603`).
407
415
 
408
416
  Et un échec qui n'est **pas** une frame : l'expiration. `startCall()` ne reçoit rien dans le délai
409
417
  imparti, supprime l'entrée en attente et rejette localement avec `RPC timeout: <méthode>`
410
- (`JsonRpcPeer.ts:428`). Défaut de 30 000 ms, réglable par appel. Aucun octet ne part sur le fil : c'est une décision du
418
+ (`JsonRpcPeer.ts#JsonRpcPeer.startCall`). Défaut de 30 000 ms, réglable par appel. Aucun octet ne part sur le fil : c'est une décision du
411
419
  client, le serveur peut très bien répondre après, sa réponse sera ignorée.
412
420
 
421
+ L'inverse n'attend pas : une frame qui porte l'`id` d'une requête en attente sans être une réponse
422
+ valide — ni `result` ni `error` bien formé — fait échouer l'appel **aussitôt**, en `RpcError`
423
+ `-32603` « réponse JSON-RPC invalide » (`JsonRpcPeer.ts#JsonRpcPeer.settleUnreadableResponse`). Si elle porte `result` ET une `error`
424
+ bien formée (la norme l'interdit), l'erreur annoncée est rendue telle quelle. Rien ne part sur le
425
+ fil : c'est un échec local, et la frame est auditée `invalid`.
426
+
413
427
  ### Les deux codes de la norme que Nodefony n'émet jamais
414
428
 
415
429
  `-32700 Parse error` et `-32600 Invalid Request` figurent dans la norme, mais **aucune frame ne les
416
430
  porte** ici :
417
431
 
418
432
  - un JSON illisible n'atteint même pas le pair — le contrôleur l'abandonne à la lecture
419
- (`RealtimeController.ts:446`) ;
420
- - une frame lisible mais non conforme (pas d'objet, `jsonrpc` absent ou faux) est classée `invalid`,
421
- signalée à l'audit, et **rien n'est renvoyé** (`JsonRpcPeer.ts:429`).
433
+ (`RealtimeController.ts#RealtimeController.onHandshake`) ;
434
+ - une frame lisible mais non conforme — pas d'objet, un lot, `jsonrpc` absent ou faux, `id` `null`,
435
+ objet ou booléen, `method` non chaîne, réponse sans `result` ni `error` bien formé, ou avec les
436
+ deux — est classée `invalid`, signalée à l'audit, et **rien n'est renvoyé** (`JsonRpcPeer.ts#JsonRpcPeer.receive`).
422
437
 
423
438
  Le choix est délibéré : une frame cassée n'a pas d'`id` digne de confiance, donc pas de corrélation
424
439
  possible ; et répondre systématiquement à du bruit offre à un attaquant un amplificateur gratuit.
425
- Le refus reste **traçable** — c'est le motif `invalid` de `FrameAuditReason` (`JsonRpcPeer.ts:155`),
440
+ Le refus reste **traçable** — c'est le motif `invalid` de `FrameAuditReason` (`JsonRpcPeer.ts#FrameAuditReason`),
426
441
  qui alimente le journal d'audit avec le pair concerné.
427
442
 
428
443
  ### Le refus d'une notification n'est pas une erreur
429
444
 
430
445
  C'est la subtilité la plus importante de la page. Le verrou de frame s'applique aux requêtes **et**
431
- aux notifications (`beforeDispatch`, `JsonRpcPeer.ts:192`), mais leurs conséquences diffèrent
446
+ aux notifications (`beforeDispatch`, `JsonRpcPeer.ts#JsonRpcPeerOptions.beforeDispatch`), mais leurs conséquences diffèrent
432
447
  radicalement :
433
448
 
434
- | La frame refusée est… | Ce qui part | Pourquoi |
435
- | --------------------- | --------------------------------------------------------------- | ---------------------------------------------------- |
436
- | une **requête** | `-32001 "unauthorized"` (`JsonRpcPeer.ts:413`) | Un `id` existe : il y a un canal de réponse |
437
- | une **notification** | la notification `realtime:denied` (`RealtimeController.ts:496`) | Aucun `id` : sans elle, le client se croirait abonné |
449
+ | La frame refusée est… | Ce qui part | Pourquoi |
450
+ | --------------------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------- |
451
+ | une **requête** | `-32001 "unauthorized"` (`JsonRpcPeer.ts#JsonRpcPeer.receive`) | Un `id` existe : il y a un canal de réponse |
452
+ | une **notification** | la notification `realtime:denied` (`RealtimeController.ts#RealtimeController.onHandshake`) | Aucun `id` : sans elle, le client se croirait abonné |
438
453
 
439
- `IRealtimeDenied` (`RealtimeEventMap.ts:269`) porte `channel` et `reason` — plus un `detail` optionnel, posé hors production seulement — et le motif
454
+ `IRealtimeDenied` (`RealtimeEventMap.ts#IRealtimeDenied`) porte `channel` et `reason` — plus un `detail` optionnel, posé hors production seulement — et le motif
440
455
  est **générique**. Jamais « il te manque `ROLE_ADMIN` » : ce serait un oracle d'autorisation, un
441
456
  attaquant y lirait la carte des droits. Trois motifs circulent : `forbidden` (le verrou a dit non),
442
- `limit` (le plafond de canaux de la connexion est atteint, `RealtimeController.ts:811`) et `unknown`
457
+ `limit` (le plafond de canaux de la connexion est atteint, `RealtimeController.ts#RealtimeController.onRealtimeNotification`) et `unknown`
443
458
  (aucun producteur ne sert ce nom — jamais un oracle : un canal gardé rend `forbidden`, qu'il existe ou
444
459
  non). Côté client,
445
- `onDenied()` (`RealtimeClient.ts:469`) branche un handler dessus.
460
+ `onDenied()` (`NodefonySocket.ts#NodefonySocket.onDenied`) branche un handler dessus.
461
+
462
+ ### L'abonnement accepté s'acquitte
463
+
464
+ Le pendant du refus : un `subscribe` ACCEPTÉ reçoit `realtime:subscribed { channel }`
465
+ (`IRealtimeSubscribed`), y compris pour un canal que la connexion suivait déjà — sans quoi un
466
+ `subscribe` réémis attendrait une réponse qui ne vient jamais. Un abonnement reçoit l'un OU
467
+ l'autre, jamais les deux.
468
+
469
+ **Garantie** : tout événement publié sur le canal APRÈS l'émission de l'acquittement est livré
470
+ à la connexion. Le producteur s'attache AVANT : une première charge (un tick initial) peut arriver
471
+ avant l'acquittement — « rien n'arrive avant » n'est pas promis. L'abonnement d'une connexion est
472
+ local au pod, le backplane n'y prend aucune part : l'acquittement local suffit.
473
+
474
+ Le welcome annonce la capacité (`subscriptionAcks: true`). Un client qui ne la voit pas — serveur
475
+ antérieur — répute l'abonnement actif dès le rejeu, au lieu d'attendre pour toujours. Côté client,
476
+ `whenSubscribed(canal)` se résout à l'acquittement et se rejette sur un refus du même canal ;
477
+ `onSubscribed()` rappelle un écouteur à chaque acquittement.
446
478
 
447
479
  > [!CAUTION]
448
480
  > Un `-32403 Forbidden` circule dans d'anciennes notes. **Ce code n'existe pas** dans Nodefony, et il
@@ -461,7 +493,7 @@ en découlent :
461
493
  politique du canal ; la demande a exactement la même forme qu'un abonnement autorisé, seule la
462
494
  réponse diffère.
463
495
  3. **Le client ne pousse que là où c'est déclaré.** Un canal n'accepte d'entrée que si un handler
464
- entrant existe pour ce nom (`RealtimeController.ts:608`) ; sinon la notification est ignorée en
496
+ entrant existe pour ce nom (`RealtimeController.ts#RealtimeController.onHandshake`) ; sinon la notification est ignorée en
465
497
  silence. Le défaut est fermé.
466
498
 
467
499
  Les mécanismes eux-mêmes — authenticator, verrou de frame, politique de canal, révocation — sont
@@ -469,19 +501,21 @@ décrits dans [la page sécurité](./securite.md).
469
501
 
470
502
  ## ⚠️ Pièges
471
503
 
472
- | Symptôme | Cause | Correction |
473
- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
474
- | `subscribe` répond `-32601 method not found` | Envoyé **avec un `id`** : classé requête, or c'est une notification (`RealtimeController.ts:592`) | L'émettre sans `id` — `socket.subscribe(canal)` |
475
- | Le handler passé à `subscribe` n'est jamais appelé | `RealtimeClient.subscribe()` prend **un seul** argument (`RealtimeClient.ts:532`) | `subscribe(canal)` **et** `on(canal, handler)`, deux gestes distincts |
476
- | `request()` expire immédiatement, ou ignore le délai | Signature **positionnelle** `(méthode, params, ms)` (`RealtimeClient.ts:727`) — un objet d'options n'est pas lu | `request(m, p, 5000)` ; le défaut est 30 000 ms |
477
- | Un tableau de frames n'obtient aucune réponse | Le batch n'est pas implémenté : un tableau n'a pas de `jsonrpc` → `invalid` (`JsonRpcPeer.ts:371`) | Une frame = un objet ; le multiplexage remplace le batch |
478
- | Une frame malformée ne renvoie **aucune** erreur | Ni `-32700` ni `-32600` ne sont émis — silence + audit (`JsonRpcPeer.ts:396`) | Lire le motif `invalid` côté serveur, pas la réponse |
479
- | L'exception du serveur n'arrive jamais au client | Zero Trust : tout throw ordinaire devient `-32603` générique (`JsonRpcPeer.ts:528`) | Lever une `RpcError` pour exposer volontairement code et `data` |
480
- | Une notification refusée disparaît sans trace côté client | Sans `id`, aucune réponse possible (`beforeDispatch`, `JsonRpcPeer.ts:151`) | Écouter `realtime:denied` via `onDenied()` (`RealtimeClient.ts:471`) |
481
- | `nodefony:kernel:ping` répond `-32601` sur mon endpoint | L'action `nodefony:kernel:ping` est déclarée par `@nodefony/studio` (`StudioRealtimeController.ts:114`) | Déclarer la sienne, ou lire `serverMethods` avant d'appeler |
482
- | Le battement de cœur ne renvoie aucun pong | La notification `ping` est un no-op serveur (`RealtimeController.ts:791`) | Pour mesurer un RTT, utiliser une action RPC — `ping()` (`RealtimeClient.ts:878`) |
483
- | Une réponse reçue est ignorée sans message | Corrélation sur `id` **numériques** seulement (`JsonRpcPeer.ts:537`) | Ne pas fabriquer soi-même de réponse à `id` chaîne |
484
- | Les premières frames envoyées après `connect()` semblent perdues | Le transport n'est branché qu'une fois le handshake terminé | Attendre `realtime:welcome` — le client le fait déjà |
504
+ | Symptôme | Cause | Correction |
505
+ | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
506
+ | `subscribe` répond `-32601 method not found` | Envoyé **avec un `id`** : classé requête, or c'est une notification (`RealtimeController.ts#RealtimeController.onHandshake`) | L'émettre sans `id` — `socket.subscribe(canal)` |
507
+ | Le handler passé à `subscribe` n'est jamais appelé | `NodefonySocket.subscribe()` prend **un seul** argument (`NodefonySocket.ts#NodefonySocket.subscribe`) | `subscribe(canal)` **et** `on(canal, handler)`, deux gestes distincts |
508
+ | `request()` expire immédiatement, ou ignore le délai | Signature **positionnelle** `(méthode, params, ms)` (`NodefonySocket.ts#NodefonySocket.request`) — un objet d'options n'est pas lu | `request(m, p, 5000)` ; le défaut est 30 000 ms |
509
+ | Un tableau de frames n'obtient aucune réponse | Le batch n'est pas implémenté : un tableau n'a pas de `jsonrpc` → `invalid` (`jsonrpc/index.ts#classifyJsonRpcFrame`) | Une frame = un objet ; le multiplexage remplace le batch |
510
+ | Une frame malformée ne renvoie **aucune** erreur | Ni `-32700` ni `-32600` ne sont émis — silence + audit (`JsonRpcPeer.ts#JsonRpcPeer.receive`) | Lire le motif `invalid` côté serveur, pas la réponse |
511
+ | Une trame binaire envoyée au client n'arrive à aucun handler | Le protocole est du texte : le client l'écarte, la compte et émet une notice `-32700` (`NodefonySocket.ts#NodefonySocket.binaryFramesDiscarded`) | Un flux binaire se lit par `onBinary()` (`NodefonySocket.ts#NodefonySocket.onBinary`), hors JSON-RPC |
512
+ | Une trame binaire envoyée au serveur n'arrive à aucune action | Même règle côté serveur : écartée, comptée (`WsConnectionTransport.ts#WsConnectionTransport.binaryDiscarded`), journalisée une fois en WARNING | Surcharger `onRealtimeBinary()` (`RealtimeController.ts#RealtimeController.onRealtimeBinary`) ; émettre par `sendBinaryToClient()` |
513
+ | L'exception du serveur n'arrive jamais au client | Zero Trust : tout throw ordinaire devient `-32603` générique (`JsonRpcPeer.ts#JsonRpcPeer.handleRequest`) | Lever une `RpcError` pour exposer volontairement code et `data` |
514
+ | Une notification refusée disparaît sans trace côté client | Sans `id`, aucune réponse possible (`beforeDispatch`, `JsonRpcPeer.ts#JsonRpcPeerOptions.beforeDispatch`) | Écouter `realtime:denied` via `onDenied()` (`NodefonySocket.ts#NodefonySocket.onDenied`) |
515
+ | `nodefony:kernel:ping` répond `-32601` sur mon endpoint | L'action `nodefony:kernel:ping` est déclarée par `@nodefony/studio` (`StudioRealtimeController.ts#StudioRealtimeController.realtimeActions`) | Déclarer la sienne, ou lire `serverMethods` avant d'appeler |
516
+ | Le battement de cœur ne renvoie aucun pong | La notification `ping` est un no-op serveur (`RealtimeController.ts#RealtimeController.onHandshake`) | Pour mesurer un RTT, utiliser une action RPC — `ping()` (`NodefonySocket.ts#NodefonySocket.ping`) |
517
+ | Une réponse reçue est ignorée sans message | Corrélation sur `id` **numériques** seulement (`JsonRpcPeer.ts#JsonRpcPeer.handleResponse`) | Ne pas fabriquer soi-même de réponse à `id` chaîne |
518
+ | Les premières frames envoyées après `connect()` semblent perdues | Le transport n'est branché qu'une fois le handshake terminé | Attendre `realtime:welcome` — le client le fait déjà |
485
519
 
486
520
  ## 🧪 Tests & couverture
487
521
 
@@ -491,7 +525,7 @@ carte de l'aperçu, régénérée depuis les résultats réels — jamais figés
491
525
 
492
526
  - **Le moteur, dans le cœur** (`src/nodefony/src/tests/`) : `JsonRpcPeer.test.ts` (classement des
493
527
  frames, corrélation, timeout, erreurs), `JsonRpcPeer.types.test.ts` (le contrat générique tient à
494
- la compilation), et la famille `RealtimeClient*.test.ts` — dispatch, identité annoncée par
528
+ la compilation), et la famille `NodefonySocket*.test.ts` — dispatch, identité annoncée par
495
529
  l'accueil, transport, `ping`. Ces suites **ne sont pas comptées** dans la carte du module
496
530
  `@nodefony/realtime` : elles appartiennent au workspace `nodefony`.
497
531
  - **Le câblage serveur, dans ce module** : `RealtimeController.test.ts` couvre les réponses
@@ -508,7 +542,7 @@ Couverture : `npm run coverage` dans `@nodefony/realtime` et dans le workspace `
508
542
  > [!CAUTION]
509
543
  > Plusieurs suites bout en bout exigent une infrastructure. **Un test sauté compte comme vert** :
510
544
  > avant de conclure « tout passe », vérifier les variables d'environnement — la source unique est
511
- > `vitest.gates.ts` à la racine, dont le rapport s'affiche en fin d'exécution.
545
+ > `scripts/test/vitest/gates.ts` à la racine, dont le rapport s'affiche en fin d'exécution.
512
546
 
513
547
  ## 🔗 Pour aller plus loin
514
548