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

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 (78) hide show
  1. package/README.md +50 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +79 -12
  4. package/dist/nodefony/config/config.js +7 -0
  5. package/dist/nodefony/decorators/realtimeDecorators.js +125 -19
  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 +143 -31
  9. package/dist/nodefony/src/backplane/controlOrder.js +57 -0
  10. package/dist/nodefony/src/backplane/envelope.js +113 -12
  11. package/dist/nodefony/src/cluster/ClusterProbeClient.js +2 -0
  12. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
  13. package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
  14. package/dist/nodefony/src/server/RealtimeConnection.js +110 -0
  15. package/dist/nodefony/src/server/RealtimeController.js +340 -81
  16. package/dist/nodefony/src/server/RealtimeHub.js +604 -61
  17. package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
  18. package/dist/nodefony/src/server/admissionBudget.js +176 -0
  19. package/dist/nodefony/src/server/channelPolicyRegistry.js +73 -0
  20. package/dist/nodefony/src/server/connectionAdmission.js +242 -0
  21. package/dist/nodefony/src/server/contractRoutes.js +85 -0
  22. package/dist/nodefony/src/server/deniedDetail.js +10 -3
  23. package/dist/nodefony/src/server/frameAdmission.js +168 -0
  24. package/dist/nodefony/src/server/subscribedAck.js +10 -1
  25. package/dist/nodefony/src/server/syslogUplink.js +3 -4
  26. package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
  27. package/dist/nodefony/src/service/RealtimeService.js +42 -1
  28. package/dist/nodefony/src/service/publicOrigin.js +40 -0
  29. package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
  30. package/dist/nodefony/testing/backplaneConformance.js +271 -0
  31. package/dist/nodefony/testing/index.js +21 -4
  32. package/dist/types/index.d.ts +34 -7
  33. package/dist/types/nodefony/config/config.d.ts +9 -0
  34. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +86 -6
  35. package/dist/types/nodefony/interfaces/IBackplane.d.ts +34 -50
  36. package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
  37. package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
  38. package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
  39. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
  40. package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
  41. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
  42. package/dist/types/nodefony/interfaces/index.d.ts +2 -1
  43. package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
  44. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +79 -16
  45. package/dist/types/nodefony/src/backplane/controlOrder.d.ts +52 -0
  46. package/dist/types/nodefony/src/backplane/envelope.d.ts +34 -0
  47. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +10 -0
  48. package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
  49. package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +17 -0
  50. package/dist/types/nodefony/src/server/RealtimeController.d.ts +91 -11
  51. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +321 -24
  52. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
  53. package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
  54. package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +34 -0
  55. package/dist/types/nodefony/src/server/connectionAdmission.d.ts +68 -0
  56. package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
  57. package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
  58. package/dist/types/nodefony/src/server/frameAdmission.d.ts +70 -0
  59. package/dist/types/nodefony/src/server/subscribedAck.d.ts +7 -0
  60. package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
  61. package/dist/types/nodefony/src/service/RealtimeService.d.ts +10 -2
  62. package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
  63. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
  64. package/dist/types/nodefony/testing/backplaneConformance.d.ts +66 -0
  65. package/dist/types/nodefony/testing/index.d.ts +12 -1
  66. package/docs/actions.md +419 -18
  67. package/docs/architecture.md +83 -8
  68. package/docs/cadence-et-contre-pression.md +87 -11
  69. package/docs/canaux-a-membres.md +267 -0
  70. package/docs/cas-d-usage.md +1702 -0
  71. package/docs/configuration.md +137 -17
  72. package/docs/cookbook-chat.md +314 -85
  73. package/docs/index.md +11 -1
  74. package/docs/observabilite.md +71 -14
  75. package/docs/protocole.md +133 -24
  76. package/docs/securite.md +284 -32
  77. package/docs/vocabulaire.md +13 -12
  78. package/package.json +14 -11
@@ -247,17 +247,17 @@ 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
 
@@ -267,6 +267,8 @@ Le contrat est `IRealtimeProbe` (`IRealtimeProbe.ts#IRealtimeProbe`). Quatre fam
267
267
  | `fanoutTotal` | Livraisons effectives (`publish` × abonnés). **Ce que ça a réellement coûté.** |
268
268
  | `inboundTotal` | Frames poussées par les clients sur les canaux entrants, via `RealtimeHub.recordInbound()` (`RealtimeHub.ts#RealtimeHub.recordInbound`). |
269
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
+ | `localOnlyTotal` | Publications restées sur ce serveur alors qu'un bus est branché (canal non diffusable). `0` sans bus. |
270
272
 
271
273
  Le rapport `fanoutTotal / publishTotal` est la mesure la plus parlante de la page : c'est la **taille
272
274
  moyenne d'un salon**. S'il vaut 1, chaque publication ne touche qu'une personne — un canal par client,
@@ -296,14 +298,68 @@ lectures possibles quand il grimpe :
296
298
  Le canal visé n'est délibérément pas exposé : un compteur d'observabilité ne doit pas devenir un
297
299
  moyen de sonder ce que le système accepte.
298
300
 
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.
301
+ Un message **mal scellé** n'entre pas dans ce compteur : le transport l'écarte avant le hub, et le
302
+ compte à part dans `sealRejectedTotal` (relayé au même niveau de la sonde, et dans
303
+ `backplane.sealRejectedTotal`). Quand celui-là grimpe, trois lectures : une rotation du secret en
304
+ cours (des exemplaires n'ont pas encore le même), un producteur mal configuré, ou quelqu'un qui
305
+ écrit sur votre bus sans connaître le secret.
306
+
307
+ ### Restés sur ce serveur — l'oubli de diffusion rendu visible
308
+
309
+ `localOnlyTotal` compte les publications restées sur **ce** serveur alors qu'un bus est branché :
310
+ leur canal n'est pas déclaré diffusable (`@RealtimeBroadcast`). Sans bus, il vaut toujours zéro —
311
+ la question ne se pose pas. Coût : un `++` sur un chemin déjà froid, aucune allocation.
312
+
313
+ Deux lectures quand il grimpe :
314
+
315
+ - **voulue** — un canal propre au serveur par nature : le forage de Studio, la barre de débogage,
316
+ la sonde d'un exemplaire ;
317
+ - **oubli** — un canal applicatif (`chat:`, `alertes:`) dont les abonnés des AUTRES serveurs ne
318
+ reçoivent rien, sans erreur. Ajouter `@RealtimeBroadcast("chat:")` sur son contrôleur.
319
+
320
+ Au démarrage, la ligne `realtime diffusion entre serveurs : …` liste les préfixes diffusés (le
321
+ canal personnel `user:` y figure d'office dès qu'un bus est branché) : tout ce qui n'y est pas
322
+ reste local.
323
+
324
+ ### Admission — les refus par cause
325
+
326
+ Quatre compteurs monotones disent POURQUOI un abonné n'est pas entré, quand la cause n'est pas son
327
+ droit. Un refus de droit (`forbidden`) n'est pas compté ici : c'est le comportement normal d'un canal
328
+ gardé. Ce que comptent ceux-ci, c'est une infrastructure qui ne suit plus, ou une configuration
329
+ incomplète (`nodefony/src/realtime/platformHealth.ts#IRealtimeProbe`).
330
+
331
+ <!-- prettier-ignore -->
332
+ | Champ | Unité | Ce qu'il compte | Quand il monte |
333
+ | --- | --- | --- | --- |
334
+ | `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 |
335
+ | `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 |
336
+ | `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 |
337
+ | `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 |
338
+
339
+ Les trois premiers vont ensemble : `allowTimedOutTotal` qui monte puis `allowSaturatedTotal` qui
340
+ 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
341
+ `maxPendingAllowsTotal` appels à la fois, jamais une rafale proportionnelle au nombre de clients. Un
342
+ `WARNING` limité à un par fenêtre de 10 s accompagne chaque famille dans le journal. Les réglages et
343
+ leur raison : [`configuration.md`](./configuration.md) ; le comportement vu par l'abonné :
344
+ [`securite.md`](./securite.md).
345
+
346
+ ### Trames écartées à l'entrée
347
+
348
+ Deux compteurs monotones disent combien de trames reçues n'ont pas pu être lues. Elles ne reçoivent
349
+ aucune réponse et la connexion reste ouverte : répondre à une trame illisible offrirait à un client
350
+ défectueux une boucle, et à un curieux un écho de ce que le décodeur accepte. Le journal en dit la
351
+ première de chaque connexion, jamais les suivantes.
352
+
353
+ <!-- prettier-ignore -->
354
+ | Champ | Unité | Ce qu'il compte | Quand il monte |
355
+ | --- | --- | --- | --- |
356
+ | `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 |
357
+ | `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
358
 
303
359
  ### Connexions et volume
304
360
 
305
361
  `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.
362
+ deux autres, connexions fermées comprises. Le débit en octets par seconde se dérive de deux photos.
307
363
 
308
364
  ### Back-pressure — le risque numéro un
309
365
 
@@ -358,7 +414,7 @@ Trois champs n'apparaissent que lorsqu'ils ont un sens :
358
414
  ### La couche d'identité, au-dessus
359
415
 
360
416
  `buildOwnHealth()` (`RealtimeAdminApi.ts#buildOwnHealth`) enrichit ce snapshot pur pour produire
361
- `IRealtimeHealth` (`IRealtimeProbe.ts#IRealtimeHealth`) : `instanceId`, la santé **process** du worker
417
+ `IRealtimeHealth` (`nodefony/src/realtime/platformHealth.ts#IRealtimeHealth`) : `instanceId`, la santé **process** du worker
362
418
  (CPU, mémoire, boucle d'événements), et — si elles ont été branchées — la santé ORM et les compteurs
363
419
  d'erreurs du journal. Ces trois derniers champs sont **additifs et optionnels** : un consommateur qui
364
420
  ne les connaît pas les ignore.
@@ -475,10 +531,11 @@ mérite d'être connue :
475
531
  | ---------------------------------------------------------------- | ----------- | -------------------------------------------------------------------------- |
476
532
  | `publishTotal`, `fanoutTotal`, `connectionCount`, octets, frames | **somme** | Le pod fait la somme de ce que font ses workers. |
477
533
  | `ingressRejectedTotal` | somme | Un refus reste un refus, quel que soit le worker qui l'a opposé. |
534
+ | `localOnlyTotal` | somme | Une publication restée locale l'est sur le worker qui l'a émise. |
478
535
  | `backpressure.maxBufferedAmount` | **maximum** | La santé d'une flotte se juge sur son **pire** membre, pas sur sa moyenne. |
479
536
  | `slowConsumers`, `drops` | somme | Ce sont des dénombrements. |
480
537
 
481
- Le discriminant est le champ `cluster: true` de `IRealtimeClusterHealth` (`IRealtimeProbe.ts#IRealtimeClusterHealth`) :
538
+ Le discriminant est le champ `cluster: true` de `IRealtimeClusterHealth` (`nodefony/src/realtime/platformHealth.ts#IRealtimeClusterHealth`) :
482
539
  un consommateur sait immédiatement s'il lit une vue pod ou une vue per-instance, et `instances[]`
483
540
  garde le détail par worker pour le forage.
484
541
 
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 }
@@ -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