@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.
- package/README.md +5 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
- package/dist/index.js +15 -3
- package/dist/nodefony/decorators/realtimeDecorators.js +231 -39
- package/dist/nodefony/src/backplane/RedisBackplane.js +33 -40
- package/dist/nodefony/src/backplane/controlOrder.js +34 -5
- package/dist/nodefony/src/backplane/envelope.js +422 -45
- package/dist/nodefony/src/backplane/ingress.js +93 -0
- package/dist/nodefony/src/backplane/presenceMessage.js +205 -0
- package/dist/nodefony/src/backplane/publishQueue.js +97 -3
- package/dist/nodefony/src/server/RealtimeConnection.js +31 -6
- package/dist/nodefony/src/server/RealtimeController.js +87 -44
- package/dist/nodefony/src/server/RealtimeHub.js +631 -127
- package/dist/nodefony/src/server/admissionBudget.js +8 -8
- package/dist/nodefony/src/server/channelPolicyRegistry.js +35 -13
- package/dist/nodefony/src/server/channelPolicyResolution.js +191 -0
- package/dist/nodefony/src/server/channelPresence.js +488 -0
- package/dist/nodefony/src/server/contractRoutes.js +3 -13
- package/dist/nodefony/src/server/frameAdmission.js +27 -35
- package/dist/nodefony/src/server/presenceBus.js +486 -0
- package/dist/nodefony/src/server/realtimeShutdown.js +31 -0
- package/dist/nodefony/src/server/remotePresence.js +541 -0
- package/dist/nodefony/src/server/subscribedAck.js +3 -0
- package/dist/nodefony/src/service/RealtimeService.js +9 -0
- package/dist/nodefony/testing/backplaneConformance.js +79 -3
- package/dist/nodefony/testing/index.js +22 -8
- package/dist/nodefony/testing/presenceClient.js +54 -0
- package/dist/types/index.d.ts +6 -1
- package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +117 -16
- package/dist/types/nodefony/interfaces/IBackplane.d.ts +12 -1
- package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +10 -2
- package/dist/types/nodefony/src/backplane/controlOrder.d.ts +23 -0
- package/dist/types/nodefony/src/backplane/envelope.d.ts +50 -7
- package/dist/types/nodefony/src/backplane/ingress.d.ts +48 -0
- package/dist/types/nodefony/src/backplane/presenceMessage.d.ts +150 -0
- package/dist/types/nodefony/src/backplane/publishQueue.d.ts +25 -1
- package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +3 -0
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +19 -1
- package/dist/types/nodefony/src/server/RealtimeHub.d.ts +139 -30
- package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +9 -2
- package/dist/types/nodefony/src/server/channelPolicyResolution.d.ts +112 -0
- package/dist/types/nodefony/src/server/channelPresence.d.ts +155 -0
- package/dist/types/nodefony/src/server/contractRoutes.d.ts +1 -1
- package/dist/types/nodefony/src/server/frameAdmission.d.ts +14 -18
- package/dist/types/nodefony/src/server/presenceBus.d.ts +112 -0
- package/dist/types/nodefony/src/server/realtimeShutdown.d.ts +21 -0
- package/dist/types/nodefony/src/server/remotePresence.d.ts +151 -0
- package/dist/types/nodefony/src/server/subscribedAck.d.ts +3 -0
- package/dist/types/nodefony/src/service/RealtimeService.d.ts +8 -1
- package/dist/types/nodefony/testing/backplaneConformance.d.ts +14 -2
- package/dist/types/nodefony/testing/index.d.ts +1 -1
- package/dist/types/nodefony/testing/presenceClient.d.ts +20 -0
- package/docs/actions.md +1 -0
- package/docs/architecture.md +21 -6
- package/docs/canaux-a-membres.md +13 -3
- package/docs/cas-d-usage.md +4 -3
- package/docs/configuration.md +38 -2
- package/docs/cookbook-chat.md +400 -152
- package/docs/observabilite.md +28 -9
- package/docs/protocole.md +10 -10
- package/docs/securite.md +64 -10
- package/package.json +12 -12
package/docs/cookbook-chat.md
CHANGED
|
@@ -77,7 +77,7 @@ apporte.
|
|
|
77
77
|
| --------------------------------------------------------------------------------------- | -------------------------------------------------------------- | ---------------------------------------- |
|
|
78
78
|
| [1 — le chat minimal](#-démarrage-rapide--étape-1-le-chat-minimal-qui-marche) | Un canal, un message qui traverse | Deux onglets qui se parlent |
|
|
79
79
|
| [2 — HTTP et WebSocket](#-étape-2--le-même-contrôleur-sert-http-et-websocket) | L'historique en `GET` dans la **même classe** | Une action, deux transports |
|
|
80
|
-
| [3 — présence et frappe](#-étape-3--présence-et-frappe-en-cours) |
|
|
80
|
+
| [3 — présence et frappe](#-étape-3--présence-et-frappe-en-cours) | La présence en option du canal, la frappe en message | Ni canal ni minuteur de plus |
|
|
81
81
|
| [4 — persister](#-étape-4--persister-lhistorique) | Un repository derrière un service | L'historique survit au redémarrage |
|
|
82
82
|
| [5 — protéger](#-étape-5--protéger-le-salon) | Zone firewall + politique de canal | L'intrus est refusé, et sait qu'il l'est |
|
|
83
83
|
| [6 — la pièce jointe](#-étape-6--la-pièce-jointe-http-et-websocket-dans-la-même-classe) | `POST` multipart + annonce sur le canal | Le différenciateur devient **visible** |
|
|
@@ -92,23 +92,23 @@ apporte.
|
|
|
92
92
|
Tous les termes employés dans cette page. Le fond de chacun est dans
|
|
93
93
|
[vocabulaire.md](./vocabulaire.md) — ici, la définition strictement suffisante pour suivre.
|
|
94
94
|
|
|
95
|
-
| Terme | Ce que c'est
|
|
96
|
-
| ------------------ |
|
|
97
|
-
| **Socket** | La connexion WebSocket unique entre un navigateur et le serveur. Une seule par onglet, quel que soit le nombre de canaux.
|
|
98
|
-
| **Canal** | Un flux nommé qui circule **dans** la socket (`chat:room`, `
|
|
99
|
-
| **Frame** | Un message du protocole sur le fil. Toutes les frames sont du JSON-RPC 2.0.
|
|
100
|
-
| **Hub** | Le distributeur côté serveur : il tient la liste « qui écoute quel canal » et recopie chaque publication vers les abonnés du processus courant.
|
|
101
|
-
| **Backplane** | Le « fond de panier » : ce qui relaie les publications d'un processus à l'autre. Sans lui, chaque réplique est une île.
|
|
102
|
-
| **Pod / réplique** | Un processus Node qui sert l'application. Un seul en développement, plusieurs en production.
|
|
103
|
-
| **Fan-out** | La recopie d'une publication vers N abonnés. Une publication, N livraisons.
|
|
104
|
-
| **Inbound** | Un canal où le **client** pousse vers le serveur. Sens inverse du pub/sub habituel ; il faut le déclarer explicitement.
|
|
105
|
-
| **RPC** | _Remote Procedure Call_ : le client appelle une méthode serveur et attend une réponse (une `Promise`). Par opposition à une notification, qui ne répond pas.
|
|
106
|
-
| **Provider** | La fonction qui « alimente » un canal côté serveur. Créée au premier abonné, détruite au dernier.
|
|
107
|
-
| **Sink** | Le point de sortie d'une connexion sur un canal. Un abonné = un sink.
|
|
108
|
-
| **BFF** | _Backend For Frontend_ : le serveur porte la session dans un cookie, le navigateur ne manipule aucun jeton.
|
|
109
|
-
| **Zone / area** | Un motif d'URL que le firewall protège, avec la liste des façons de prouver son identité.
|
|
110
|
-
| **Politique** | Les exigences attachées à un canal : authentifié, tel rôle, tel scope.
|
|
111
|
-
| **Multipart** | L'encodage d'un formulaire HTTP qui transporte des fichiers (`multipart/form-data`).
|
|
95
|
+
| Terme | Ce que c'est |
|
|
96
|
+
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
97
|
+
| **Socket** | La connexion WebSocket unique entre un navigateur et le serveur. Une seule par onglet, quel que soit le nombre de canaux. |
|
|
98
|
+
| **Canal** | Un flux nommé qui circule **dans** la socket (`chat:room`, `room:42`). Analogie : la socket est le câble, les canaux sont les fréquences qui y passent. |
|
|
99
|
+
| **Frame** | Un message du protocole sur le fil. Toutes les frames sont du JSON-RPC 2.0. |
|
|
100
|
+
| **Hub** | Le distributeur côté serveur : il tient la liste « qui écoute quel canal » et recopie chaque publication vers les abonnés du processus courant. |
|
|
101
|
+
| **Backplane** | Le « fond de panier » : ce qui relaie les publications d'un processus à l'autre. Sans lui, chaque réplique est une île. |
|
|
102
|
+
| **Pod / réplique** | Un processus Node qui sert l'application. Un seul en développement, plusieurs en production. |
|
|
103
|
+
| **Fan-out** | La recopie d'une publication vers N abonnés. Une publication, N livraisons. |
|
|
104
|
+
| **Inbound** | Un canal où le **client** pousse vers le serveur. Sens inverse du pub/sub habituel ; il faut le déclarer explicitement. |
|
|
105
|
+
| **RPC** | _Remote Procedure Call_ : le client appelle une méthode serveur et attend une réponse (une `Promise`). Par opposition à une notification, qui ne répond pas. |
|
|
106
|
+
| **Provider** | La fonction qui « alimente » un canal côté serveur. Créée au premier abonné, détruite au dernier. |
|
|
107
|
+
| **Sink** | Le point de sortie d'une connexion sur un canal. Un abonné = un sink. |
|
|
108
|
+
| **BFF** | _Backend For Frontend_ : le serveur porte la session dans un cookie, le navigateur ne manipule aucun jeton. |
|
|
109
|
+
| **Zone / area** | Un motif d'URL que le firewall protège, avec la liste des façons de prouver son identité. |
|
|
110
|
+
| **Politique** | Les exigences attachées à un canal : authentifié, tel rôle, tel scope. |
|
|
111
|
+
| **Multipart** | L'encodage d'un formulaire HTTP qui transporte des fichiers (`multipart/form-data`). |
|
|
112
112
|
|
|
113
113
|
## 🧠 Le modèle mental en trois phrases
|
|
114
114
|
|
|
@@ -630,102 +630,324 @@ s'applique aux **deux** — c'est le même pipeline, pas deux implémentations
|
|
|
630
630
|
|
|
631
631
|
## 🧩 Étape 3 : présence et frappe en cours
|
|
632
632
|
|
|
633
|
-
On ajoute deux
|
|
634
|
-
la fonctionnalité — c'est **ce qu'elle ne coûte pas
|
|
633
|
+
On ajoute deux choses : qui est là, et qui est en train d'écrire. Le point de cette étape n'est pas
|
|
634
|
+
la fonctionnalité — c'est **ce qu'elle ne coûte pas** : pas un canal de plus, pas un minuteur, pas
|
|
635
|
+
une ligne de code par pod.
|
|
635
636
|
|
|
636
|
-
###
|
|
637
|
+
### La présence est une option du canal, pas un canal de plus
|
|
637
638
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
639
|
+
Être abonné au salon, c'est y être présent. La présence se déclare donc **sur le canal lui-même**,
|
|
640
|
+
par une option de sa politique — `presence: true` — et le framework fait le reste : il tient la liste
|
|
641
|
+
des présents, l'envoie entière à chaque abonné qui arrive, puis n'envoie que les changements,
|
|
642
|
+
regroupés par fenêtre de 250 ms. Une connexion qui se ferme s'en va aussitôt de la liste.
|
|
642
643
|
|
|
643
644
|
```mermaid
|
|
644
645
|
flowchart LR
|
|
645
646
|
N["Onglet du navigateur<br/>UNE connexion WebSocket"]
|
|
646
|
-
N -.->|"chat:room"|
|
|
647
|
-
|
|
648
|
-
|
|
647
|
+
N -.->|"chat:room"| C["Le canal du salon"]
|
|
648
|
+
C --> M[Messages]
|
|
649
|
+
C --> T["« Untel écrit… »"]
|
|
650
|
+
C --> P["Qui est là<br/>(option presence)"]
|
|
649
651
|
```
|
|
650
652
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
653
|
+
> [!WARNING]
|
|
654
|
+
> **La recette « un `Set` des connectés publié toutes les cinq secondes » est fausse dès deux
|
|
655
|
+
> processus.** Chaque processus ne connaît que les connexions qu'il tient : derrière
|
|
656
|
+
> `nodefony cluster -w 2` ou deux pods, chacun publie SA liste et écrase celle de l'autre. Elle coûte
|
|
657
|
+
> en plus un envoi permanent, et annonce les départs jusqu'à cinq secondes en retard. La présence du
|
|
658
|
+
> framework est tenue **entre les pods** (étape 7) : rien à écrire de plus.
|
|
659
|
+
|
|
660
|
+
### La frappe est un message, pas un état
|
|
661
|
+
|
|
662
|
+
« Untel écrit » ne dit pas qui est là : c'est un message éphémère, publié sur le canal du salon,
|
|
663
|
+
que chacun reçoit parce qu'il y est abonné et que l'écran efface seul. Il ne passe jamais par la
|
|
664
|
+
présence — sinon chaque frappe ferait renvoyer la liste à tout le salon.
|
|
665
|
+
|
|
666
|
+
> [!IMPORTANT]
|
|
667
|
+
> **À cette étape, l'auteur de la frappe vient du client** — comme le pseudo du message de l'étape 1,
|
|
668
|
+
> et pour la même raison : il n'y a encore ni comptes ni salons, un seul salon ouvert à tous. Ce
|
|
669
|
+
> n'est pas la forme finale. Dès l'[étape 5](#-étape-5--protéger-le-salon), l'auteur vient de la
|
|
670
|
+
> **connexion** (`this.user`), jamais d'un champ : un « Untel écrit » forgé usurpe aussi bien qu'un
|
|
671
|
+
> message (voir le [contrôleur complet](#-le-contrôleur-complet)).
|
|
654
672
|
|
|
655
673
|
### Le contrôleur — fichier **modifié**
|
|
656
674
|
|
|
657
|
-
|
|
658
|
-
|
|
675
|
+
Le contrôleur entier à cette étape ; l'envoi de l'étape 1 (`chat:send`) ne change pas et n'est pas
|
|
676
|
+
répété.
|
|
659
677
|
|
|
660
|
-
|
|
661
|
-
static present = new Set<string>();
|
|
678
|
+
<!-- example: chatPresence.ts -->
|
|
662
679
|
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
680
|
+
```ts
|
|
681
|
+
import { controller, route } from "@nodefony/framework";
|
|
682
|
+
import type { ContextType } from "@nodefony/http";
|
|
683
|
+
import {
|
|
684
|
+
RealtimeChannel,
|
|
685
|
+
RealtimeController,
|
|
686
|
+
RealtimeInbound,
|
|
687
|
+
getRealtimeHub,
|
|
688
|
+
} from "@nodefony/realtime";
|
|
689
|
+
|
|
690
|
+
/** Le canal du salon : il porte les messages, la frappe — et la présence. */
|
|
691
|
+
export const ROOM_CHANNEL = "chat:room";
|
|
692
|
+
|
|
693
|
+
/** « Untel écrit » : un message ÉPHÉMÈRE du canal, jamais gardé, jamais rejoué. */
|
|
694
|
+
export interface ChatTyping {
|
|
695
|
+
typing: string;
|
|
676
696
|
}
|
|
677
697
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
698
|
+
@controller("/chat")
|
|
699
|
+
export class ChatController extends RealtimeController {
|
|
700
|
+
constructor(context: ContextType) {
|
|
701
|
+
super("chat", context);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
@route("chat-realtime", {
|
|
705
|
+
path: "/realtime",
|
|
706
|
+
requirements: { methods: ["WEBSOCKET"] },
|
|
707
|
+
})
|
|
708
|
+
async realtime(message: string | Uint8Array | null): Promise<void> {
|
|
709
|
+
this.handleRealtime(message);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// La présence est portée PAR le canal : être abonné, c'est être présent. Ni
|
|
713
|
+
// minuteur, ni liste à tenir, ni second canal — et elle reste juste quand
|
|
714
|
+
// l'application tourne sur plusieurs pods (étape 7).
|
|
715
|
+
@RealtimeChannel(ROOM_CHANNEL, { presence: true })
|
|
716
|
+
room(): () => void {
|
|
717
|
+
return () => {};
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// La frappe n'est PAS un état de présence : c'est un message du canal, que
|
|
721
|
+
// chacun reçoit parce qu'il y est abonné, et que l'écran efface seul.
|
|
722
|
+
@RealtimeInbound("chat:typing")
|
|
723
|
+
onTyping(params: unknown): void {
|
|
724
|
+
if (!ChatController.isTyping(params)) return;
|
|
725
|
+
const typing: ChatTyping = { typing: params.author.slice(0, 40) };
|
|
726
|
+
getRealtimeHub().publish(ROOM_CHANNEL, typing);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** Ce qui arrive du réseau n'est pas fiable : un auteur non vide, ou rien. */
|
|
730
|
+
private static isTyping(value: unknown): value is { author: string } {
|
|
731
|
+
return (
|
|
732
|
+
typeof value === "object" &&
|
|
733
|
+
value !== null &&
|
|
734
|
+
"author" in value &&
|
|
735
|
+
typeof value.author === "string" &&
|
|
736
|
+
value.author.length > 0
|
|
737
|
+
);
|
|
738
|
+
}
|
|
682
739
|
}
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
### Côté client
|
|
743
|
+
|
|
744
|
+
`onPresence` rappelle l'écran avec **la vue entière** à chaque changement : il n'y a rien à
|
|
745
|
+
fusionner soi-même. En React, `useNodefonyPresence(chatSocket, "chat:room")` rend le même
|
|
746
|
+
instantané (Vue, Angular et Svelte ont leur équivalent).
|
|
683
747
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
748
|
+
<!-- example: chatPresence.client.ts -->
|
|
749
|
+
|
|
750
|
+
```ts
|
|
751
|
+
import { NodefonySocket } from "nodefony/client";
|
|
752
|
+
|
|
753
|
+
const ROOM = "chat:room";
|
|
754
|
+
const socket = NodefonySocket.shared({ url: "/chat/realtime" });
|
|
755
|
+
|
|
756
|
+
/** Qui est là : rappelé à chaque changement, la vue entière à chaque fois. */
|
|
757
|
+
export function watchPresence(render: (line: string) => void): () => void {
|
|
758
|
+
const off = socket.onPresence(ROOM, (p) => {
|
|
759
|
+
if (!p.ready) return; // « pas encore reçu » n'est pas « vide »
|
|
760
|
+
const names = [...p.members.keys()].join(", ");
|
|
761
|
+
const total = p.count + p.unnamed; // nommés (comptes) + connexions anonymes
|
|
762
|
+
render(`${total} présent(s)${names === "" ? "" : ` : ${names}`}`);
|
|
763
|
+
});
|
|
764
|
+
socket.subscribe(ROOM);
|
|
765
|
+
return () => {
|
|
766
|
+
off();
|
|
767
|
+
socket.unsubscribe(ROOM);
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** Signaler qu'on écrit — au plus une fois toutes les deux secondes. */
|
|
772
|
+
export function typingNotifier(author: string): () => void {
|
|
773
|
+
let last = 0;
|
|
774
|
+
return () => {
|
|
775
|
+
const now = Date.now();
|
|
776
|
+
if (now - last < 2000) return;
|
|
777
|
+
last = now;
|
|
778
|
+
socket.emit("chat:typing", { author });
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** « Untel écrit… » : affiché à réception, effacé seul trois secondes plus tard. */
|
|
783
|
+
export function watchTyping(
|
|
784
|
+
self: string,
|
|
785
|
+
show: (author: string | null) => void,
|
|
786
|
+
): () => void {
|
|
787
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
788
|
+
return socket.on(ROOM, (frame) => {
|
|
789
|
+
if (typeof frame !== "object" || frame === null || !("typing" in frame))
|
|
790
|
+
return;
|
|
791
|
+
if (typeof frame.typing !== "string" || frame.typing === self) return;
|
|
792
|
+
show(frame.typing);
|
|
793
|
+
clearTimeout(timer);
|
|
794
|
+
timer = setTimeout(() => show(null), 3000);
|
|
795
|
+
});
|
|
690
796
|
}
|
|
691
797
|
```
|
|
692
798
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
799
|
+
| Champ de l'instantané | Ce qu'il dit |
|
|
800
|
+
| --------------------- | ------------------------------------------------------------------------------------------------------------- |
|
|
801
|
+
| `ready` | `false` tant que la première liste n'est pas arrivée — « pas encore reçu » n'est pas « vide » |
|
|
802
|
+
| `count` | les présents **nommés** : un par compte, quel que soit son nombre d'onglets |
|
|
803
|
+
| `unnamed` | les connexions comptées **sans nom** : anonymes, appelants machine sans compte |
|
|
804
|
+
| `members` | `Map` identité → `{ tabs }` : qui, et dans combien d'onglets |
|
|
805
|
+
| `partial` | `true` quand la vue est incomplète ou figée (connexion coupée, pod injoignable) — la liste suivante la répare |
|
|
697
806
|
|
|
698
|
-
###
|
|
807
|
+
### À cette étape, personne n'est nommé
|
|
699
808
|
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
809
|
+
Tout le monde est encore anonyme : la présence **compte** les connexions (`unnamed`), elle ne nomme
|
|
810
|
+
personne. Et le pseudo que le client envoie avec son message n'y change rien — se déclarer « Alice »
|
|
811
|
+
ne fait pas de toi une présente nommée. Les noms arrivent à l'[étape 5](#-étape-5--protéger-le-salon),
|
|
812
|
+
avec les comptes : la présence nomme ce que le serveur a vérifié, jamais ce que le client affirme.
|
|
813
|
+
|
|
814
|
+
### Le test — fichier **créé**
|
|
815
|
+
|
|
816
|
+
Le test joue des connexions réelles contre le contrôleur, et lit ce que **l'écran** de chacune
|
|
817
|
+
afficherait : les trames de présence reçues, appliquées par la même vue que le navigateur.
|
|
818
|
+
|
|
819
|
+
<!-- example: chatPresence.test.ts -->
|
|
820
|
+
|
|
821
|
+
```ts
|
|
822
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
823
|
+
import {
|
|
824
|
+
PresenceView,
|
|
825
|
+
REALTIME_PRESENCE_METHOD,
|
|
826
|
+
isPresenceFrame,
|
|
827
|
+
} from "nodefony";
|
|
828
|
+
import type { IPresenceSnapshot } from "nodefony";
|
|
829
|
+
import { getRealtimeHub } from "@nodefony/realtime";
|
|
830
|
+
import { createRealtimeHarness } from "@nodefony/realtime/testing";
|
|
831
|
+
import type { IRealtimeHarness } from "@nodefony/realtime/testing";
|
|
832
|
+
import { createTestIdentity } from "@nodefony/security/testing";
|
|
833
|
+
import { ChatController, ROOM_CHANNEL } from "./chatPresence";
|
|
834
|
+
|
|
835
|
+
const open: IRealtimeHarness<ChatController>[] = [];
|
|
836
|
+
const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
|
|
837
|
+
|
|
838
|
+
/** Une connexion : un compte (`name`), ou un anonyme. */
|
|
839
|
+
async function connect(
|
|
840
|
+
name?: string,
|
|
841
|
+
): Promise<IRealtimeHarness<ChatController>> {
|
|
842
|
+
const h = createRealtimeHarness((ctx) => new ChatController(ctx), {
|
|
843
|
+
...(name === undefined
|
|
844
|
+
? {}
|
|
845
|
+
: { identity: createTestIdentity({ identifier: name }) }),
|
|
846
|
+
resetHub: open.length === 0,
|
|
847
|
+
});
|
|
848
|
+
await h.connect();
|
|
849
|
+
open.push(h);
|
|
850
|
+
return h;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** Ce que l'écran de cette connexion affiche : ses trames de présence, appliquées comme le fait le navigateur. */
|
|
854
|
+
function screenOf(h: IRealtimeHarness<ChatController>): IPresenceSnapshot {
|
|
855
|
+
const view = new PresenceView(ROOM_CHANNEL);
|
|
856
|
+
for (const frame of h.received) {
|
|
857
|
+
if (
|
|
858
|
+
frame.method === REALTIME_PRESENCE_METHOD &&
|
|
859
|
+
isPresenceFrame(frame.params)
|
|
860
|
+
)
|
|
861
|
+
view.apply(frame.params);
|
|
862
|
+
}
|
|
863
|
+
return view.snapshot();
|
|
864
|
+
}
|
|
716
865
|
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
866
|
+
/** Attendre un FAIT, borné : les changements partent groupés, par fenêtre de 250 ms. */
|
|
867
|
+
async function until(fact: () => boolean): Promise<void> {
|
|
868
|
+
for (let i = 0; i < 100 && !fact(); i++)
|
|
869
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
870
|
+
expect(fact()).toBe(true);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
describe("salon — la présence portée par le canal, la frappe en message éphémère", () => {
|
|
874
|
+
afterEach(() => {
|
|
875
|
+
for (const h of open.splice(0)) h.close();
|
|
876
|
+
getRealtimeHub().clear();
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
it("être abonné, c'est être présent : un compte est nommé, un anonyme compté sans nom", async () => {
|
|
880
|
+
const alice = await connect("alice");
|
|
881
|
+
const guest = await connect();
|
|
882
|
+
await alice.subscribe(ROOM_CHANNEL);
|
|
883
|
+
await guest.subscribe(ROOM_CHANNEL);
|
|
884
|
+
await flush();
|
|
885
|
+
// Le dernier arrivé reçoit la liste complète dès son abonnement.
|
|
886
|
+
const seen = screenOf(guest);
|
|
887
|
+
expect(seen.ready).toBe(true);
|
|
888
|
+
expect([...seen.members.keys()]).toEqual(["alice"]);
|
|
889
|
+
expect([seen.count, seen.unnamed]).toEqual([1, 1]);
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
it("se déclarer « bob » ne nomme personne : seul un compte est nommé", async () => {
|
|
893
|
+
const guest = await connect();
|
|
894
|
+
await guest.subscribe(ROOM_CHANNEL);
|
|
895
|
+
await guest.notify("chat:typing", { author: "bob" });
|
|
896
|
+
await flush();
|
|
897
|
+
expect(getRealtimeHub().presence(ROOM_CHANNEL)).toMatchObject({
|
|
898
|
+
count: 0,
|
|
899
|
+
unnamed: 1,
|
|
900
|
+
});
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
it("la frappe arrive comme un message du canal, et ne touche pas la présence", async () => {
|
|
904
|
+
const alice = await connect("alice");
|
|
905
|
+
const bob = await connect("bob");
|
|
906
|
+
await alice.subscribe(ROOM_CHANNEL);
|
|
907
|
+
await bob.subscribe(ROOM_CHANNEL);
|
|
908
|
+
await bob.notify("chat:typing", { author: "bob" });
|
|
909
|
+
await flush();
|
|
910
|
+
expect(alice.messages(ROOM_CHANNEL)).toEqual([{ typing: "bob" }]);
|
|
911
|
+
expect(getRealtimeHub().presence(ROOM_CHANNEL)?.members.get("bob")).toEqual(
|
|
912
|
+
{
|
|
913
|
+
tabs: 1,
|
|
914
|
+
},
|
|
915
|
+
);
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
it("connexion fermée : le présent s'en va, chez le serveur PUIS sur l'écran des autres", async () => {
|
|
919
|
+
const alice = await connect("alice");
|
|
920
|
+
const bob = await connect("bob");
|
|
921
|
+
await alice.subscribe(ROOM_CHANNEL);
|
|
922
|
+
await bob.subscribe(ROOM_CHANNEL);
|
|
923
|
+
await until(() => screenOf(alice).members.has("bob"));
|
|
924
|
+
bob.close();
|
|
925
|
+
await flush();
|
|
926
|
+
expect(getRealtimeHub().presence(ROOM_CHANNEL)?.members.has("bob")).toBe(
|
|
927
|
+
false,
|
|
928
|
+
);
|
|
929
|
+
await until(() => !screenOf(alice).members.has("bob"));
|
|
930
|
+
expect(screenOf(alice).count).toBe(1);
|
|
931
|
+
});
|
|
932
|
+
});
|
|
720
933
|
```
|
|
721
934
|
|
|
722
935
|
### Ce qu'on observe
|
|
723
936
|
|
|
724
|
-
| Tu fais | Tu vois
|
|
725
|
-
| --------------------------------------- |
|
|
726
|
-
| Tu
|
|
727
|
-
| Tu
|
|
728
|
-
| Tu
|
|
937
|
+
| Tu fais | Tu vois |
|
|
938
|
+
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
939
|
+
| Tu ouvres deux onglets | « 2 présent(s) » dans chacun, sans nom : ce sont deux anonymes. |
|
|
940
|
+
| Tu tapes dans l'onglet gauche | « invité-412 est en train d'écrire… » apparaît dans l'onglet droit, puis disparaît seul. La liste des présents ne bouge pas. |
|
|
941
|
+
| Tu fermes l'onglet gauche | L'onglet droit passe à « 1 présent(s) » dans le quart de seconde. |
|
|
942
|
+
| Tu ouvres l'onglet réseau du navigateur | **Une seule** entrée WebSocket et **un seul** canal : messages, frappe et présence voyagent ensemble. |
|
|
943
|
+
|
|
944
|
+
### Le même modèle que les normes
|
|
945
|
+
|
|
946
|
+
La présence suit le modèle des protocoles de présence normalisés, pour qu'une passerelle reste
|
|
947
|
+
possible : un **présent** est l'_occupant_ d'un salon de XEP-0045 ; la liste entière puis les
|
|
948
|
+
changements sont la _full_ puis la _partial presence_ de RFC 5262 ; un abonnement répété redemande
|
|
949
|
+
la liste entière, comme XEP-0045 §17.3 ; et « en train d'écrire » reste hors de la présence, comme
|
|
950
|
+
le veut RFC 6121 §4.4.1. Le détail des choix est dans l'ADR-0025 (présence répartie d'un canal).
|
|
729
951
|
|
|
730
952
|
## 💾 Étape 4 : persister l'historique
|
|
731
953
|
|
|
@@ -830,26 +1052,25 @@ export const securityConfig = () =>
|
|
|
830
1052
|
|
|
831
1053
|
Le décorateur de canal accepte un second argument : les exigences à satisfaire pour s'y abonner.
|
|
832
1054
|
|
|
833
|
-
|
|
1055
|
+
La déclaration du salon, complète, avec sa politique :
|
|
834
1056
|
|
|
835
1057
|
```ts ignore
|
|
836
1058
|
// modules/chat/nodefony/controllers/ChatController.ts
|
|
837
|
-
// → remplace
|
|
838
|
-
@RealtimeChannel(ROOM_CHANNEL, {
|
|
839
|
-
|
|
1059
|
+
// → remplace la déclaration de canal de l'étape 3
|
|
1060
|
+
@RealtimeChannel(ROOM_CHANNEL, {
|
|
1061
|
+
authenticated: true,
|
|
1062
|
+
roles: ["ROLE_USER"],
|
|
1063
|
+
presence: true,
|
|
1064
|
+
})
|
|
1065
|
+
room(): () => void {
|
|
840
1066
|
return () => {};
|
|
841
1067
|
}
|
|
842
|
-
|
|
843
|
-
@RealtimeChannel("chat:presence", { authenticated: true, roles: ["ROLE_USER"] })
|
|
844
|
-
presence(channel: string, publish: RealtimePublish): () => void {
|
|
845
|
-
const timer = setInterval(() => {
|
|
846
|
-
publish(channel, { present: [...ChatController.present] });
|
|
847
|
-
}, 5000);
|
|
848
|
-
timer.unref();
|
|
849
|
-
return () => clearInterval(timer);
|
|
850
|
-
}
|
|
851
1068
|
```
|
|
852
1069
|
|
|
1070
|
+
La présence suit la politique : n'est présent que celui qu'elle admet. Et puisque chacun est
|
|
1071
|
+
désormais connecté à son compte, la liste **nomme** les présents — par l'identité vérifiée à la
|
|
1072
|
+
poignée de main, jamais par un champ du client.
|
|
1073
|
+
|
|
853
1074
|
Le module temps réel ne décide de rien : il **transporte la déclaration** jusqu'au firewall, seul à
|
|
854
1075
|
connaître la hiérarchie des rôles et l'identité réelle.
|
|
855
1076
|
|
|
@@ -926,8 +1147,11 @@ await socket.post("/chat/send", { text });
|
|
|
926
1147
|
> **La règle à retenir.** Canal inbound = flux volatil, à haute fréquence, dont l'expéditeur importe
|
|
927
1148
|
> peu (« untel écrit »). Action de contrôleur = tout ce qui doit être **attribué, autorisé ou
|
|
928
1149
|
> conservé**. Le chat de l'étape 1 utilisait un canal inbound par simplicité ; dès qu'il y a des
|
|
929
|
-
> comptes, l'envoi devient une action. La frappe en cours, elle, reste un canal inbound
|
|
930
|
-
>
|
|
1150
|
+
> comptes, l'envoi devient une action. La frappe en cours, elle, reste un canal inbound : rien n'y
|
|
1151
|
+
> est conservé. Mais son auteur se lit sur la **connexion** (`this.user`), plus dans les paramètres
|
|
1152
|
+
> — sinon n'importe qui ferait écrire « Alice ». Avec **plusieurs salons à admission**, un canal
|
|
1153
|
+
> inbound ne sait pas dans quel salon la connexion est admise : la frappe devient alors une route,
|
|
1154
|
+
> qui relit l'admission du salon comme l'envoi (c'est ce que fait la démo de `create app`).
|
|
931
1155
|
|
|
932
1156
|
Côté navigateur, l'identité annoncée par le serveur se lit sans appeler la moindre route :
|
|
933
1157
|
|
|
@@ -966,11 +1190,12 @@ export const SendBody = z.object({
|
|
|
966
1190
|
to: z.string().optional(),
|
|
967
1191
|
});
|
|
968
1192
|
|
|
969
|
-
/**
|
|
1193
|
+
/**
|
|
1194
|
+
* La query de l'historique — une query arrive en texte, `coerce` la convertit.
|
|
1195
|
+
* Pas de `since` sur un instant : voir « La reconnexion », plus bas.
|
|
1196
|
+
*/
|
|
970
1197
|
export const HistoryQuery = z.object({
|
|
971
1198
|
limit: z.coerce.number().int().min(1).max(200).default(50),
|
|
972
|
-
/** Rattrapage après une coupure : les messages postérieurs à cet instant. */
|
|
973
|
-
since: z.coerce.number().optional(),
|
|
974
1199
|
});
|
|
975
1200
|
```
|
|
976
1201
|
|
|
@@ -1370,6 +1595,21 @@ use("@nodefony/realtime", {
|
|
|
1370
1595
|
});
|
|
1371
1596
|
```
|
|
1372
1597
|
|
|
1598
|
+
### Ce que le relais ne partage pas : l'état
|
|
1599
|
+
|
|
1600
|
+
Le backplane transporte des **trames**, rien d'autre. Ce que l'application **garde** — l'historique,
|
|
1601
|
+
les pièces jointes, la liste des salons — ne traverse pas avec lui : chaque pod lit et écrit le sien.
|
|
1602
|
+
Plusieurs pods exigent donc un état **partagé** :
|
|
1603
|
+
|
|
1604
|
+
- **la base** : PostgreSQL ou MySQL, que tous les pods joignent. Un fichier SQLite vit sur le disque
|
|
1605
|
+
d'UNE machine : c'est le mode d'un poste de développement ou d'une démo, pas d'une application
|
|
1606
|
+
répartie ;
|
|
1607
|
+
- **les fichiers** : un stockage partagé (volume réseau, stockage objet), jamais le disque local
|
|
1608
|
+
d'un pod — une pièce reçue par l'un serait introuvable depuis l'autre.
|
|
1609
|
+
|
|
1610
|
+
La démo de `create app --demo` tient sur un seul pod, pour cette raison : sa base est un fichier
|
|
1611
|
+
SQLite et ses pièces restent sur le disque du serveur.
|
|
1612
|
+
|
|
1373
1613
|
### Ce qui n'a pas bougé
|
|
1374
1614
|
|
|
1375
1615
|
C'est l'argument, et il se vérifie fichier par fichier :
|
|
@@ -1382,6 +1622,10 @@ C'est l'argument, et il se vérifie fichier par fichier :
|
|
|
1382
1622
|
| `ChatRoom.tsx`, `realtime.ts` | **Non.** Le navigateur ne sait pas, et n'a pas à savoir, combien de pods existent. |
|
|
1383
1623
|
| `nodefony.config.ts` | Une chaîne de caractères. |
|
|
1384
1624
|
|
|
1625
|
+
La présence suit le même chemin : un canal diffusé partage ses présents entre les pods, sans une
|
|
1626
|
+
ligne de plus. Un canal à présence **non** diffusé reste juste, mais local à son processus — le
|
|
1627
|
+
journal le signale une fois, au premier abonné du canal, dès qu'un bus est configuré.
|
|
1628
|
+
|
|
1385
1629
|
Aucun appel de publication ne change, aucun abonnement ne change, aucune règle métier ne change. Ce
|
|
1386
1630
|
qui change, c'est **par où** la publication repart après le fan-out local — et cela se décide dans la
|
|
1387
1631
|
configuration, pas dans le code.
|
|
@@ -1411,11 +1655,14 @@ Au démarrage de chaque worker, la ligne d'identité du temps réel change de fo
|
|
|
1411
1655
|
`kind=local cross-pod=no`, on lit maintenant un `kind` de transport et `cross-pod=yes` : c'est la
|
|
1412
1656
|
confirmation, au boot, que le relais est en place.
|
|
1413
1657
|
|
|
1414
|
-
| Tu fais
|
|
1415
|
-
|
|
|
1416
|
-
| Alice envoie un fichier
|
|
1417
|
-
| Alice tape un message
|
|
1418
|
-
|
|
|
1658
|
+
| Tu fais | Avant (étape 6) | Maintenant |
|
|
1659
|
+
| ------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------- |
|
|
1660
|
+
| Alice envoie un fichier | Bob ne voit rien | L'annonce apparaît chez Bob, sur l'autre processus |
|
|
1661
|
+
| Alice tape un message | Aléatoire | Reçu par tous, quel que soit le worker |
|
|
1662
|
+
| Alice et Bob, un worker chacun | Chacun ne voit que lui présent | Les deux se voient présents ; un onglet sur chaque worker compte pour un seul présent |
|
|
1663
|
+
| Tu arrêtes un worker proprement | — | Ses présents disparaissent chez les autres aussitôt : il l'annonce en partant |
|
|
1664
|
+
| Tu tues un worker (`kill -9`) | — | Ses présents disparaissent chez les autres au délai d'oubli : trois preuves de vie manquées, 15 s par défaut |
|
|
1665
|
+
| Tu arrêtes le service Redis | — | Le salon continue **dans chaque processus**, isolément ; la présence venue des autres pods se fige, marquée `partial` |
|
|
1419
1666
|
|
|
1420
1667
|
Cette dernière ligne mérite une explication : si le backplane est injoignable au démarrage, le
|
|
1421
1668
|
framework **ne bloque pas le boot**. Il journalise un avertissement, garde un hub local et sert le
|
|
@@ -1456,7 +1703,6 @@ import {
|
|
|
1456
1703
|
RealtimeInbound,
|
|
1457
1704
|
getRealtimeHub,
|
|
1458
1705
|
} from "@nodefony/realtime";
|
|
1459
|
-
import type { RealtimePublish } from "@nodefony/realtime";
|
|
1460
1706
|
import { HttpError } from "@nodefony/http";
|
|
1461
1707
|
import type { ContextType, IUploadedFile } from "@nodefony/http";
|
|
1462
1708
|
import type { ContractRouteSchemas } from "nodefony";
|
|
@@ -1486,9 +1732,6 @@ class ChatController extends ContractRealtimeController<ChatContract> {
|
|
|
1486
1732
|
/** Stock en mémoire — remplacé par un repository à l'étape 4. */
|
|
1487
1733
|
static history: ChatMessage[] = [];
|
|
1488
1734
|
|
|
1489
|
-
/** Les pseudos présents dans le salon, sur CE processus. */
|
|
1490
|
-
static present = new Set<string>();
|
|
1491
|
-
|
|
1492
1735
|
constructor(context: ContextType) {
|
|
1493
1736
|
super("chat", context);
|
|
1494
1737
|
}
|
|
@@ -1519,10 +1762,7 @@ class ChatController extends ContractRealtimeController<ChatContract> {
|
|
|
1519
1762
|
historyAction(
|
|
1520
1763
|
input: RouteInput<{ query: typeof HistoryQuery }>,
|
|
1521
1764
|
): ChatMessage[] {
|
|
1522
|
-
|
|
1523
|
-
return ChatController.history
|
|
1524
|
-
.filter((m) => since === undefined || m.sentAt > since)
|
|
1525
|
-
.slice(-limit);
|
|
1765
|
+
return ChatController.history.slice(-input.query.limit);
|
|
1526
1766
|
}
|
|
1527
1767
|
|
|
1528
1768
|
/** Envoi — au salon, ou en privé à une personne ; l'auteur vient du serveur. */
|
|
@@ -1583,41 +1823,29 @@ class ChatController extends ContractRealtimeController<ChatContract> {
|
|
|
1583
1823
|
|
|
1584
1824
|
// ── Les canaux ────────────────────────────────────────────────────────────
|
|
1585
1825
|
|
|
1586
|
-
/**
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
/** Présence — un rappel régulier, arrêté au dernier désabonné. */
|
|
1593
|
-
@RealtimeChannel("chat:presence", {
|
|
1826
|
+
/**
|
|
1827
|
+
* Le salon. Rien à démarrer : il relaie, il n'émet pas de lui-même. Sa
|
|
1828
|
+
* présence est tenue par le framework, entre les pods compris.
|
|
1829
|
+
*/
|
|
1830
|
+
@RealtimeChannel(ROOM_CHANNEL, {
|
|
1594
1831
|
authenticated: true,
|
|
1595
1832
|
roles: ["ROLE_USER"],
|
|
1833
|
+
presence: true,
|
|
1596
1834
|
})
|
|
1597
|
-
|
|
1598
|
-
const timer = setInterval(() => {
|
|
1599
|
-
publish(channel, { present: [...ChatController.present] });
|
|
1600
|
-
}, 5000);
|
|
1601
|
-
timer.unref();
|
|
1602
|
-
return () => clearInterval(timer);
|
|
1603
|
-
}
|
|
1604
|
-
|
|
1605
|
-
/** Frappe en cours — volatile : ni attribué, ni conservé. */
|
|
1606
|
-
@RealtimeChannel("chat:typing")
|
|
1607
|
-
typing(_channel: string, _publish: RealtimePublish): () => void {
|
|
1835
|
+
room(): () => void {
|
|
1608
1836
|
return () => {};
|
|
1609
1837
|
}
|
|
1610
1838
|
|
|
1611
|
-
/**
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
if (
|
|
1620
|
-
getRealtimeHub().publish(
|
|
1839
|
+
/**
|
|
1840
|
+
* Frappe en cours : un message ÉPHÉMÈRE du salon, ni conservé ni rejoué. Le
|
|
1841
|
+
* seul canal entrant restant — l'auteur est celui de la connexion, pas un
|
|
1842
|
+
* champ du client.
|
|
1843
|
+
*/
|
|
1844
|
+
@RealtimeInbound("chat:typing", { authenticated: true })
|
|
1845
|
+
onTyping(): void {
|
|
1846
|
+
const author = this.user?.identifier;
|
|
1847
|
+
if (author === undefined) return;
|
|
1848
|
+
getRealtimeHub().publish(ROOM_CHANNEL, { typing: author });
|
|
1621
1849
|
}
|
|
1622
1850
|
}
|
|
1623
1851
|
|
|
@@ -1641,20 +1869,40 @@ hooks React ne se démontent pas ; tes composants ne voient rien passer. Côté
|
|
|
1641
1869
|
libère les abonnements et détruit les providers devenus inutiles.
|
|
1642
1870
|
|
|
1643
1871
|
**Ce qui reste à ta charge** : **le trou**. Les messages publiés pendant la coupure ne sont pas
|
|
1644
|
-
rejoués — le hub distribue à qui est là, il ne conserve rien. À la reconnexion,
|
|
1645
|
-
l'
|
|
1872
|
+
rejoués — le hub distribue à qui est là, il ne conserve rien. À la reconnexion, attends que
|
|
1873
|
+
l'abonnement soit **effectif**, relis les derniers messages, et **recoupe-les par identifiant** :
|
|
1646
1874
|
|
|
1647
1875
|
```tsx ignore
|
|
1648
1876
|
// Au retour de `reconnecting` vers `connected`, on comble le trou.
|
|
1649
1877
|
useEffect(() => {
|
|
1650
1878
|
if (state !== "connected") return;
|
|
1651
|
-
const lastSeen = messages.at(-1)?.sentAt ?? 0;
|
|
1652
1879
|
void socket
|
|
1653
|
-
|
|
1654
|
-
.
|
|
1880
|
+
// Lire AVANT l'abonnement effectif perdrait ce qui se publie entre les deux.
|
|
1881
|
+
.whenSubscribed("chat:room")
|
|
1882
|
+
.then(() =>
|
|
1883
|
+
socket.get<ChatMessage[]>("/chat/history", { query: { limit: 50 } }),
|
|
1884
|
+
)
|
|
1885
|
+
.then((page) =>
|
|
1886
|
+
setMessages((previous) => {
|
|
1887
|
+
// Par `id` : un message arrivé par les DEUX chemins n'apparaît qu'une fois.
|
|
1888
|
+
const byId = new Map(previous.map((m) => [m.id, m]));
|
|
1889
|
+
for (const m of page) byId.set(m.id, m);
|
|
1890
|
+
return [...byId.values()];
|
|
1891
|
+
}),
|
|
1892
|
+
);
|
|
1655
1893
|
}, [state]);
|
|
1656
1894
|
```
|
|
1657
1895
|
|
|
1896
|
+
Deux pièges, que ce code évite :
|
|
1897
|
+
|
|
1898
|
+
- **Un rattrapage borné par l'heure** (`since: dernierMessage.sentAt`) double ou perd des messages :
|
|
1899
|
+
chaque pod a son horloge, et deux messages de la même milliseconde se départagent mal. L'heure
|
|
1900
|
+
sert à afficher, jamais à rattraper.
|
|
1901
|
+
- **Une page qui ne recoupe rien** de ce que l'écran montrait signale un trou plus long qu'elle :
|
|
1902
|
+
les messages plus anciens ne sont plus contigus. La démo de `create app --demo` les retire
|
|
1903
|
+
plutôt que de les afficher de part et d'autre d'un silence qui n'a pas eu lieu
|
|
1904
|
+
(`catchUpMessages`).
|
|
1905
|
+
|
|
1658
1906
|
### Les messages en double
|
|
1659
1907
|
|
|
1660
1908
|
**Symptôme** — le même message apparaît deux fois.
|