@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
@@ -23,7 +23,7 @@ tags:
23
23
  ]
24
24
  version: "doc"
25
25
  status: stable
26
- updated: 2026-07-19
26
+ updated: 2026-11-10
27
27
  source: "src/packages/@nodefony/realtime/docs/configuration.md"
28
28
  ---
29
29
 
@@ -281,13 +281,14 @@ regarder quand un message ne traverse pas.
281
281
 
282
282
  ## ⚙️ Le schéma, clé par clé
283
283
 
284
- Sept blocs, seize clés au total. Le tableau donne la vérité complète ; les sections qui suivent
284
+ Vingt et une clés au total. Le tableau donne la vérité complète ; les sections qui suivent
285
285
  expliquent quand et pourquoi en changer.
286
286
 
287
287
  <!-- prettier-ignore -->
288
288
  | Clé | Type | Défaut | Effet |
289
289
  | --- | --- | --- | --- |
290
290
  | `enabled` | `boolean` | `true` | `false` = module chargé mais inerte : ni API d'administration, ni backplane, ni sonde, ni garde |
291
+ | `codec` | `"json"` \| `"cbor"` | `"json"` | forme des frames sur le fil. `"cbor"` = accordé à tout client qui l'offre, les autres restent en texte avec la convention d'octets `$bytes` |
291
292
  | `backplane.driver` | `string` | `"loopback"` | nom résolu dans le registre de drivers ; inconnu → avertissement, le hub reste local |
292
293
  | `backplane.namespace` | `string?` | _absent_ → nom d'app | cloison du transport partagé ; suffixe le canal pub/sub. Motif `^[\w.-]+$` |
293
294
  | `backplane.secret` | `string?` (≥ 32) | _absent_ | scelle les messages du transport partagé (HMAC) ; identique sur tous les pods. Absent = bus ouvert + avertissement au boot |
@@ -295,9 +296,14 @@ expliquent quand et pourquoi en changer.
295
296
  | `cluster.probe.enabled` | `boolean` | `true` | branche la sonde agrégée du pod en worker de cluster. `false` = aucun minuteur, aucun IPC |
296
297
  | `slowConsumer.bytes` | `number` entier > 0 | `1048576` (1 MiB) | seuil de **comptage** des consommateurs lents dans la sonde. Observe, n'agit pas |
297
298
  | `limits.maxChannelsPerConnection` | `number > 0` \| `null` | `256` | plafond de canaux par connexion ; au-delà le `subscribe` est refusé. `null` = illimité |
299
+ | `authenticateTimeoutMs` | `number` entier > 0 | `10000` | délai de l'authentification à la poignée de main ; dépassé, fermeture `1013` et le client revient |
300
+ | `allowTimeoutMs` | `number` entier > 0 | `5000` | délai d'une règle d'admission `allow`, attente en file comprise ; dépassé, motif `unavailable` |
301
+ | `maxPendingAllowsPerConnection` | `number` entier > 0 | `8` | appels d'`allow` en vol pour une connexion ; au-delà la demande attend son tour |
302
+ | `maxPendingAllowsTotal` | `number` entier > 0 | `512` | appels d'`allow` en vol pour l'exemplaire ; au-delà `unavailable` sans appel |
298
303
  | `csrf.checkOrigin.enabled` | `boolean` | `false` | active le contrôle d'`Origin` à l'ouverture de la socket |
299
304
  | `csrf.checkOrigin.allowList` | `string[]` | `[]` | origines acceptées, **comparaison exacte**. Vide + activé = tout est refusé |
300
305
  | `csrf.checkOrigin.allowMissingOrigin` | `boolean` | `false` | accepter une ouverture sans en-tête `Origin` (clients non-navigateur) |
306
+ | `publicOrigin` | `string` (origine nue) | aucun | où la socket est servie quand ce n'est pas l'origine de la page ; déclarée seule dans `connect-src` |
301
307
  | `clientLogs.enabled` | `boolean` | `false` | accepte les journaux du navigateur sur le canal montant `nodefony:syslog:uplink`. Surface d'écriture, fermée par défaut ; une connexion anonyme ne pousse rien |
302
308
  | `clientLogs.maxEntriesPerBatch` | `number` | `50` | entrées acceptées par lot |
303
309
  | `clientLogs.maxEntriesPerWindow` | `number` | `300` | entrées acceptées par fenêtre de débit |
@@ -325,6 +331,47 @@ inerte (`RealtimeService.init()`, `RealtimeService.ts#RealtimeService.init`).
325
331
  Le bon usage : un environnement où le temps réel n'a pas de sens (une commande CLI, un banc). Pour
326
332
  désactiver vraiment, retire le module du manifeste.
327
333
 
334
+ ### `codec` — la forme des frames sur le fil
335
+
336
+ Le codec ne change pas ce qu'une frame **dit**, seulement la forme qu'elle prend sur le fil : c'est
337
+ toujours du JSON-RPC 2.0, écrit en texte ou en CBOR (ADR-0022 §2). Le défaut `"json"` est le bon
338
+ réglage tant que les charges sont des objets ordinaires. Passe à `"cbor"` dès que des **octets**
339
+ circulent dans un canal (document partagé, série de mesures, audio) :
340
+
341
+ ```ts ignore
342
+ // nodefony.config.ts
343
+ use("@nodefony/realtime", { codec: "cbor" });
344
+ ```
345
+
346
+ Ce que le serveur fait alors, connexion par connexion (`frameCodecNegotiation.ts#negotiateFrameCodec`) :
347
+
348
+ | Le client… | Fil retenu | Les octets d'une charge |
349
+ | ------------------------------------------------------------ | ----------------------------------------------- | --------------------------------------------------------------------- |
350
+ | offre `nodefony.jsonrpc.cbor.v1` (`codec: CBOR_FRAME_CODEC`) | CBOR, annoncé dans `realtime:welcome` (`codec`) | chaîne d'octets native, sans base64 |
351
+ | n'offre rien (client ancien, autre langage, `curl`) | JSON-RPC texte | `{"$bytes":"<base64url>"}`, annoncé par le champ `bytes` de l'accueil |
352
+
353
+ Aucun client ne casse : celui qui ne connaît pas CBOR reste en texte et reçoit les mêmes données.
354
+ Un canal suivi par les deux sortes de connexions paie **un encodage par codec**, jamais un par
355
+ abonné (`RealtimeHub.ts#ChannelFrame`).
356
+
357
+ Quatre conséquences à connaître avant d'activer :
358
+
359
+ - **Le flux binaire brut ne sert plus les connexions CBOR.** Sur une telle connexion, toute trame
360
+ binaire est une frame du codec : `onRealtimeBinary()` n'y est jamais appelée et
361
+ `sendBinaryToClient()` n'y écrit rien. Il ne sert plus que les connexions restées en texte.
362
+ - **Un objet applicatif de la forme exacte `{"$bytes": "<chaîne>"}` est refusé** à l'envoi vers une
363
+ connexion texte : le client le relirait en octets. La règle est détaillée dans
364
+ [Protocole](./protocole.md#des-octets-dans-la-charge--une-frame-comme-les-autres).
365
+ - **Le REST du plan d'administration prend la même convention.** Une réponse `GET
366
+ /nodefony/<module>/api/*` passe par le codec des connexions texte et porte l'en-tête
367
+ `x-nodefony-bytes: $bytes` (`AdminApiController.ts#AdminApiController.dispatch`). Un client la
368
+ relit par `textCodecForMarker(response.headers.get(BYTES_MARKER_HEADER))`, exporté par
369
+ `nodefony` : des octets y reviennent à l'identique, que la valeur arrive par `fetch` ou par la
370
+ socket. Sans l'en-tête, le corps est du JSON natif.
371
+ - **`NF_REALTIME_CODEC=json` remet le fil en texte** pour le lire dans l'inspecteur réseau du
372
+ navigateur. C'est une exception de débogage : en texte, la taille des trames et la contre-pression
373
+ changent, donc une mesure prise ainsi ne vaut pas pour la production.
374
+
328
375
  ### `backplane.driver` — qui relaie les publications
329
376
 
330
377
  C'est **le** réglage du module. Le champ est une chaîne libre et non une énumération, volontairement :
@@ -382,8 +429,15 @@ déploiement, passe-le par `NF_REALTIME_BACKPLANE_SECRET` (secret k8s, variable
382
429
  a la précédence sur la configuration, et un secret n'a rien à faire dans un fichier versionné.
383
430
 
384
431
  Sans secret, rien ne casse : le transport reste ouvert et le démarrage l'annonce en avertissement.
385
- La bonne façon de vérifier que le scellement fonctionne est le compteur `ingressRejectedTotal` de la
386
- sonde de santé — il reste à zéro en régime normal.
432
+ La bonne façon de vérifier que le scellement fonctionne est le compteur `sealRejectedTotal` de la
433
+ sonde de santé : il compte les messages reçus dont le sceau ne se vérifie pas, et reste à zéro en
434
+ régime normal. Chaque refus est aussi journalisé — le premier, puis un sur cent.
435
+
436
+ > [!WARNING]
437
+ > **Changer le secret, c'est couper le fan-out entre les deux générations.** Pendant une rotation, les
438
+ > exemplaires qui n'ont pas encore le même secret se refusent leurs messages : `sealRejectedTotal`
439
+ > monte sur chacun d'eux jusqu'à ce que tous portent le nouveau. Un second secret accepté en
440
+ > lecture, pour une rotation sans coupure, viendra dans une version 10.x.
387
441
 
388
442
  > [!NOTE]
389
443
  > Le driver `cluster` (IPC entre les workers d'un même pod) n'a pas besoin de secret : ce canal ne
@@ -484,6 +538,43 @@ les clients — un tableau de bord interne qui compose des dizaines de flux, par
484
538
  > Le plafond est **par connexion**, pas global. Cent connexions à 256 canaux restent possibles :
485
539
  > c'est une garde contre un client abusif, pas contre une charge légitime mal dimensionnée.
486
540
 
541
+ ### Les délais et plafonds de l'admission — quand la base ne répond plus
542
+
543
+ Deux moments demandent une réponse à l'extérieur du processus : l'**authentification** à la poignée
544
+ de main (relire une session, vérifier une clé distante) et la règle d'**admission** `allow` d'un canal
545
+ (lire une appartenance en base). Quand la base ralentit ou tombe, ces quatre clés décident de ce que
546
+ le serveur retient, et de ce qu'il rend au client. Elles sont appliquées par
547
+ `AdmissionBudget` (`admissionBudget.ts#AdmissionBudget`), posées par `RealtimeService.init()`
548
+ (`RealtimeService.ts#RealtimeService.init`).
549
+
550
+ <!-- prettier-ignore -->
551
+ | Clé | Défaut | Ce qui se passe au-delà | Pourquoi ce défaut |
552
+ | --- | --- | --- | --- |
553
+ | `authenticateTimeoutMs` | `10000` | la socket est fermée en `1013` « try again later » ; le client recule puis revient ; une identité arrivée après coup est ignorée | l'authentification relit souvent une session en base ou une clé distante ; 10 s couvrent une base chargée, et au-delà retenir la connexion ne l'authentifierait pas plus vite — elle occuperait seulement une place |
554
+ | `allowTimeoutMs` | `5000` | l'abonné reçoit `realtime:denied` motif `unavailable` et réessaie ; un verdict tardif est ignoré, jamais une admission | une lecture saine répond en millisecondes ; 5 s couvrent une base chargée sans laisser un écran vide au-delà de ce qu'un humain attend |
555
+ | `maxPendingAllowsPerConnection` | `8` | la demande d'abonnement **attend son tour** — jamais refusée | assez pour ouvrir plusieurs canaux en parallèle, assez peu pour qu'une connexion ne monopolise pas le pool de la base (souvent 10) ; refuser casserait le rejeu au `welcome`, qui renvoie tous les canaux d'un coup |
556
+ | `maxPendingAllowsTotal` | `512` | `unavailable` sans appeler la règle | à ce niveau la base est déjà l'étranglement : empiler davantage d'appels n'admettrait personne plus vite, et retarderait la reprise |
557
+
558
+ Deux comportements ne se règlent pas, et c'est voulu : le **coupe-circuit** (50 délais dépassés en
559
+ 5 s suspendent les appels pendant 5 s, avec un `retryAfterMs` rendu au client) et le **réessai
560
+ client** (trois au plus, recul exponentiel à gigue complète). Exposés en réglage, ils
561
+ deviendraient le premier levier tourné pendant un incident — exactement le moment où ils
562
+ protègent la base.
563
+
564
+ ```typescript
565
+ // nodefony.config.ts — une base lente, assumée : on attend plus, on retient moins
566
+ use("@nodefony/realtime", {
567
+ authenticateTimeoutMs: 15_000,
568
+ allowTimeoutMs: 8000,
569
+ maxPendingAllowsTotal: 256,
570
+ });
571
+ ```
572
+
573
+ Les quatre clés refusent `0`, un nombre négatif ou non entier, et tout délai au-delà de
574
+ `2 147 483 647` ms : `setTimeout` ramènerait ce délai à 1 ms, et chaque appel échouerait aussitôt.
575
+ Le comportement vu par l'abonné : [`securite.md`](./securite.md) ; les compteurs qui disent quand
576
+ ces bornes travaillent : [`observabilite.md`](./observabilite.md).
577
+
487
578
  ### `csrf.checkOrigin` — qui a le droit d'ouvrir la socket
488
579
 
489
580
  Trois clés qui forment une seule politique, appliquée à l'ouverture de la connexion
@@ -515,6 +606,31 @@ non-navigateur pouvant toujours forger l'en-tête — appartient à [Sécurité]
515
606
  > vérifier. Et `allowMissingOrigin: true` sans authentificateur fort rouvre exactement la brèche que
516
607
  > le contrôle ferme.
517
608
 
609
+ ### `publicOrigin` — une socket servie sur une autre origine
610
+
611
+ La politique de contenu que pose `@nodefony/security` autorise les connexions vers l'origine de la
612
+ page seulement (`connect-src 'self'`). Une socket servie au même endroit que la page — le cas
613
+ courant, une adresse relative — passe sans rien régler.
614
+
615
+ Une socket servie **ailleurs** (un autre domaine, un autre port) serait bloquée par le navigateur.
616
+ Tu ne l'écris pas dans la politique : tu dis au module où elle est servie, et il s'y déclare
617
+ lui-même au démarrage (`RealtimeService.ts#RealtimeService.init`).
618
+
619
+ ```ts
620
+ use("@nodefony/realtime", {
621
+ publicOrigin: "wss://rt.example.com",
622
+ });
623
+ ```
624
+
625
+ La valeur est une **origine nue** : schéma, hôte, port facultatif — ni chemin, ni joker. Une valeur
626
+ qui n'en est pas une arrête le démarrage en nommant la clé. `https://rt.example.com` est accepté et
627
+ déclaré `wss://rt.example.com` : c'est cette forme qui couvre la socket ET le même hôte en
628
+ `https:` (`publicOrigin.ts#realtimeConnectSource`).
629
+
630
+ Deux clés, deux questions : `publicOrigin` dit où la socket **est** ; `csrf.checkOrigin.allowList`
631
+ dit quelles pages ont le droit de l'**ouvrir**. Une socket sur un autre domaine a souvent besoin
632
+ des deux.
633
+
518
634
  ## 🔌 Choisir le driver de backplane — trois situations
519
635
 
520
636
  Le choix ne se déduit pas d'un tableau de performances : il se déduit de **ta topologie de
@@ -634,7 +750,8 @@ class NatsBackplane implements IBackplane {
634
750
  this.#handler = handler;
635
751
  }
636
752
  async stop(): Promise<void> {
637
- /* fermer, libérer les écouteurs — idempotent */
753
+ /* laisser partir ce qui est déjà émis (résoudre APRÈS l'acquittement),
754
+ puis fermer et libérer les écouteurs — idempotent */
638
755
  }
639
756
  describe(): IBackplaneInfo {
640
757
  return {
@@ -666,6 +783,13 @@ Quatre points que la fabrique doit respecter :
666
783
  4. `describe()` alimente **trois** sorties depuis une source unique : la ligne de journal, la sonde
667
784
  de santé et l'écran Studio. Remplis-la honnêtement, en particulier `crossPod`.
668
785
 
786
+ Côté driver, un point compte à l'arrêt : **`stop()` laisse partir ce qui est déjà émis**. Le
787
+ processus sort au tick qui suit l'arrêt du noyau. Un `stop()` qui ferme la connexion sans attendre
788
+ l'acquittement perd la dernière publication, c'est-à-dire la fin de la présence de ce serveur. Les
789
+ autres serveurs gardent alors ses présents jusqu'au délai d'oubli. Le pilote Redis attend sa file
790
+ d'envoi (`publishQueue.ts#BackplanePublishQueue.settled`) ; un pilote maison bâti sur
791
+ `BackplanePublishQueue` en hérite.
792
+
669
793
  **L'autre voie : fournir une instance déjà construite.** Déclare un service nommé
670
794
  `realtimeBackplane` dans le conteneur d'injection de ton module. `RealtimeService.init()`
671
795
  (`RealtimeService.ts#RealtimeService.init`) le lit et le branche **avant** que le registre soit consulté : une instance
@@ -679,12 +803,40 @@ backplane })`), et la documentation d'architecture présente cette voie. En prat
679
803
  > inconnue, dont une instance de classe posée dans `backplane`, fait échouer le boot. Pour brancher un objet déjà construit, utilise
680
804
  > le service `realtimeBackplane`.
681
805
 
806
+ ### Arrêter proprement un bus que tu fournis
807
+
808
+ À l'arrêt du noyau, le module arrête **le bus qu'il a créé lui-même** depuis le registre. Il appelle
809
+ `shutdownRealtime` (`realtimeShutdown.ts#shutdownRealtime`), qui fait trois choses :
810
+
811
+ 1. la présence des canaux annonce sa fin : dernier lot, puis preuve de vie finale ;
812
+ 2. le bus s'arrête en laissant partir ce qui est déjà émis ;
813
+ 3. le tout est borné à `BACKPLANE_STOP_TIMEOUT_MS` (2 s), pour qu'un bus injoignable ne retienne
814
+ pas l'arrêt.
815
+
816
+ Un bus fourni par `realtimeBackplane` reste **à toi** : le module envoie bien la fin de la
817
+ présence, mais n'arrête pas ce bus. Branche le même appel à l'arrêt du module qui l'a construit :
818
+
819
+ ```ts ignore
820
+ import { getRealtimeHub, shutdownRealtime } from "@nodefony/realtime";
821
+
822
+ // Dans le module qui a construit `bus` et l'a déclaré comme `realtimeBackplane`.
823
+ this.kernel?.once("onTerminate", async () => {
824
+ const stopped = await shutdownRealtime(getRealtimeHub(), bus);
825
+ if (!stopped)
826
+ this.log("bus temps réel : arrêt non terminé dans le délai", "WARNING");
827
+ });
828
+ ```
829
+
830
+ `shutdownRealtime` rend `false` quand le bus n'a pas fini à temps. Ce qui restait en vol peut alors
831
+ être perdu, et les autres serveurs oublieront celui-ci au délai d'oubli, sans fantôme durable.
832
+ L'appel est idempotent : la présence ne s'annonce terminée qu'une fois.
833
+
682
834
  Le détail du contrat, de l'anti-écho et du cycle de vie d'un driver est dans
683
835
  [Architecture](./architecture.md).
684
836
 
685
837
  ## ⚙️ Variables d'environnement et précédence
686
838
 
687
- Six variables influent sur le module. Trois lui sont propres, les autres viennent du cœur ou du
839
+ Neuf variables influent sur le module. Six lui sont propres, les autres viennent du cœur ou du
688
840
  mode cluster. Les trois premières décrivent un **déploiement** — le fond de panier auquel ce
689
841
  processus se raccorde — et c'est pour cela qu'elles ne vivent pas dans un fichier versionné.
690
842
 
@@ -693,6 +845,8 @@ processus se raccorde — et c'est pour cela qu'elles ne vivent pas dans un fich
693
845
  | `NF_REALTIME_DRIVER` | ce module | remplace `backplane.driver` — nom libre, un pilote inconnu est signalé |
694
846
  | `NF_REALTIME_BACKPLANE_NAMESPACE` | ce module | remplace `backplane.namespace` — sépare deux déploiements de la même app |
695
847
  | `NF_REALTIME_BACKPLANE_SECRET` | ce module | remplace `backplane.secret` — scelle le transport partagé (≥ 32 car.) |
848
+ | `NF_REALTIME_CODEC` | ce module | remplace `codec` — `json` remet le fil en texte, pour déboguer seulement |
849
+ | `NF_REALTIME_PUBLIC_ORIGIN` | ce module | remplace `publicOrigin` — où la socket est servie, propre au déploiement |
696
850
  | `NF__REALTIME__<CHEMIN>` | mécanisme du cœur | remplace n'importe quelle clé, par son chemin. Ex. `NF__REALTIME__ENABLED` |
697
851
  | `NF_CLUSTER` | posée par le lancement | à `1`, le driver `cluster` s'active en worker. Ne la pose pas à la main |
698
852
  | `NF_CLUSTER_PROBE` | ce module | à `0`, coupe la sonde de pod même si `cluster.probe.enabled` est vrai |
@@ -702,14 +856,14 @@ L'ordre de recouvrement, du plus faible au plus fort :
702
856
 
703
857
  1. **Les valeurs d'usine du schéma** — ce que tu obtiens sans rien écrire.
704
858
  2. **La configuration de l'application** — ton `use("@nodefony/realtime", { … })`.
705
- 3. **`NF_REALTIME_DRIVER`**, **`NF_REALTIME_BACKPLANE_NAMESPACE`** et
706
- **`NF_REALTIME_BACKPLANE_SECRET`** — posées par le Kernel après la fusion et **avant** la
859
+ 3. **`NF_REALTIME_DRIVER`**, **`NF_REALTIME_BACKPLANE_NAMESPACE`**,
860
+ **`NF_REALTIME_BACKPLANE_SECRET`**, **`NF_REALTIME_CODEC`** et **`NF_REALTIME_PUBLIC_ORIGIN`** — posées par le Kernel après la fusion et **avant** la
707
861
  validation Zod (`applyEnvShortcuts`, `Kernel.ts#Kernel.applyEnvConfigOverrides`) : une valeur fausse **arrête le démarrage** en nommant la
708
862
  variable (un secret de moins de 32 caractères, un espace de noms qui contient `:`). Chacune
709
863
  accepte aussi sa forme `<NOM>_FILE` (secret monté par Docker ou Kubernetes).
710
864
  4. **`NF__REALTIME__<CHEMIN>`** — même moment, même validation. Un chemin introuvable produit un
711
865
  avertissement avec une suggestion, jamais une clé fantôme. Viser le **même** champ qu'une des
712
- trois variables ci-dessus est une ambiguïté : refusée en production, et en développement
866
+ quatre variables ci-dessus est une ambiguïté : refusée en production, et en développement
713
867
  `NF__…` l'emporte avec un avertissement qui nomme les deux.
714
868
 
715
869
  Le mécanisme générique mérite d'être connu : le double tiret bas sépare les niveaux, les segments
@@ -782,16 +936,18 @@ Trois endroits, par ordre de fiabilité décroissante.
782
936
 
783
937
  ## ⚠️ Pièges
784
938
 
785
- | Symptôme | Cause | Correction |
786
- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
787
- | Un message n'arrive qu'à la moitié des clients, sans aucune erreur | plusieurs répliques avec `driver: "loopback"` | passer à `redis` et déclarer `@nodefony/redis` dans le manifeste |
788
- | Driver `redis` configuré, journal `RealtimeHub reste local` | module `@nodefony/redis` absent du manifeste, ou connexions `publish`/`subscribe` indisponibles | déclarer le module ; vérifier que Redis répond |
789
- | Le fan-out traverse, mais des messages d'un autre environnement arrivent | `backplane.namespace` non posé : deux déploiements de la même application dérivent le même canal | poser un namespace explicite et distinct par environnement |
790
- | Le démarrage échoue avec `clé inconnue : …` | clé absente du schéma strict (faute de frappe, ou clé retirée) | corriger ou retirer la clé que nomme le message |
791
- | Baisser `slowConsumer.bytes` ne ferme pas les clients lents | cette clé ne pilote que le **comptage** de la sonde | régler `websocketSecure.maxBackpressure` / `.backpressureCloseAfterDrops` (@nodefony/http) |
792
- | Toutes les connexions tombent en `4003` après activation du contrôle d'origine | `allowList` vide, ou origine non identique au caractère près (port, schéma, sous-domaine) | inscrire l'origine exacte ; un client non-navigateur relève de `allowMissingOrigin` |
793
- | `enabled: false` posé « pour désactiver », et la garde d'origine ne s'applique plus | le service devient inerte et ne pose plus les politiques sur le hub | retirer le module du manifeste plutôt que l'éteindre |
794
- | Le plafond de canaux semble ignoré en test | il ne l'est pas : le hub porte le même défaut de 256 sans configuration (`RealtimeHub.ts#RealtimeHub`) | vérifier le motif de refus `realtime:denied` côté client |
939
+ | Symptôme | Cause | Correction |
940
+ | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
941
+ | Un message n'arrive qu'à la moitié des clients, sans aucune erreur | plusieurs répliques avec `driver: "loopback"` | passer à `redis` et déclarer `@nodefony/redis` dans le manifeste |
942
+ | Driver `redis` configuré, journal `RealtimeHub reste local` | module `@nodefony/redis` absent du manifeste, ou connexions `publish`/`subscribe` indisponibles | déclarer le module ; vérifier que Redis répond |
943
+ | Le fan-out traverse, mais des messages d'un autre environnement arrivent | `backplane.namespace` non posé : deux déploiements de la même application dérivent le même canal | poser un namespace explicite et distinct par environnement |
944
+ | Le démarrage échoue avec `clé inconnue : …` | clé absente du schéma strict (faute de frappe, ou clé retirée) | corriger ou retirer la clé que nomme le message |
945
+ | Baisser `slowConsumer.bytes` ne ferme pas les clients lents | cette clé ne pilote que le **comptage** de la sonde | régler `websocketSecure.maxBackpressure` / `.backpressureCloseAfterDrops` (@nodefony/http) |
946
+ | Toutes les connexions tombent en `4003` après activation du contrôle d'origine | `allowList` vide, ou origine non identique au caractère près (port, schéma, sous-domaine) | inscrire l'origine exacte ; un client non-navigateur relève de `allowMissingOrigin` |
947
+ | `enabled: false` posé « pour désactiver », et la garde d'origine ne s'applique plus | le service devient inerte et ne pose plus les politiques sur le hub | retirer le module du manifeste plutôt que l'éteindre |
948
+ | Le plafond de canaux semble ignoré en test | il ne l'est pas : le hub porte le même défaut de 256 sans configuration (`RealtimeHub.ts#RealtimeHub`) | vérifier le motif de refus `realtime:denied` côté client |
949
+ | `codec: "cbor"` posé, et `onRealtimeBinary()` ne reçoit plus rien | sur une connexion CBOR, toute trame binaire est une frame du codec (ADR-0022 §2) | publier les octets dans un canal ; le flux brut ne sert que les connexions texte |
950
+ | `codec: "cbor"` posé, et le navigateur reste en texte | le client n'a pas offert le codec : `codec: CBOR_FRAME_CODEC` absent, ou passé après la 1ʳᵉ création | l'offrir à la PREMIÈRE création de la socket ([client](../../../../nodefony/docs/client.md#choisir-le-codec-de-trame--texte-ou-cbor)) |
795
951
 
796
952
  ## 🧪 Tests & couverture
797
953