@nodefony/realtime 10.0.0-beta.5 → 10.0.0-beta.7

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 (96) hide show
  1. package/README.md +55 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +91 -12
  4. package/dist/nodefony/config/config.js +7 -0
  5. package/dist/nodefony/decorators/realtimeDecorators.js +341 -43
  6. package/dist/nodefony/interfaces/IRealtimeConnection.js +1 -0
  7. package/dist/nodefony/src/backplane/ClusterBackplane.js +32 -4
  8. package/dist/nodefony/src/backplane/RedisBackplane.js +146 -41
  9. package/dist/nodefony/src/backplane/controlOrder.js +86 -0
  10. package/dist/nodefony/src/backplane/envelope.js +505 -27
  11. package/dist/nodefony/src/backplane/ingress.js +93 -0
  12. package/dist/nodefony/src/backplane/presenceMessage.js +205 -0
  13. package/dist/nodefony/src/backplane/publishQueue.js +97 -3
  14. package/dist/nodefony/src/cluster/ClusterProbeClient.js +2 -0
  15. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
  16. package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
  17. package/dist/nodefony/src/server/RealtimeConnection.js +135 -0
  18. package/dist/nodefony/src/server/RealtimeController.js +409 -107
  19. package/dist/nodefony/src/server/RealtimeHub.js +1158 -111
  20. package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
  21. package/dist/nodefony/src/server/admissionBudget.js +176 -0
  22. package/dist/nodefony/src/server/channelPolicyRegistry.js +95 -0
  23. package/dist/nodefony/src/server/channelPolicyResolution.js +191 -0
  24. package/dist/nodefony/src/server/channelPresence.js +488 -0
  25. package/dist/nodefony/src/server/connectionAdmission.js +242 -0
  26. package/dist/nodefony/src/server/contractRoutes.js +75 -0
  27. package/dist/nodefony/src/server/deniedDetail.js +10 -3
  28. package/dist/nodefony/src/server/frameAdmission.js +160 -0
  29. package/dist/nodefony/src/server/presenceBus.js +486 -0
  30. package/dist/nodefony/src/server/realtimeShutdown.js +31 -0
  31. package/dist/nodefony/src/server/remotePresence.js +541 -0
  32. package/dist/nodefony/src/server/subscribedAck.js +13 -1
  33. package/dist/nodefony/src/server/syslogUplink.js +3 -4
  34. package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
  35. package/dist/nodefony/src/service/RealtimeService.js +51 -1
  36. package/dist/nodefony/src/service/publicOrigin.js +40 -0
  37. package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
  38. package/dist/nodefony/testing/backplaneConformance.js +347 -0
  39. package/dist/nodefony/testing/index.js +43 -12
  40. package/dist/nodefony/testing/presenceClient.js +54 -0
  41. package/dist/types/index.d.ts +39 -7
  42. package/dist/types/nodefony/config/config.d.ts +9 -0
  43. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +198 -17
  44. package/dist/types/nodefony/interfaces/IBackplane.d.ts +46 -51
  45. package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
  46. package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
  47. package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
  48. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
  49. package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
  50. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
  51. package/dist/types/nodefony/interfaces/index.d.ts +2 -1
  52. package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
  53. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +89 -18
  54. package/dist/types/nodefony/src/backplane/controlOrder.d.ts +75 -0
  55. package/dist/types/nodefony/src/backplane/envelope.d.ts +77 -0
  56. package/dist/types/nodefony/src/backplane/ingress.d.ts +48 -0
  57. package/dist/types/nodefony/src/backplane/presenceMessage.d.ts +150 -0
  58. package/dist/types/nodefony/src/backplane/publishQueue.d.ts +25 -1
  59. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +10 -0
  60. package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
  61. package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +20 -0
  62. package/dist/types/nodefony/src/server/RealtimeController.d.ts +110 -12
  63. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +448 -42
  64. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
  65. package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
  66. package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +41 -0
  67. package/dist/types/nodefony/src/server/channelPolicyResolution.d.ts +112 -0
  68. package/dist/types/nodefony/src/server/channelPresence.d.ts +155 -0
  69. package/dist/types/nodefony/src/server/connectionAdmission.d.ts +68 -0
  70. package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
  71. package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
  72. package/dist/types/nodefony/src/server/frameAdmission.d.ts +66 -0
  73. package/dist/types/nodefony/src/server/presenceBus.d.ts +112 -0
  74. package/dist/types/nodefony/src/server/realtimeShutdown.d.ts +21 -0
  75. package/dist/types/nodefony/src/server/remotePresence.d.ts +151 -0
  76. package/dist/types/nodefony/src/server/subscribedAck.d.ts +10 -0
  77. package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
  78. package/dist/types/nodefony/src/service/RealtimeService.d.ts +17 -2
  79. package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
  80. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
  81. package/dist/types/nodefony/testing/backplaneConformance.d.ts +78 -0
  82. package/dist/types/nodefony/testing/index.d.ts +13 -2
  83. package/dist/types/nodefony/testing/presenceClient.d.ts +20 -0
  84. package/docs/actions.md +420 -18
  85. package/docs/architecture.md +101 -11
  86. package/docs/cadence-et-contre-pression.md +87 -11
  87. package/docs/canaux-a-membres.md +277 -0
  88. package/docs/cas-d-usage.md +1703 -0
  89. package/docs/configuration.md +175 -19
  90. package/docs/cookbook-chat.md +701 -224
  91. package/docs/index.md +11 -1
  92. package/docs/observabilite.md +97 -21
  93. package/docs/protocole.md +142 -33
  94. package/docs/securite.md +347 -41
  95. package/docs/vocabulaire.md +13 -12
  96. package/package.json +16 -13
package/docs/index.md CHANGED
@@ -46,7 +46,9 @@ Quatre parcours selon ce que tu viens faire. L'ordre compte : chaque étape supp
46
46
  2. [Ce que le module apporte](#-ce-que-le-module-apporte) — pourquoi une couche de plus au-dessus de
47
47
  la WebSocket, et ce qu'elle t'évite d'écrire.
48
48
  3. [Architecture](./architecture.md) — le trajet complet d'une frame, du câble jusqu'à ta méthode.
49
- 4. [Cookbook — un chat](./cookbook-chat.md) — l'exemple intégrateur, de bout en bout.
49
+ 4. [Cas d'usage](./cas-d-usage.md) — jeu, salon privé, sonde, notification, tâche : l'exemple
50
+ court qui correspond à ton besoin, et le piège qui l'accompagne.
51
+ 5. [Cookbook — un chat](./cookbook-chat.md) — l'exemple intégrateur, de bout en bout.
50
52
 
51
53
  **Je passe d'un processus à plusieurs** — le moment où le temps réel casse sans prévenir. En
52
54
  mono-processus tout marchait ; à deux répliques, un message publié sur l'une n'atteint jamais un
@@ -93,6 +95,8 @@ Le tableau pour choisir en cinq secondes ; les cards en dessous pour savoir ce q
93
95
  | [Actions RPC](./actions.md) | appeler le serveur et attendre une réponse | tu veux savoir si l'appel a marché |
94
96
  | [Observabilité](./observabilite.md) | la sonde, les canaux de santé, les écrans | tu te demandes si ta socket va bien |
95
97
  | [Cadence et contre-pression](./cadence-et-contre-pression.md) | un client trop lent : ralentir, refuser, couper | un écran prend du retard, ou la mémoire monte |
98
+ | [Canal à membres](./canaux-a-membres.md) | abonner d'office, éjecter, terminer un canal | le serveur décide qui est dans le canal |
99
+ | [Cas d'usage](./cas-d-usage.md) | un exemple court par besoin, et son piège | tu sais ce que tu veux construire |
96
100
  | [Cookbook — un chat](./cookbook-chat.md) | l'exemple complet, client et serveur | tu veux du code qui marche tout de suite |
97
101
 
98
102
  ```nodefony-cards
@@ -121,6 +125,12 @@ Le tableau pour choisir en cinq secondes ; les cards en dessous pour savoir ce q
121
125
  { "icon": "🚦", "title": "cadence-et-contre-pression", "href": "./cadence-et-contre-pression.md",
122
126
  "desc": "Deux étages qui ne se parlent pas et se rencontrent quand même : le client ralentit la cadence de ses canaux d'état (AIMD), le serveur refuse d'empiler au-delà de 4 Mio puis ferme en 1013. Avec les courbes calculées par le vrai code, et les sondes qui le montrent en production.",
123
127
  "meta": "un écran prend du retard, ou la mémoire du serveur monte" },
128
+ { "icon": "👥", "title": "canaux-a-membres", "href": "./canaux-a-membres.md",
129
+ "desc": "Une partie à quatre, un salon fermé, un appel : le serveur abonne une connexion d'office, en éjecte une, termine le canal pour tous — et allow reste seul juge de qui entre.",
130
+ "meta": "le serveur décide qui est dans le canal" },
131
+ { "icon": "🎯", "title": "cas-d-usage", "href": "./cas-d-usage.md",
132
+ "desc": "Un jeu, un salon sur invitation, des capteurs, une notification personnelle, la progression d'une tâche : l'exemple de chaque besoin, exécuté par la suite de tests, avec le choix qui le justifie et l'erreur qui ne se voit qu'en production.",
133
+ "meta": "tu sais ce que tu veux construire" },
124
134
  { "icon": "🍳", "title": "cookbook-chat", "href": "./cookbook-chat.md",
125
135
  "desc": "Un salon de discussion complet : configuration, service métier, contrôleur, client navigateur, puis le passage en cluster.",
126
136
  "meta": "du code qui tourne, à déformer vers ton besoin" }
@@ -247,26 +247,29 @@ socket.subscribe("shop:health");
247
247
 
248
248
  ## 🧰 Ce que la sonde expose, champ par champ
249
249
 
250
- Le contrat est `IRealtimeProbe` (`IRealtimeProbe.ts#IRealtimeProbe`). Quatre familles, un discriminant.
250
+ Le contrat est `IRealtimeProbe` (`nodefony/src/realtime/platformHealth.ts#IRealtimeProbe`). Quatre familles, un discriminant.
251
251
 
252
252
  ### Canaux — qui vit, qui écoute
253
253
 
254
- | Champ | Ce qu'il dit |
255
- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
256
- | `channels[]` | Une entrée par canal **actif** (au moins un abonné), cf `IRealtimeChannelStat` (`IRealtimeProbe.ts#IRealtimeChannelStat`). |
257
- | `channels[].channel` | Le nom exact souscrit, **suffixe de cadence compris** (`nodefony:orm:health:2000`). |
258
- | `channels[].subscribers` | Abonnés locaux vivants. Instantané, pas un cumul. |
259
- | `channels[].messages` | Publications cumulées sur ce canal. |
260
- | `channelCount` | Nombre de canaux actifs sur ce processus. |
254
+ | Champ | Ce qu'il dit |
255
+ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
256
+ | `channels[]` | Une entrée par canal **actif** (au moins un abonné), cf `IRealtimeChannelStat` (`nodefony/src/realtime/platformHealth.ts#IRealtimeChannelStat`). |
257
+ | `channels[].channel` | Le nom exact souscrit, **suffixe de cadence compris** (`nodefony:orm:health:2000`). |
258
+ | `channels[].subscribers` | Abonnés locaux vivants. Instantané, pas un cumul. |
259
+ | `channels[].messages` | Publications cumulées sur ce canal. |
260
+ | `channelCount` | Nombre de canaux actifs sur ce processus. |
261
261
 
262
262
  ### Fan-out — l'amplification réelle du broker
263
263
 
264
- | Champ | Ce qu'il dit |
265
- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
266
- | `publishTotal` | Appels à `publish`. **Ce que le serveur a voulu envoyer.** |
267
- | `fanoutTotal` | Livraisons effectives (`publish` × abonnés). **Ce que ça a réellement coûté.** |
268
- | `inboundTotal` | Frames poussées par les clients sur les canaux entrants, via `RealtimeHub.recordInbound()` (`RealtimeHub.ts#RealtimeHub.recordInbound`). |
269
- | `ingressRejectedTotal` | Messages venus du **backplane** et jetés parce que leur canal n'est pas déclaré diffusable. |
264
+ | Champ | Ce qu'il dit |
265
+ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
266
+ | `publishTotal` | Appels à `publish`. **Ce que le serveur a voulu envoyer.** |
267
+ | `fanoutTotal` | Livraisons effectives (`publish` × abonnés). **Ce que ça a réellement coûté.** |
268
+ | `inboundTotal` | Frames poussées par les clients sur les canaux entrants, via `RealtimeHub.recordInbound()` (`RealtimeHub.ts#RealtimeHub.recordInbound`). |
269
+ | `ingressRejectedTotal` | Messages venus du **backplane** et jetés parce que leur canal n'est pas déclaré diffusable. |
270
+ | `sealRejectedTotal` | Messages venus du **backplane** et jetés parce que leur sceau ne se vérifie pas. `0` sans bus scellé. |
271
+ | `ingressUnknownDiscardedTotal` | Messages venus du **backplane** et jetés parce que d'une version **plus récente** : canal `nodefony:bus:*` ou type d'ordre inconnus. |
272
+ | `localOnlyTotal` | Publications restées sur ce serveur alors qu'un bus est branché (canal non diffusable). `0` sans bus. |
270
273
 
271
274
  Le rapport `fanoutTotal / publishTotal` est la mesure la plus parlante de la page : c'est la **taille
272
275
  moyenne d'un salon**. S'il vaut 1, chaque publication ne touche qu'une personne — un canal par client,
@@ -296,14 +299,83 @@ lectures possibles quand il grimpe :
296
299
  Le canal visé n'est délibérément pas exposé : un compteur d'observabilité ne doit pas devenir un
297
300
  moyen de sonder ce que le système accepte.
298
301
 
299
- ⚠️ Ce compteur ne voit pas tout : un message **mal scellé** est écarté par le transport, avant
300
- d'atteindre le hub, donc sans être compté ici. Un bus scellé peut ainsi rester à zéro alors qu'il est
301
- attaqué — le signe est alors l'absence de trafic légitime, pas ce compteur.
302
+ ### Messages du bus d'une version plus récente — compté à part
303
+
304
+ Une version suivante peut ajouter des messages au bus : un canal du territoire `nodefony:bus:`, ou un
305
+ nouveau type d'ordre sur le canal de contrôle. Pendant un déploiement progressif, un serveur resté sur
306
+ l'ancienne version les reçoit sans les connaître. Il les **jette** — aucun abonné n'en reçoit rien —
307
+ et les compte dans `ingressUnknownDiscardedTotal`, **jamais** dans `ingressRejectedTotal` : sinon
308
+ chaque déploiement déclencherait de fausses alertes d'injection, et une vraie attaque passerait
309
+ inaperçue dans ce bruit. Le journal le signale une seule fois par motif, en `INFO`, sans recopier le
310
+ nom reçu.
311
+
312
+ Il monte pendant un déploiement, puis s'arrête quand tous les serveurs portent la même version. S'il
313
+ continue de monter ensuite, un serveur est resté sur une version plus ancienne. Ce qui reste
314
+ suspect reste compté comme une injection : un autre canal de la plateforme, une variante de casse du
315
+ territoire, un ordre d'un type connu mais de forme fausse, ou qui vise un canal de la plateforme.
316
+
317
+ Un message **mal scellé** n'entre pas dans ce compteur : le transport l'écarte avant le hub, et le
318
+ compte à part dans `sealRejectedTotal` (relayé au même niveau de la sonde, et dans
319
+ `backplane.sealRejectedTotal`). Quand celui-là grimpe, trois lectures : une rotation du secret en
320
+ cours (des exemplaires n'ont pas encore le même), un producteur mal configuré, ou quelqu'un qui
321
+ écrit sur votre bus sans connaître le secret.
322
+
323
+ ### Restés sur ce serveur — l'oubli de diffusion rendu visible
324
+
325
+ `localOnlyTotal` compte les publications restées sur **ce** serveur alors qu'un bus est branché :
326
+ leur canal n'est pas déclaré diffusable (`@RealtimeBroadcast`). Sans bus, il vaut toujours zéro —
327
+ la question ne se pose pas. Coût : un `++` sur un chemin déjà froid, aucune allocation.
328
+
329
+ Deux lectures quand il grimpe :
330
+
331
+ - **voulue** — un canal propre au serveur par nature : le forage de Studio, la barre de débogage,
332
+ la sonde d'un exemplaire ;
333
+ - **oubli** — un canal applicatif (`chat:`, `alertes:`) dont les abonnés des AUTRES serveurs ne
334
+ reçoivent rien, sans erreur. Ajouter `@RealtimeBroadcast("chat:")` sur son contrôleur.
335
+
336
+ Au démarrage, la ligne `realtime diffusion entre serveurs : …` liste les préfixes diffusés (le
337
+ canal personnel `user:` y figure d'office dès qu'un bus est branché) : tout ce qui n'y est pas
338
+ reste local.
339
+
340
+ ### Admission — les refus par cause
341
+
342
+ Quatre compteurs monotones disent POURQUOI un abonné n'est pas entré, quand la cause n'est pas son
343
+ droit. Un refus de droit (`forbidden`) n'est pas compté ici : c'est le comportement normal d'un canal
344
+ gardé. Ce que comptent ceux-ci, c'est une infrastructure qui ne suit plus, ou une configuration
345
+ incomplète (`nodefony/src/realtime/platformHealth.ts#IRealtimeProbe`).
346
+
347
+ <!-- prettier-ignore -->
348
+ | Champ | Unité | Ce qu'il compte | Quand il monte |
349
+ | --- | --- | --- | --- |
350
+ | `authenticateTimedOutTotal` | une poignée de main | connexions fermées en `1013` faute d'authentification dans `authenticateTimeoutMs` | la base ou le service qui porte les identités ne répond plus ; les clients reculent puis reviennent |
351
+ | `allowTimedOutTotal` | un appel d'`allow` | règles d'admission restées sans réponse dans `allowTimeoutMs` ; l'abonné a reçu `unavailable` et réessaie | la base que lisent les règles ne suit plus ; 50 en 5 s ouvrent le coupe-circuit |
352
+ | `allowSaturatedTotal` | une demande | demandes rendues `unavailable` SANS appeler la règle : plafond `maxPendingAllowsTotal` atteint, coupe-circuit ouvert, ou délai épuisé dans la file d'une connexion | une tempête de reconnexion, ou une base lente qui fait déborder les places |
353
+ | `systemFloorDeniedTotal` | un abonnement | canaux de plateforme (`syslog:`, `orm:`…) refusés faute de module de sécurité chargé | une configuration incomplète autant qu'une tentative d'accès — dans les deux cas, quelque chose est à regarder |
354
+
355
+ Les trois premiers vont ensemble : `allowTimedOutTotal` qui monte puis `allowSaturatedTotal` qui
356
+ s'envole, c'est la base qui a cessé de répondre et le serveur qui se protège — la base reçoit au plus
357
+ `maxPendingAllowsTotal` appels à la fois, jamais une rafale proportionnelle au nombre de clients. Un
358
+ `WARNING` limité à un par fenêtre de 10 s accompagne chaque famille dans le journal. Les réglages et
359
+ leur raison : [`configuration.md`](./configuration.md) ; le comportement vu par l'abonné :
360
+ [`securite.md`](./securite.md).
361
+
362
+ ### Trames écartées à l'entrée
363
+
364
+ Deux compteurs monotones disent combien de trames reçues n'ont pas pu être lues. Elles ne reçoivent
365
+ aucune réponse et la connexion reste ouverte : répondre à une trame illisible offrirait à un client
366
+ défectueux une boucle, et à un curieux un écho de ce que le décodeur accepte. Le journal en dit la
367
+ première de chaque connexion, jamais les suivantes.
368
+
369
+ <!-- prettier-ignore -->
370
+ | Champ | Unité | Ce qu'il compte | Quand il monte |
371
+ | --- | --- | --- | --- |
372
+ | `binaryDiscardedTotal` | une trame reçue | trames binaires reçues sur une connexion texte dont le contrôleur ne lit pas les octets bruts (`onRealtimeBinary`) | un client envoie un flux que personne n'attend, ou le lecteur a été oublié côté serveur |
373
+ | `malformedDiscardedTotal` | une trame reçue | trames illisibles pour le codec de la connexion : JSON invalide, CBOR tronqué | un client défectueux, ou quelqu'un qui sonde le décodeur |
302
374
 
303
375
  ### Connexions et volume
304
376
 
305
377
  `connectionCount`, `bytesSentTotal`, `messagesSentTotal` — vivants pour la première, cumulés pour les
306
- deux autres. Le débit en octets par seconde se dérive de deux photos.
378
+ deux autres, connexions fermées comprises. Le débit en octets par seconde se dérive de deux photos.
307
379
 
308
380
  ### Back-pressure — le risque numéro un
309
381
 
@@ -353,12 +425,15 @@ Trois champs n'apparaissent que lorsqu'ils ont un sens :
353
425
  signale une perte **volontaire** : la mémoire du processus a été préservée au prix de messages non
354
426
  relayés aux autres répliques. Il doit rester à zéro ; s'il bouge, regarder l'état du bus avant de
355
427
  relever le plafond (`backplane.maxQueueBytes`) — un plafond plus haut ne rattrape pas un bus en
356
- retard, il repousse l'échéance.
428
+ retard, il repousse l'échéance. Les **ordres** (terminer un canal, éjecter un compte) ne sont
429
+ jamais abandonnés pour faire place : ils passent hors plafond. Un ordre que le bus refuse (bus
430
+ injoignable) est compté à part, `controlLostTotal`, et annoncé une fois par épisode — s'il bouge,
431
+ une éjection n'a pas été appliquée par les autres répliques.
357
432
 
358
433
  ### La couche d'identité, au-dessus
359
434
 
360
435
  `buildOwnHealth()` (`RealtimeAdminApi.ts#buildOwnHealth`) enrichit ce snapshot pur pour produire
361
- `IRealtimeHealth` (`IRealtimeProbe.ts#IRealtimeHealth`) : `instanceId`, la santé **process** du worker
436
+ `IRealtimeHealth` (`nodefony/src/realtime/platformHealth.ts#IRealtimeHealth`) : `instanceId`, la santé **process** du worker
362
437
  (CPU, mémoire, boucle d'événements), et — si elles ont été branchées — la santé ORM et les compteurs
363
438
  d'erreurs du journal. Ces trois derniers champs sont **additifs et optionnels** : un consommateur qui
364
439
  ne les connaît pas les ignore.
@@ -475,10 +550,11 @@ mérite d'être connue :
475
550
  | ---------------------------------------------------------------- | ----------- | -------------------------------------------------------------------------- |
476
551
  | `publishTotal`, `fanoutTotal`, `connectionCount`, octets, frames | **somme** | Le pod fait la somme de ce que font ses workers. |
477
552
  | `ingressRejectedTotal` | somme | Un refus reste un refus, quel que soit le worker qui l'a opposé. |
553
+ | `localOnlyTotal` | somme | Une publication restée locale l'est sur le worker qui l'a émise. |
478
554
  | `backpressure.maxBufferedAmount` | **maximum** | La santé d'une flotte se juge sur son **pire** membre, pas sur sa moyenne. |
479
555
  | `slowConsumers`, `drops` | somme | Ce sont des dénombrements. |
480
556
 
481
- Le discriminant est le champ `cluster: true` de `IRealtimeClusterHealth` (`IRealtimeProbe.ts#IRealtimeClusterHealth`) :
557
+ Le discriminant est le champ `cluster: true` de `IRealtimeClusterHealth` (`nodefony/src/realtime/platformHealth.ts#IRealtimeClusterHealth`) :
482
558
  un consommateur sait immédiatement s'il lit une vue pod ou une vue per-instance, et `instances[]`
483
559
  garde le détail par worker pour le forage.
484
560
 
package/docs/protocole.md CHANGED
@@ -274,6 +274,18 @@ production** (`welcomeEnv()`, `welcomeEnv.ts#welcomeEnv`) : c'est lui que porte
274
274
  en développement. Émis par le contrôleur au handshake (`peer.notify("realtime:welcome")`,
275
275
  `RealtimeController.ts#RealtimeController.onHandshake`). Il n'y a pas de champ `version`.
276
276
 
277
+ Un septième champ, **`codec`**, est réservé au codec de trame retenu pour les frames qui suivent
278
+ (`RealtimeEventMap.ts#IRealtimeWelcome.codec`, ADR-0022 §2) : un des sous-protocoles que le client a
279
+ offerts au handshake. **Absent, il vaut `nodefony.jsonrpc.v1`** — JSON-RPC texte —, et le codec ne se
280
+ déduit jamais de l'écho `Sec-WebSocket-Protocol`, que `ws` renvoie qu'il parle le sous-protocole ou
281
+ non. Le client sait l'offrir et l'adopter (`frameCodecNegotiation.ts#adoptFrameCodec`) ; le serveur le
282
+ choisit quand l'application active `codec: "cbor"`, et renvoie ce choix dans le handshake.
283
+
284
+ Un huitième, **`bytes`**, dit qu'une connexion restée en texte reçoit les octets sous la convention
285
+ `{"$bytes":"<base64url>"}` (`RealtimeEventMap.ts#IRealtimeWelcome.bytes`) : le serveur l'émet quand il a
286
+ activé un codec binaire que le client n'a pas pris, et le client relit alors des `Uint8Array` sans rien
287
+ importer. Absent : JSON natif. La valeur nomme la convention ; une valeur inconnue laisse le JSON natif.
288
+
277
289
  ## 🔌 Anatomie d'une frame, champ par champ
278
290
 
279
291
  ### Le tronc commun
@@ -282,7 +294,7 @@ en développement. Émis par le contrôleur au handshake (`peer.notify("realtime
282
294
  {
283
295
  "jsonrpc": "2.0", // signature de protocole — OBLIGATOIRE, sinon la frame est `invalid`
284
296
  "method": "subscribe", // ce qu'on demande — sa PRÉSENCE fait de la frame un appel
285
- "params": { "channel": "nodefony:orm:health" }, // la charge ; forme libre, jamais fiable côté serveur
297
+ "params": { "channel": "nodefony:orm:health" }, // la charge : objet ou tableau, jamais fiable côté serveur
286
298
  "id": 42, // PRÉSENT = requête (réponse due) · ABSENT = notification
287
299
  }
288
300
  ```
@@ -291,9 +303,25 @@ en développement. Émis par le contrôleur au handshake (`peer.notify("realtime
291
303
  | --------- | ------------------------- | -------------------- | ----------------------------------------------------------------------------------------- |
292
304
  | `jsonrpc` | oui, toujours | `"2.0"` littéral | Sans lui, `JsonRpcPeer.receive()` classe `invalid` (`JsonRpcPeer.ts#JsonRpcPeer.receive`) |
293
305
  | `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 |
306
+ | `params` | non | objet ou tableau | Charge applicative. Vient du réseau : **jamais** digne de confiance |
295
307
  | `id` | sur une requête / réponse | nombre **ou** chaîne | Corrèle l'aller et le retour |
296
308
 
309
+ > [!IMPORTANT]
310
+ > **La charge d'un canal est un objet ou un tableau, ou absente.** JSON-RPC 2.0 (§4.2) exige un
311
+ > `params` structuré : une bibliothèque JSON-RPC stricte, dans un autre langage, refuserait la trame
312
+ > d'un scalaire. `publish` lève donc une `TypeError` qui nomme le canal et le type reçu, à la ligne
313
+ > fautive, des deux côtés de la socket (`payload.ts#isRealtimePayload`) :
314
+ >
315
+ > ```ts
316
+ > const temperature = 42;
317
+ > hub.publish("capteur:temp", temperature); // TypeError — un scalaire
318
+ > hub.publish("capteur:temp", { value: temperature }); // conforme
319
+ > hub.publish("build:done"); // conforme : part sans `params`, arrive `undefined`
320
+ > ```
321
+ >
322
+ > Des octets restent admis. Une charge absente arrive `undefined` chez l'abonné, quel que soit le bus
323
+ > entre les serveurs, scellé ou non.
324
+
297
325
  > [!IMPORTANT]
298
326
  > Le pair accepte un `id` **chaîne** en entrée (conforme à la norme), mais il n'attribue jamais que
299
327
  > des `id` **numériques** à ses propres appels sortants. Une réponse portant un `id` chaîne est donc
@@ -334,6 +362,60 @@ obtenue par la socket est **identique** à ce que rendrait la même route en RES
334
362
  dans le `result` casserait cette égalité. Un pair qui ne connaît pas `meta` l'ignore — le champ est
335
363
  rétro-compatible par construction.
336
364
 
365
+ ### Des octets dans la charge — une frame comme les autres
366
+
367
+ **Une trame binaire routée est une notification JSON-RPC dont la charge est, ou contient, un
368
+ `Uint8Array` ; le codec dit seulement comment il la porte sur le fil** (ADR-0022 §3). Il n'existe
369
+ pas de seconde grammaire, ni d'en-tête d'octets maison du type `[n° de canal][charge]` : un tel
370
+ en-tête aurait demandé un second verrou et une seconde sonde, qui finissent toujours par diverger
371
+ du premier.
372
+
373
+ ```ts
374
+ // côté serveur — la même publication, quel que soit le codec de chaque abonné
375
+ const frame = new Uint8Array([0x01, 0x02, 0x03]);
376
+ hub.publish("capteur:raw", { seq: 42, frame });
377
+ ```
378
+
379
+ | Connexion de l'abonné | Ce qui passe sur le fil | Ce que reçoit `on("capteur:raw")` |
380
+ | --------------------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
381
+ | CBOR | trame binaire : `frame` en chaîne d'octets native (type majeur 2) | `{ seq: 42, frame: Uint8Array }` |
382
+ | texte, serveur en `codec: "cbor"` | trame texte : `"frame":{"$bytes":"AQID"}` (base64url) | `{ seq: 42, frame: Uint8Array }` |
383
+ | texte, serveur en `codec: "json"` | trame texte, sans convention d'octets : `"frame":{"0":1,"1":2,"2":3}` | un objet déformé, plus un `Uint8Array` (`jsonFrameCodec.ts#JSON_FRAME_CODEC`) — d'où `codec: "cbor"` dès que des octets circulent |
384
+
385
+ Parce que c'est la même frame, elle passe par **les mêmes portes** que le texte : verrou de frame,
386
+ politique de canal, audit des refus, sonde du hub ([sécurité](./securite.md)). Le codec se négocie
387
+ au handshake et s'annonce dans l'accueil (champs `codec` et `bytes`, voir plus haut) ; il se règle
388
+ côté serveur par la clé [`codec`](./configuration.md#codec--la-forme-des-frames-sur-le-fil), côté
389
+ navigateur par l'option `codec` de la socket.
390
+
391
+ **La règle du `$bytes` forgé.** Sur une connexion texte, le récepteur relit en octets tout objet
392
+ dont la **seule** clé est `$bytes` et dont la valeur est une chaîne. Un objet applicatif qui a
393
+ exactement cette forme sans venir d'octets changerait donc de nature selon le codec de l'abonné :
394
+ objet pour un client CBOR, octets pour un client texte. L'encodeur texte le **refuse** plutôt que
395
+ d'envoyer une donnée qui serait mal relue (`jsonFrameCodec.ts#JSON_BYTES_FRAME_CODEC`) :
396
+
397
+ ```ts ignore
398
+ socket.publish("prix", { montant: { $bytes: "12 euros" } });
399
+ // TypeError : codec JSON : objet {"$bytes": …} non issu d'octets sous la clé "montant"
400
+ // — le récepteur le relirait en octets ; envoyer un Uint8Array, ou ajouter une autre clé à l'objet
401
+
402
+ socket.publish("prix", { montant: { $bytes: "12 euros", devise: "EUR" } }); // conforme : deux clés
403
+ ```
404
+
405
+ - **Côté navigateur**, l'émission lève à la ligne fautive.
406
+ - **Côté serveur**, une publication diffusée qui contient un tel objet n'atteint pas les abonnés
407
+ texte ; une seule ligne `ERROR` est écrite par publication, pas une par abonné
408
+ (`RealtimeHub.ts#ChannelFrame.claimFailure`). La clé citée est tronquée à 64 caractères et
409
+ échappée, pour qu'un client ne puisse pas forger de ligne de journal (CWE-117).
410
+ - **Limite assumée** : un tel objet **conservé** par l'application, par exemple l'état initial
411
+ qu'un canal renvoie à chaque nouvel abonné, sera refusé aux abonnés texte **à chaque envoi**.
412
+ Renommer la clé ou en ajouter une seconde règle le cas une fois pour toutes.
413
+
414
+ Le refus est préféré à un échappement (envelopper l'objet dans une seconde clé réservée) : échapper
415
+ coûterait un parcours de chaque trame, et imposerait une seconde convention aux deux côtés du fil.
416
+ Le refus ne coûte qu'une recherche de `$bytes` dans le texte déjà produit. Il ne vaut que sous la
417
+ convention d'octets : avec `codec: "json"`, `$bytes` est une clé comme une autre.
418
+
337
419
  ## 🧰 Les méthodes nominales
338
420
 
339
421
  Ce que le serveur comprend **sans qu'aucun contrôleur ne l'ait déclaré**, plus les deux formes
@@ -381,7 +463,7 @@ sequenceDiagram
381
463
  participant P as JsonRpcPeer (serveur)
382
464
  participant H as RealtimeHub
383
465
  C->>P: handshake WebSocket
384
- P-->>C: realtime:welcome — ts, protocol, channels, methods, identity
466
+ P-->>C: realtime:welcome — ts, protocol, channels, methods, identity, codec?, bytes?
385
467
  C->>P: subscribe — params.channel = "nodefony:orm:health"
386
468
  Note over P,H: le hub crée le producteur au 1er abonné
387
469
  P-->>C: nodefony:orm:health — params { queries: 42 }
@@ -402,16 +484,16 @@ Voici **tout** ce que le moteur produit. Les codes de la plage `-32000`…`-3209
402
484
  la norme aux erreurs applicatives serveur ; Nodefony y place son refus d'autorisation et le défaut
403
485
  de `RpcError`.
404
486
 
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 |
487
+ | Code | Émis par | Déclencheur | Ce que voit le pair |
488
+ | -------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
489
+ | `-32601` | `JsonRpcPeer.handleRequest()` (`JsonRpcPeer.ts#JsonRpcPeer.handleRequest`) | requête vers une action non enregistrée | `method not found: <nom>` |
490
+ | `-32603` | même méthode (`JsonRpcPeer.ts#JsonRpcPeer.handleRequest`) | le handler a levé une exception **ordinaire** | `internal error` — générique, rien d'autre |
491
+ | `-32001` | le refus du verrou de frame (`JsonRpcPeer.ts#JsonRpcPeer.receive`) | `beforeDispatch` a dit non **sur une requête** | `unauthorized`, sans jamais dire pourquoi |
492
+ | `-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 |
493
+ | `-32602` | le pont API, via `RpcError` (`RealtimeController.ts#RealtimeController.actionParamsWrapper`) | `api.request` appelé avec un `params.path` invalide | message explicite (l'appel est malformé) |
494
+ | `-32000` | le pont API (`RealtimeController.ts#RealtimeController.invokeApiRequest`) | la route invoquée REFUSE : 401, 403, 404, 405, **422**, 429 | le message, `data.status` — `data.fields` sur un 422, `data.retryAfter` si l'action a posé `Retry-After` |
413
495
 
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`).
496
+ 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. Un refus passager (429, 503) dont l'action a posé `Retry-After` (RFC 9110 §10.2.3) porte l'attente dans `data.retryAfter`, en secondes — une date HTTP y est ramenée à l'écart : une socket ouverte n'a plus d'en-tête, et le même code de contrôleur (`this.response?.setHeader("Retry-After", n)` puis le refus) sert les deux transports. Seul cet en-tête traverse : rien d'autre posé par l'action ne fuit sur une trame. Tout autre `throw` reste opaque (`-32603`).
415
497
 
416
498
  Et un échec qui n'est **pas** une frame : l'expiration. `startCall()` ne reçoit rien dans le délai
417
499
  imparti, supprime l'entrée en attente et rejette localement avec `RPC timeout: <méthode>`
@@ -443,7 +525,7 @@ qui alimente le journal d'audit avec le pair concerné.
443
525
  ### Le refus d'une notification n'est pas une erreur
444
526
 
445
527
  C'est la subtilité la plus importante de la page. Le verrou de frame s'applique aux requêtes **et**
446
- aux notifications (`beforeDispatch`, `JsonRpcPeer.ts#JsonRpcPeerOptions.beforeDispatch`), mais leurs conséquences diffèrent
528
+ aux notifications (`beforeDispatch`, `JsonRpcPeer.ts#IJsonRpcPeerHooks.beforeDispatch`), mais leurs conséquences diffèrent
447
529
  radicalement :
448
530
 
449
531
  | La frame refusée est… | Ce qui part | Pourquoi |
@@ -453,11 +535,25 @@ radicalement :
453
535
 
454
536
  `IRealtimeDenied` (`RealtimeEventMap.ts#IRealtimeDenied`) porte `channel` et `reason` — plus un `detail` optionnel, posé hors production seulement — et le motif
455
537
  est **générique**. Jamais « il te manque `ROLE_ADMIN` » : ce serait un oracle d'autorisation, un
456
- attaquant y lirait la carte des droits. Trois motifs circulent : `forbidden` (le verrou a dit non),
457
- `limit` (le plafond de canaux de la connexion est atteint, `RealtimeController.ts#RealtimeController.onRealtimeNotification`) et `unknown`
458
- (aucun producteur ne sert ce nom — jamais un oracle : un canal gardé rend `forbidden`, qu'il existe ou
459
- non). Côté client,
460
- `onDenied()` (`NodefonySocket.ts#NodefonySocket.onDenied`) branche un handler dessus.
538
+ attaquant y lirait la carte des droits. Quatre motifs circulent, et l'union est FERMÉE
539
+ (`RealtimeEventMap.ts#RealtimeDeniedReason`) — un motif inconnu reçu d'un serveur plus récent est
540
+ replié sur `forbidden` :
541
+
542
+ <!-- prettier-ignore -->
543
+ | Motif | Sens | Le client réessaie ? |
544
+ | --- | --- | --- |
545
+ | `forbidden` | le verrou ou la règle d'admission `allow` a dit non | jamais — le droit ne changera pas en réessayant |
546
+ | `limit` | le plafond de canaux de la connexion est atteint (`RealtimeController.ts#RealtimeController.onRealtimeNotification`) | jamais |
547
+ | `unknown` | aucun producteur ne sert ce nom — jamais un oracle : un canal gardé rend `forbidden`, qu'il existe ou non | jamais |
548
+ | `unavailable` | la règle `allow` n'a pas répondu à temps, ou le serveur protège sa base (plafond, coupe-circuit) | **oui**, le seul : trois fois au plus |
549
+
550
+ `unavailable` peut porter `retryAfterMs` : le délai, en millisecondes, pendant lequel le serveur
551
+ n'appellera plus la règle (coupe-circuit ouvert). Le client attend ce délai, puis ajoute un recul
552
+ exponentiel à **gigue complète** — un tirage uniforme entre zéro et 1 s, 2 s, puis 4 s — pour que
553
+ mille clients refusés au même instant ne reviennent pas au même instant. Il ne rejoue que le
554
+ `subscribe` concerné, jamais la connexion. Côté client,
555
+ `onDenied()` (`NodefonySocket.ts#NodefonySocket.onDenied`) branche un handler dessus ; il ne voit que le
556
+ refus DÉFINITIF, après le dernier réessai.
461
557
 
462
558
  ### L'abonnement accepté s'acquitte
463
559
 
@@ -481,6 +577,18 @@ antérieur — répute l'abonnement actif dès le rejeu, au lieu d'attendre pour
481
577
  > n'est réservé par aucune norme. Le refus d'autorisation est `-32001` sur une requête, et une
482
578
  > notification `realtime:denied` sur une notification.
483
579
 
580
+ ### La fermeture qui dit « reviens plus tard »
581
+
582
+ Une frame refusée laisse la connexion ouverte ; certaines causes, elles, la ferment. Le code de
583
+ fermeture dit alors ce que le client doit faire (`closeCodes.ts`, le registre complet est dans
584
+ [`securite.md`](./securite.md)). Un seul est une invitation à revenir : `1013` « try again later »
585
+ (RFC 6455 §7.4.1). Le serveur l'émet quand l'authentification n'a pas répondu à temps à la poignée
586
+ de main (`authenticateTimeoutMs`), ou quand le client ne vide plus sa file d'envoi. Le client le
587
+ range parmi les fermetures reconnectables (`notice.ts#isReconnectableCloseCode`) : il recule, puis
588
+ rouvre une connexion neuve, rejoue son `welcome` et ses abonnements. Un navigateur ne peut pas
589
+ émettre `1013` lui-même (`WebSocket.close()` n'accepte que `1000` et `3000`-`4999`) : c'est
590
+ toujours le serveur qui ferme.
591
+
484
592
  ## 🔐 Ce que le protocole ne décide pas
485
593
 
486
594
  Le protocole transporte et classe. Il ne décide ni de l'identité, ni des droits — trois règles qui
@@ -501,21 +609,22 @@ décrits dans [la page sécurité](./securite.md).
501
609
 
502
610
  ## ⚠️ Pièges
503
611
 
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à |
612
+ | Symptôme | Cause | Correction |
613
+ | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
614
+ | `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)` |
615
+ | 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 |
616
+ | `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 |
617
+ | 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 |
618
+ | 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 |
619
+ | Une trame binaire envoyée au client n'arrive à aucun handler | Sur une connexion JSON-RPC texte, une trame binaire n'a pas de canal : 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 ; des octets routés par canal passent par un codec binaire (`codec`) |
620
+ | 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 | Des octets **par canal** : `codec: "cbor"` et `publish`. Un protocole binaire propre à l'application : surcharger `onRealtimeBinary()` (`RealtimeController.ts#RealtimeController.onRealtimeBinary`), émettre par `sendBinaryToClient()` — ⚠️ hors du verrou de frame et des gardes d'action et de canal : toute décision d'accès sur ces octets appartient à l'application |
621
+ | `TypeError : codec JSON : objet {"$bytes": …} non issu d'octets` | Un objet applicatif a la forme exacte du marqueur d'octets ; un client texte le relirait en octets (`jsonFrameCodec.ts#JSON_BYTES_FRAME_CODEC`) | Envoyer un vrai `Uint8Array`, renommer la clé, ou en ajouter une seconde — [la règle](#des-octets-dans-la-charge--une-frame-comme-les-autres) |
622
+ | 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` |
623
+ | Une notification refusée disparaît sans trace côté client | Sans `id`, aucune réponse possible (`beforeDispatch`, `JsonRpcPeer.ts#IJsonRpcPeerHooks.beforeDispatch`) | Écouter `realtime:denied` via `onDenied()` (`NodefonySocket.ts#NodefonySocket.onDenied`) |
624
+ | `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 |
625
+ | 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`) |
626
+ | 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 |
627
+ | 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à |
519
628
 
520
629
  ## 🧪 Tests & couverture
521
630