@nodefony/realtime 10.0.0-beta.6 → 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 (62) hide show
  1. package/README.md +5 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +15 -3
  4. package/dist/nodefony/decorators/realtimeDecorators.js +231 -39
  5. package/dist/nodefony/src/backplane/RedisBackplane.js +33 -40
  6. package/dist/nodefony/src/backplane/controlOrder.js +34 -5
  7. package/dist/nodefony/src/backplane/envelope.js +422 -45
  8. package/dist/nodefony/src/backplane/ingress.js +93 -0
  9. package/dist/nodefony/src/backplane/presenceMessage.js +205 -0
  10. package/dist/nodefony/src/backplane/publishQueue.js +97 -3
  11. package/dist/nodefony/src/server/RealtimeConnection.js +31 -6
  12. package/dist/nodefony/src/server/RealtimeController.js +87 -44
  13. package/dist/nodefony/src/server/RealtimeHub.js +631 -127
  14. package/dist/nodefony/src/server/admissionBudget.js +8 -8
  15. package/dist/nodefony/src/server/channelPolicyRegistry.js +35 -13
  16. package/dist/nodefony/src/server/channelPolicyResolution.js +191 -0
  17. package/dist/nodefony/src/server/channelPresence.js +488 -0
  18. package/dist/nodefony/src/server/contractRoutes.js +3 -13
  19. package/dist/nodefony/src/server/frameAdmission.js +27 -35
  20. package/dist/nodefony/src/server/presenceBus.js +486 -0
  21. package/dist/nodefony/src/server/realtimeShutdown.js +31 -0
  22. package/dist/nodefony/src/server/remotePresence.js +541 -0
  23. package/dist/nodefony/src/server/subscribedAck.js +3 -0
  24. package/dist/nodefony/src/service/RealtimeService.js +9 -0
  25. package/dist/nodefony/testing/backplaneConformance.js +79 -3
  26. package/dist/nodefony/testing/index.js +22 -8
  27. package/dist/nodefony/testing/presenceClient.js +54 -0
  28. package/dist/types/index.d.ts +6 -1
  29. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +117 -16
  30. package/dist/types/nodefony/interfaces/IBackplane.d.ts +12 -1
  31. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +10 -2
  32. package/dist/types/nodefony/src/backplane/controlOrder.d.ts +23 -0
  33. package/dist/types/nodefony/src/backplane/envelope.d.ts +50 -7
  34. package/dist/types/nodefony/src/backplane/ingress.d.ts +48 -0
  35. package/dist/types/nodefony/src/backplane/presenceMessage.d.ts +150 -0
  36. package/dist/types/nodefony/src/backplane/publishQueue.d.ts +25 -1
  37. package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +3 -0
  38. package/dist/types/nodefony/src/server/RealtimeController.d.ts +19 -1
  39. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +139 -30
  40. package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +9 -2
  41. package/dist/types/nodefony/src/server/channelPolicyResolution.d.ts +112 -0
  42. package/dist/types/nodefony/src/server/channelPresence.d.ts +155 -0
  43. package/dist/types/nodefony/src/server/contractRoutes.d.ts +1 -1
  44. package/dist/types/nodefony/src/server/frameAdmission.d.ts +14 -18
  45. package/dist/types/nodefony/src/server/presenceBus.d.ts +112 -0
  46. package/dist/types/nodefony/src/server/realtimeShutdown.d.ts +21 -0
  47. package/dist/types/nodefony/src/server/remotePresence.d.ts +151 -0
  48. package/dist/types/nodefony/src/server/subscribedAck.d.ts +3 -0
  49. package/dist/types/nodefony/src/service/RealtimeService.d.ts +8 -1
  50. package/dist/types/nodefony/testing/backplaneConformance.d.ts +14 -2
  51. package/dist/types/nodefony/testing/index.d.ts +1 -1
  52. package/dist/types/nodefony/testing/presenceClient.d.ts +20 -0
  53. package/docs/actions.md +1 -0
  54. package/docs/architecture.md +21 -6
  55. package/docs/canaux-a-membres.md +13 -3
  56. package/docs/cas-d-usage.md +4 -3
  57. package/docs/configuration.md +38 -2
  58. package/docs/cookbook-chat.md +400 -152
  59. package/docs/observabilite.md +28 -9
  60. package/docs/protocole.md +10 -10
  61. package/docs/securite.md +64 -10
  62. package/package.json +12 -12
@@ -21,7 +21,7 @@ tags:
21
21
  ]
22
22
  version: "doc"
23
23
  status: stable
24
- updated: 2026-07-19
24
+ updated: 2026-11-10
25
25
  source: "src/packages/@nodefony/realtime/docs/architecture.md"
26
26
  ---
27
27
 
@@ -628,8 +628,11 @@ même process). Sans filtre, le fan-out local serait fait deux fois. D'où **deu
628
628
  et non une :
629
629
 
630
630
  1. **Côté hub** : l'arrivée passe par `publishLocal`, jamais par `publish`. Rien ne repart.
631
- 2. **Côté backplane** : à la réception, on compare l'`originId` de l'enveloppe au sien et
632
- on jette si c'est le même (`RedisBackplane.ts#RedisBackplane.originId`, `ClusterBackplane.ts#ClusterBackplane.#ingress`).
631
+ 2. **Côté backplane** : à la réception, l'écho se reconnaît à son `originId` et se jette
632
+ (`ingress.ts#BackplaneIngress`, `ClusterBackplane.ts#ClusterBackplane.#ingress`). Sur un bus
633
+ partagé, il se reconnaît à la **fin** du message, sans le décoder : sous un secret, un seul
634
+ calcul de sceau le vérifie (`envelope.ts#BackplaneEcho`). Chaque écho est compté
635
+ (`echoTotal` de la carte d'identité du backplane) : tant qu'il revient, le bus est vivant.
633
636
 
634
637
  L'étiquette elle-même est calculée par `resolveBackplaneOriginId()` (`originId.ts#resolveBackplaneOriginId`), et
635
638
  sa recette mérite qu'on s'y arrête :
@@ -745,9 +748,21 @@ drivers livrés, exporté pour lui : `sealBackplaneEnvelope` à l'émission,
745
748
  La preuve est la même pour tous : `runBackplaneConformance`, exportée par
746
749
  `@nodefony/realtime/testing` (`backplaneConformance.ts#runBackplaneConformance`). Elle
747
750
  relie plusieurs pairs du driver sur un même bus et vérifie la diffusion à tous, l'ordre,
748
- l'absence de rejeu, le sceau, le transport des ordres de contrôle, le refus de l'espace
749
- réservé à l'entrée du hub, le remplacement du handler et l'arrêt. Les drivers `cluster` et
750
- `redis` la passent dans le dépôt ; `loopback`, qui n'a aucun pair, n'a rien à y prouver.
751
+ l'absence de rejeu, le sceau, le transport des ordres de contrôle et de la preuve de vie même
752
+ file saturée, le refus de l'espace réservé à l'entrée du hub, la présence entre deux pods, le
753
+ remplacement du handler et l'arrêt. Les drivers `cluster` et `redis` la passent dans le
754
+ dépôt ; `loopback`, qui n'a aucun pair, n'a rien à y prouver.
755
+
756
+ Le cas « présence entre deux pods » branche deux hubs réels sur deux pairs du driver.
757
+ Il vérifie trois choses :
758
+
759
+ - une identité ouverte sur les deux compte pour un seul présent ;
760
+ - l'arrêt propre d'un hub retire ses présents chez l'autre bien avant le délai d'oubli ;
761
+ - aucun message de présence n'arrive en double ni altéré.
762
+
763
+ Un bus « au moins une fois », qui relivre un message, y tombe : la présence compte les doublons
764
+ dans `presenceStaleDiscardedTotal`. Le driver n'a rien à écrire pour la présence, qui ne parle
765
+ qu'à `IBackplane`. Ce cas prouve que son transport la porte.
751
766
 
752
767
  ```typescript
753
768
  import { describe, it } from "vitest";
@@ -173,6 +173,12 @@ du canal — ses onglets, quel que soit l'exemplaire qui les sert. Il rend le no
173
173
  éjectées **ici** ; les autres exemplaires l'appliquent sans compte rendu. Un compte vide n'éjecte
174
174
  rien. La même règle vaut : retirer l'appartenance là où `allow` la lit, **puis** éjecter.
175
175
 
176
+ **Un accès borné dans le temps** (l'invité d'un événement, un abonnement qui expire) ne se périme
177
+ pas seul : le verdict d'`allow` est retenu sur la connexion, et le hub ne relit rien. À l'échéance,
178
+ l'application rejoue sa règle et éjecte qui n'y a plus droit. Un seul minuteur suffit, visé sur
179
+ la prochaine échéance — ni un par accès, ni un balayage périodique qui lirait la base pour rien. La
180
+ démo le fait pour ses salons (`RoomService.closeEnded`, gabarit `create app --demo`).
181
+
176
182
  **`closeChannel(canal)`** envoie `realtime:closed { reason: "ended" }` à chaque membre, sur tous les
177
183
  exemplaires, puis libère le producteur. Les écouteurs du serveur (`listen`) restent inscrits, sur un canal redevenu passif. Le
178
184
  hub ne garde **aucune marque « fermé »** : le `subscribe` suivant rejoue la fabrique, qui rend `null`
@@ -226,7 +232,10 @@ transporte les **publications** et deux **ordres** — terminer un canal, éject
226
232
  chaque exemplaire applique à SES connexions ; ni membres, ni politiques, ni acquittements. Ces deux
227
233
  ordres ne font qu'enlever : seul `allow` fait entrer. Livrés « au plus une fois », comme toute
228
234
  publication : un exemplaire qui en manque un, ou qui démarre après, est rattrapé par `allow` (pour
229
- une éjection) ou par la fabrique qui rend `null` à une instance terminée (pour une fin).
235
+ une éjection) ou par la fabrique qui rend `null` à une instance terminée (pour une fin). Un bus
236
+ chargé ne les sacrifie pas : quand la file d'envoi vers le bus est pleine, les publications en
237
+ trop sont abandonnées, les ordres passent quand même. Seul un bus injoignable en perd un, et la
238
+ perte se compte (`controlLostTotal`) et s'annonce.
230
239
 
231
240
  Les gestes **par connexion** agissent sur les connexions de ce processus. Deux voies marchent en
232
241
  cluster pour eux :
@@ -248,8 +257,9 @@ marcherait en développement et une fois sur N en production serait le pire des
248
257
  gestionnaire entrant, refus de `allow` rendu au seul appelant, connexion fermée ou désabonnée
249
258
  pendant l'attente ; fin du canal, producteur libéré, écouteur conservé, fabrique rejouée.
250
259
  - **Entre exemplaires** (`memberOrdersAcrossPods.test.ts`) : fin et éjection d'un compte données sur
251
- un hub atteignent les membres de l'autre ; canal de contrôle jamais diffusé ; ordres mal formés,
252
- inventés ou visant un canal de la plateforme rejetés et comptés (`ingressRejectedTotal`). Sur un
260
+ un hub atteignent les membres de l'autre ; canal de contrôle jamais diffusé ; ordres mal formés
261
+ ou visant un canal de la plateforme rejetés et comptés (`ingressRejectedTotal`), ordre d'un type
262
+ inconnu de cette version jeté et compté à part (`ingressUnknownDiscardedTotal`). Sur un
253
263
  **Redis réel** (`memberOrdersRedis.test.ts`, gate Redis) : ordre scellé appliqué, ordre non scellé
254
264
  ignoré, et le rejeu d'une éjection captée — le risque accepté — décrit par un test. Sur le **bus du
255
265
  cluster** (`clusterIpc.e2e.test.ts`, `npm run test:cluster`) : deux vrais processus.
@@ -517,8 +517,8 @@ export class MatchRealtime extends RealtimeController {
517
517
  return () => {};
518
518
  }
519
519
 
520
- // Un canal décoré n'est servi que sous son nom exact : la famille l'est ici,
521
- // et seulement pour une partie qui existe.
520
+ // Le motif servirait toute la famille : la surcharge la FILTRE — seule une
521
+ // partie qui existe est servie, tout autre nom est refusé (`null`).
522
522
  override createRealtimeChannel(channel: string): (() => void) | null {
523
523
  return this.matches.exists(channel) ? () => {} : null;
524
524
  }
@@ -758,7 +758,8 @@ export class RoomRealtime extends RealtimeController {
758
758
  return () => {};
759
759
  }
760
760
 
761
- // Un canal décoré n'est servi que sous son nom exact : la famille l'est ici.
761
+ // La surcharge FILTRE la famille : un nom qui n'est pas un salon est refusé
762
+ // (`null`). Sans elle, le motif servirait tout `room:*`, sous `allow`.
762
763
  override createRealtimeChannel(channel: string): (() => void) | null {
763
764
  return RoomService.roomOf(channel) === null ? null : () => {};
764
765
  }
@@ -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
 
@@ -750,7 +750,8 @@ class NatsBackplane implements IBackplane {
750
750
  this.#handler = handler;
751
751
  }
752
752
  async stop(): Promise<void> {
753
- /* 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 */
754
755
  }
755
756
  describe(): IBackplaneInfo {
756
757
  return {
@@ -782,6 +783,13 @@ Quatre points que la fabrique doit respecter :
782
783
  4. `describe()` alimente **trois** sorties depuis une source unique : la ligne de journal, la sonde
783
784
  de santé et l'écran Studio. Remplis-la honnêtement, en particulier `crossPod`.
784
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
+
785
793
  **L'autre voie : fournir une instance déjà construite.** Déclare un service nommé
786
794
  `realtimeBackplane` dans le conteneur d'injection de ton module. `RealtimeService.init()`
787
795
  (`RealtimeService.ts#RealtimeService.init`) le lit et le branche **avant** que le registre soit consulté : une instance
@@ -795,6 +803,34 @@ backplane })`), et la documentation d'architecture présente cette voie. En prat
795
803
  > inconnue, dont une instance de classe posée dans `backplane`, fait échouer le boot. Pour brancher un objet déjà construit, utilise
796
804
  > le service `realtimeBackplane`.
797
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
+
798
834
  Le détail du contrat, de l'anti-écho et du cycle de vie d'un driver est dans
799
835
  [Architecture](./architecture.md).
800
836