@nodefony/realtime 10.0.0-alpha.1
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/LICENSE +544 -0
- package/README.md +207 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/index.js +218 -0
- package/dist/nodefony/config/config.js +63 -0
- package/dist/nodefony/config/defineModuleConfig.js +60 -0
- package/dist/nodefony/decorators/realtimeDecorators.js +240 -0
- package/dist/nodefony/interfaces/IBackplane.js +1 -0
- package/dist/nodefony/interfaces/IChannelPolicy.js +1 -0
- package/dist/nodefony/interfaces/IClientLogsLimits.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeAuthenticator.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeAuthenticatorMatcher.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeController.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeHandshake.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeProbe.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeToken.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/src/backplane/ClusterBackplane.js +105 -0
- package/dist/nodefony/src/backplane/LoopbackBackplane.js +40 -0
- package/dist/nodefony/src/backplane/RedisBackplane.js +176 -0
- package/dist/nodefony/src/backplane/backplaneRegistry.js +20 -0
- package/dist/nodefony/src/backplane/envelope.js +109 -0
- package/dist/nodefony/src/backplane/originId.js +35 -0
- package/dist/nodefony/src/backplane/publishQueue.js +96 -0
- package/dist/nodefony/src/cluster/ClusterProbeClient.js +284 -0
- package/dist/nodefony/src/server/AnonymousRealtimeToken.js +35 -0
- package/dist/nodefony/src/server/RealtimeAdminApi.js +87 -0
- package/dist/nodefony/src/server/RealtimeController.js +601 -0
- package/dist/nodefony/src/server/RealtimeHub.js +926 -0
- package/dist/nodefony/src/server/ServerRealtimeSocket.js +168 -0
- package/dist/nodefony/src/server/deniedDetail.js +29 -0
- package/dist/nodefony/src/server/syslogUplink.js +105 -0
- package/dist/nodefony/src/server/welcomeEnv.js +25 -0
- package/dist/nodefony/src/service/RealtimeService.js +240 -0
- package/dist/nodefony/src/transport/WsConnectionTransport.js +94 -0
- package/dist/nodefony/testing/index.js +163 -0
- package/dist/types/index.d.ts +99 -0
- package/dist/types/nodefony/config/config.d.ts +43 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +49 -0
- package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +190 -0
- package/dist/types/nodefony/interfaces/IBackplane.d.ts +128 -0
- package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +28 -0
- package/dist/types/nodefony/interfaces/IClientLogsLimits.d.ts +22 -0
- package/dist/types/nodefony/interfaces/IRealtimeAuthenticator.d.ts +53 -0
- package/dist/types/nodefony/interfaces/IRealtimeAuthenticatorMatcher.d.ts +46 -0
- package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +36 -0
- package/dist/types/nodefony/interfaces/IRealtimeHandshake.d.ts +27 -0
- package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +206 -0
- package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +48 -0
- package/dist/types/nodefony/interfaces/index.d.ts +18 -0
- package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +76 -0
- package/dist/types/nodefony/src/backplane/LoopbackBackplane.d.ts +29 -0
- package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +164 -0
- package/dist/types/nodefony/src/backplane/backplaneRegistry.d.ts +51 -0
- package/dist/types/nodefony/src/backplane/envelope.d.ts +27 -0
- package/dist/types/nodefony/src/backplane/originId.d.ts +22 -0
- package/dist/types/nodefony/src/backplane/publishQueue.d.ts +71 -0
- package/dist/types/nodefony/src/cluster/ClusterProbeClient.d.ts +90 -0
- package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +3 -0
- package/dist/types/nodefony/src/server/RealtimeAdminApi.d.ts +18 -0
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +165 -0
- package/dist/types/nodefony/src/server/RealtimeHub.d.ts +516 -0
- package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +63 -0
- package/dist/types/nodefony/src/server/deniedDetail.d.ts +25 -0
- package/dist/types/nodefony/src/server/syslogUplink.d.ts +58 -0
- package/dist/types/nodefony/src/server/welcomeEnv.d.ts +21 -0
- package/dist/types/nodefony/src/service/RealtimeService.d.ts +167 -0
- package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +78 -0
- package/dist/types/nodefony/testing/index.d.ts +169 -0
- package/docs/actions.md +573 -0
- package/docs/architecture.md +889 -0
- package/docs/configuration.md +803 -0
- package/docs/cookbook-chat.md +1619 -0
- package/docs/couverture-bancs.md +139 -0
- package/docs/index.md +372 -0
- package/docs/observabilite.md +592 -0
- package/docs/protocole.md +518 -0
- package/docs/securite.md +845 -0
- package/docs/vocabulaire.md +845 -0
- package/package.json +83 -0
|
@@ -0,0 +1,889 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Architecture — la socket, le hub, le backplane"
|
|
3
|
+
navTitle: Architecture
|
|
4
|
+
lang: fr
|
|
5
|
+
module: "@nodefony/realtime"
|
|
6
|
+
topic: realtime
|
|
7
|
+
coverageModule: realtime
|
|
8
|
+
section: "Realtime"
|
|
9
|
+
audience: [developer]
|
|
10
|
+
tags:
|
|
11
|
+
[
|
|
12
|
+
realtime,
|
|
13
|
+
architecture,
|
|
14
|
+
hub,
|
|
15
|
+
backplane,
|
|
16
|
+
fan-out,
|
|
17
|
+
websocket,
|
|
18
|
+
json-rpc,
|
|
19
|
+
cluster,
|
|
20
|
+
sonde,
|
|
21
|
+
]
|
|
22
|
+
version: "doc"
|
|
23
|
+
status: stable
|
|
24
|
+
updated: 2026-07-19
|
|
25
|
+
source: "src/packages/@nodefony/realtime/docs/architecture.md"
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
# Architecture — la socket, le hub, le backplane
|
|
29
|
+
|
|
30
|
+
> Une application temps réel Nodefony ouvre **une seule** connexion WebSocket par client
|
|
31
|
+
> et fait voyager **N canaux** dedans. Cette page explique la machinerie qui rend ça
|
|
32
|
+
> possible : le protocole qui étiquette les messages, le **hub** qui aiguille les abonnés
|
|
33
|
+
> d'un process, et le **backplane** qui franchit la frontière du process quand tu passes à
|
|
34
|
+
> plusieurs workers ou plusieurs pods. Tout est ancré sur
|
|
35
|
+
> `src/packages/@nodefony/realtime/nodefony/src/`.
|
|
36
|
+
|
|
37
|
+
📍 [Documentation](../../../../../docs/index.md) › [Realtime](index.md) › **Architecture**
|
|
38
|
+
|
|
39
|
+
```nodefony-livegraph
|
|
40
|
+
{
|
|
41
|
+
"graph": "architecture",
|
|
42
|
+
"height": 520,
|
|
43
|
+
"title": "L'architecture, en direct",
|
|
44
|
+
"hint": "Les quatre étages, avec leurs compteurs réels. OFF = aucun abonnement côté serveur ; ON = la page s'abonne au canal de santé."
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## 🧠 Le modèle mental — une prise, un standard, un fond de panier
|
|
49
|
+
|
|
50
|
+
Trois objets suffisent à comprendre l'ensemble, et ils se racontent avec du matériel bien
|
|
51
|
+
réel :
|
|
52
|
+
|
|
53
|
+
- La **socket**, c'est la **prise murale** que tient ton code. Elle est unique, tu y
|
|
54
|
+
branches ce que tu veux, tu ignores ce qu'il y a derrière le mur.
|
|
55
|
+
- Le **hub**, c'est le **standard téléphonique** d'un immeuble : il tient la liste
|
|
56
|
+
« qui écoute quelle conférence » et recopie chaque message aux bons abonnés.
|
|
57
|
+
- Le **backplane**, c'est le **fond de panier** d'un rack : la carte passive au dos qui
|
|
58
|
+
relie les serveurs entre eux. Il ne fait qu'une chose — porter un message d'un standard
|
|
59
|
+
à l'autre.
|
|
60
|
+
|
|
61
|
+
```mermaid
|
|
62
|
+
flowchart TD
|
|
63
|
+
subgraph POD_A["Process A — 1 hub"]
|
|
64
|
+
C1["Client 1<br/>1 WebSocket"] --> T1["WsConnectionTransport"]
|
|
65
|
+
C2["Client 2<br/>1 WebSocket"] --> T2["WsConnectionTransport"]
|
|
66
|
+
T1 --> P1["JsonRpcPeer<br/>frames JSON-RPC 2.0"]
|
|
67
|
+
T2 --> P2["JsonRpcPeer"]
|
|
68
|
+
P1 --> HUB_A["RealtimeHub<br/>canal → abonnés locaux"]
|
|
69
|
+
P2 --> HUB_A
|
|
70
|
+
SVC["Un service métier<br/>serverSocket().publish()"] --> HUB_A
|
|
71
|
+
end
|
|
72
|
+
HUB_A -->|"canal déclaré broadcast"| BP["IBackplane<br/>loopback · cluster IPC · redis"]
|
|
73
|
+
BP --> HUB_B["RealtimeHub<br/>Process B"]
|
|
74
|
+
HUB_B --> C3["Client 3"]
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Trois règles tiennent tout l'édifice :
|
|
78
|
+
|
|
79
|
+
1. **Chaque étage ignore les étages du dessous.** Ton contrôleur appelle `publish()` ; il
|
|
80
|
+
ne sait pas s'il tourne seul ou dans un cluster de douze pods.
|
|
81
|
+
2. **Le fan-out local est fait AVANT la propagation.** Le hub sert d'abord ses abonnés,
|
|
82
|
+
puis passe le relais au backplane.
|
|
83
|
+
3. **Ce qui arrive du backplane n'y retourne jamais.** C'est la barrière anti-boucle, sans
|
|
84
|
+
laquelle un message tournerait à l'infini entre les pods.
|
|
85
|
+
|
|
86
|
+
## 📖 Lexique
|
|
87
|
+
|
|
88
|
+
| Terme | Sens |
|
|
89
|
+
| ------------- | ------------------------------------------------------------------------------------------------------ |
|
|
90
|
+
| Socket | La prise que tient le métier : un handle, N canaux multiplexés. Contrat `IRealtimeSocket` (isomorphe). |
|
|
91
|
+
| Hub | Le broker d'un process : table « canal → abonnés locaux » + fan-out. Classe `RealtimeHub`. |
|
|
92
|
+
| Peer | Le moteur de protocole d'**une** connexion. Classe `JsonRpcPeer`, identique serveur et navigateur. |
|
|
93
|
+
| Transport | Le câble : ce qui déplace des octets. Côté serveur `WsConnectionTransport`. |
|
|
94
|
+
| Frame | Un message JSON-RPC 2.0 sur le fil (requête, réponse ou notification). |
|
|
95
|
+
| Canal | Un nom logique auquel on s'abonne (`chat:room-42`). Plusieurs canaux partagent une connexion. |
|
|
96
|
+
| Fan-out | Recopier une charge à tous les abonnés d'un canal. |
|
|
97
|
+
| Backplane | Le fond de panier : porte les publications d'un process à l'autre. Contrat `IBackplane`. |
|
|
98
|
+
| Provider | Le producteur d'un canal (ticker, écouteur). **Un seul par canal et par process**, partagé. |
|
|
99
|
+
| Sink | Le point de livraison d'**une** connexion sur un canal. 1 sink = 1 client abonné. |
|
|
100
|
+
| `originId` | L'étiquette du process émetteur, comparée pour jeter les messages qui reviennent à leur source. |
|
|
101
|
+
| Anti-écho | Le filtre qui jette ces retours. Sans lui, un message se dédoublerait à chaque aller-retour. |
|
|
102
|
+
| Backpressure | La file d'envoi non drainée d'un client lent (`bufferedAmount`). Le risque mémoire numéro un. |
|
|
103
|
+
| RPC | _Remote Procedure Call_ : une frame avec `id`, qui attend une réponse corrélée. |
|
|
104
|
+
| Sonde (probe) | Lecture pure de l'état du hub (canaux, fan-out, connexions, backpressure). |
|
|
105
|
+
| IPC | _Inter-Process Communication_ : le canal de messages entre le master d'un cluster Node et ses workers. |
|
|
106
|
+
|
|
107
|
+
## Qu'est-ce qu'une socket multiplexée ?
|
|
108
|
+
|
|
109
|
+
Le réflexe naïf, quand une page a besoin de trois flux temps réel (un chat, un compteur de
|
|
110
|
+
présence, une barre de progression), c'est d'ouvrir trois WebSockets : trois handshakes,
|
|
111
|
+
trois authentifications, trois reconnexions à orchestrer.
|
|
112
|
+
|
|
113
|
+
**Multiplexer**, c'est ouvrir **une** connexion et étiqueter chaque message avec le nom du
|
|
114
|
+
flux auquel il appartient — une multiprise : un seul câble au mur, N appareils derrière. Le
|
|
115
|
+
prix à payer est un protocole d'étiquetage ; le gain, une authentification et un seul état
|
|
116
|
+
de connexion à afficher.
|
|
117
|
+
|
|
118
|
+
Nodefony étiquette avec **JSON-RPC 2.0**, une norme publique plutôt qu'un format maison :
|
|
119
|
+
|
|
120
|
+
| Forme de frame | A un `id` ? | Attend une réponse ? | À quoi ça sert |
|
|
121
|
+
| ---------------- | ------------ | ------------------------------- | ---------------------------------------------- |
|
|
122
|
+
| **requête** | oui | oui (une Promise côté émetteur) | appeler une action et récupérer son résultat |
|
|
123
|
+
| **réponse** | oui (miroir) | non | le résultat, corrélé par son `id` |
|
|
124
|
+
| **notification** | non | non | pousser un événement, s'abonner, se désabonner |
|
|
125
|
+
|
|
126
|
+
Le point remarquable : le **même** moteur de protocole tourne des deux côtés du fil. La
|
|
127
|
+
classe `JsonRpcPeer` (`JsonRpcPeer.ts:271`) est du code isomorphe du cœur — le navigateur
|
|
128
|
+
l'exécute dans `RealtimeClient`, le serveur l'instancie une fois par connexion dans
|
|
129
|
+
`RealtimeController.onHandshake()` (`RealtimeController.ts:312`). Le serveur peut donc
|
|
130
|
+
appeler le client, pas seulement l'inverse : c'est du vrai duplex, pas un aller-retour
|
|
131
|
+
déguisé.
|
|
132
|
+
|
|
133
|
+
## La vision Nodefony — un handle, N canaux, zéro couplage au cluster
|
|
134
|
+
|
|
135
|
+
Trois partis pris distinguent cette pile d'un simple « serveur WebSocket ».
|
|
136
|
+
|
|
137
|
+
**Un seul contrat de socket, des deux côtés.** Un service back qui pousse des événements
|
|
138
|
+
écrit le même code qu'une page front : `publish` / `subscribe` / `on`. Côté serveur c'est
|
|
139
|
+
`ServerRealtimeSocket` (`ServerRealtimeSocket.ts:43`), obtenu par `serverSocket()`
|
|
140
|
+
(`ServerRealtimeSocket.ts:223`) ; côté navigateur, `RealtimeClient`. Une exception, assumée
|
|
141
|
+
et explicite : `ServerRealtimeSocket.request()` (`ServerRealtimeSocket.ts:131`) **rejette
|
|
142
|
+
toujours** — un handle posé sur le hub n'a pas d'interlocuteur unique, puisque le hub est
|
|
143
|
+
multi-clients. Pour un appel serveur→client ciblé, on passe par la connexion :
|
|
144
|
+
`RealtimeController.requestClient()` (`RealtimeController.ts:263`).
|
|
145
|
+
|
|
146
|
+
**Un provider par canal, pas un par client.** Si mille onglets s'abonnent au même canal de
|
|
147
|
+
santé, le calcul ne doit tourner qu'une fois. Le hub crée le producteur au **premier**
|
|
148
|
+
abonné et le détruit au **dernier** (`RealtimeHub.subscribe()`, `RealtimeHub.ts:388`) : le
|
|
149
|
+
coût suit le nombre de canaux, pas le nombre de clients.
|
|
150
|
+
|
|
151
|
+
**Le cluster est un détail de configuration.** Ni ton contrôleur ni ton client ne savent
|
|
152
|
+
combien de process tournent ; le seul objet au courant est le backplane, choisi par une
|
|
153
|
+
chaîne de caractères. Passer de mono-process à multi-pods ne touche aucune ligne applicative.
|
|
154
|
+
|
|
155
|
+
**Le compromis, dit franchement** : un canal **ne franchit PAS** la frontière du process
|
|
156
|
+
tant que tu ne l'as pas déclaré. Le défaut est l'isolement (`RealtimeHub.publish()`,
|
|
157
|
+
`RealtimeHub.ts:530`) — un choix de sûreté détaillé plus bas.
|
|
158
|
+
|
|
159
|
+
## 🚀 Démarrage rapide
|
|
160
|
+
|
|
161
|
+
Un salon de discussion complet, vu d'une application créée par `nodefony create app` :
|
|
162
|
+
trois fichiers, et il fonctionne en mono-process comme en cluster.
|
|
163
|
+
|
|
164
|
+
### 1. Charger le module et choisir le fond de panier
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
// nodefony.config.ts
|
|
168
|
+
import { defineConfig, use } from "nodefony";
|
|
169
|
+
|
|
170
|
+
export default defineConfig(() => ({
|
|
171
|
+
modules: [
|
|
172
|
+
"@nodefony/framework",
|
|
173
|
+
use("@nodefony/realtime", {
|
|
174
|
+
// "loopback" = un seul process : le hub suffit, aucun transport réseau.
|
|
175
|
+
// "cluster" (workers IPC) ou "redis" (multi-pods) sans changer une ligne de code.
|
|
176
|
+
backplane: { driver: "loopback" },
|
|
177
|
+
// Défense CSRF de l'upgrade WebSocket : SameSite ne protège pas un handshake.
|
|
178
|
+
csrf: {
|
|
179
|
+
checkOrigin: {
|
|
180
|
+
enabled: true,
|
|
181
|
+
allowList: ["http://localhost:5151"],
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
}),
|
|
185
|
+
],
|
|
186
|
+
}));
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### 2. Le contrôleur temps réel — ton seul code serveur
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
/// <reference lib="es2025" />
|
|
193
|
+
// ↑ utile uniquement si ton tsconfig fixe `lib` sous ES2025 : les paquets Nodefony
|
|
194
|
+
// exposent leurs types en SOURCE. Le tsconfig généré par `nodefony create app`
|
|
195
|
+
// porte déjà `"lib": ["ESNext", …]` — tu n'as alors rien à ajouter.
|
|
196
|
+
// nodefony/controllers/ChatController.ts
|
|
197
|
+
import { controller, route } from "@nodefony/framework";
|
|
198
|
+
import {
|
|
199
|
+
RealtimeController,
|
|
200
|
+
RealtimeChannel,
|
|
201
|
+
RealtimeInbound,
|
|
202
|
+
serverSocket,
|
|
203
|
+
} from "@nodefony/realtime";
|
|
204
|
+
import type { RealtimePublish } from "@nodefony/realtime";
|
|
205
|
+
import type { ContextType } from "@nodefony/http";
|
|
206
|
+
|
|
207
|
+
interface ChatMessage {
|
|
208
|
+
from: string;
|
|
209
|
+
text: string;
|
|
210
|
+
ts: number;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
@controller("/chat")
|
|
214
|
+
class ChatController extends RealtimeController {
|
|
215
|
+
constructor(context: ContextType) {
|
|
216
|
+
super("chat", context);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// UNE route WebSocket : la base porte tout le protocole, tu délègues.
|
|
220
|
+
@route("chat-realtime", {
|
|
221
|
+
path: "/realtime",
|
|
222
|
+
requirements: { methods: ["WEBSOCKET"] },
|
|
223
|
+
})
|
|
224
|
+
async realtime(message: string | Buffer | null): Promise<void> {
|
|
225
|
+
this.handleRealtime(message);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Canaux annoncés au client dans le message de bienvenue (découverte).
|
|
229
|
+
protected override realtimeChannels(): string[] {
|
|
230
|
+
return ["chat:room-42"];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// OPT-IN cross-process : sans cette ligne, un message publié sur le pod A
|
|
234
|
+
// ne sortirait jamais du pod A. Le préfixe couvre `chat:*`.
|
|
235
|
+
// (le préfixe diffusable est déclaré par `@RealtimeBroadcast` sur la classe)
|
|
236
|
+
|
|
237
|
+
// Le provider du canal : créé au 1ᵉʳ abonné, son `dispose` appelé au dernier.
|
|
238
|
+
// Ici rien à produire — le contenu vient des clients, pas d'un ticker.
|
|
239
|
+
@RealtimeChannel("chat:room-42")
|
|
240
|
+
room(_channel: string, _publish: RealtimePublish): () => void {
|
|
241
|
+
return () => {};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Canal FULL-DUPLEX : le client a le droit de pousser ici (défaut : interdit).
|
|
245
|
+
@RealtimeInbound("chat:send")
|
|
246
|
+
onSend(params: unknown, reply: (payload: unknown) => void): void {
|
|
247
|
+
// `params` vient du réseau : jamais de confiance, on valide d'abord.
|
|
248
|
+
const text = (params as { text?: unknown } | undefined)?.text;
|
|
249
|
+
if (typeof text !== "string" || text.length === 0) return;
|
|
250
|
+
const msg: ChatMessage = { from: "anonyme", text, ts: Date.now() };
|
|
251
|
+
serverSocket().publish("chat:room-42", msg); // fan-out à tous les abonnés
|
|
252
|
+
reply({ ok: true }); // accusé, à CETTE connexion seulement
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export default ChatController;
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### 3. Le client — navigateur, mobile ou autre serveur
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
// frontend/src/chat.ts
|
|
263
|
+
import { RealtimeClient } from "nodefony/client";
|
|
264
|
+
|
|
265
|
+
interface ChatMessage {
|
|
266
|
+
from: string;
|
|
267
|
+
text: string;
|
|
268
|
+
ts: number;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const scheme = location.protocol === "https:" ? "wss" : "ws";
|
|
272
|
+
// `shared` = une seule socket par URL pour toute la page, même si dix
|
|
273
|
+
// composants la demandent (les options ne comptent qu'à la création).
|
|
274
|
+
const socket = RealtimeClient.shared({
|
|
275
|
+
url: `${scheme}://${location.host}/chat/realtime`,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// `on` REÇOIT, `subscribe` DEMANDE au serveur de pousser. Les deux sont nécessaires.
|
|
279
|
+
socket.on("chat:room-42", (msg: unknown) => {
|
|
280
|
+
const m = msg as ChatMessage;
|
|
281
|
+
console.log(`[${m.ts}] ${m.from}: ${m.text}`);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
export async function start(): Promise<void> {
|
|
285
|
+
await socket.connect();
|
|
286
|
+
socket.subscribe("chat:room-42");
|
|
287
|
+
socket.publish("chat:send", { text: "Salut !" });
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
### Ce qu'on observe
|
|
292
|
+
|
|
293
|
+
Au démarrage du serveur, le module annonce son fond de panier — une ligne, toujours la
|
|
294
|
+
même forme, quel que soit le driver (`src/packages/@nodefony/realtime/index.ts:303`) :
|
|
295
|
+
|
|
296
|
+
```
|
|
297
|
+
realtime backplane driver=loopback kind=local cross-pod=no (hub local)
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Puis, en ouvrant deux onglets, la sonde montre le canal vivant et ses deux abonnés :
|
|
301
|
+
|
|
302
|
+
```bash
|
|
303
|
+
curl -s http://localhost:5151/nodefony/realtime/api/health | head -20
|
|
304
|
+
# {
|
|
305
|
+
# "instanceId": "51234",
|
|
306
|
+
# "channels": [{ "channel": "chat:room-42", "subscribers": 2, "messages": 7 }],
|
|
307
|
+
# "channelCount": 1,
|
|
308
|
+
# "publishTotal": 7, "fanoutTotal": 14, "inboundTotal": 7,
|
|
309
|
+
# "connectionCount": 2,
|
|
310
|
+
# "backpressure": { "maxBufferedAmount": 0, "slowConsumers": 0, "drops": 0 },
|
|
311
|
+
# "backplane": { "driver": "loopback", "kind": "local", "crossPod": false }
|
|
312
|
+
# }
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
`publishTotal` compte les publications, `fanoutTotal` les livraisons réelles : l'écart
|
|
316
|
+
entre les deux **est** le facteur d'amplification du canal.
|
|
317
|
+
|
|
318
|
+
## 🏗️ Architecture interne — les quatre étages
|
|
319
|
+
|
|
320
|
+
La pile se lit de haut en bas ; chaque étage ne connaît que son voisin du dessous.
|
|
321
|
+
|
|
322
|
+
| Étage | Qui | Sa seule responsabilité | Ce qu'il ignore |
|
|
323
|
+
| ---------- | ------------------------------------------------------- | --------------------------------------- | ----------------------------- |
|
|
324
|
+
| Applicatif | ton contrôleur, tes services | le métier : quoi publier, quoi accepter | tout le reste |
|
|
325
|
+
| Protocole | `JsonRpcPeer` (`JsonRpcPeer.ts:271`) | étiqueter, corréler, refuser une frame | par où passent les octets |
|
|
326
|
+
| Transport | `WsConnectionTransport` (`WsConnectionTransport.ts:46`) | déplacer des octets, mesurer la file | ce que veut dire un message |
|
|
327
|
+
| Hub | `RealtimeHub` (`RealtimeHub.ts:213`) | table des abonnés locaux + fan-out | qu'il existe d'autres process |
|
|
328
|
+
| Backplane | `IBackplane` (`IBackplane.ts:75`) | porter un message aux autres process | ce qu'est un abonné |
|
|
329
|
+
|
|
330
|
+
> [!NOTE]
|
|
331
|
+
> Une part de la documentation historique décrit cette pile en « 5 étages » en comptant
|
|
332
|
+
> ton code applicatif comme un étage à part. C'est le même découpage : **quatre briques du
|
|
333
|
+
> framework**, plus ce que tu écris.
|
|
334
|
+
|
|
335
|
+
### L'étage protocole — le même moteur des deux côtés
|
|
336
|
+
|
|
337
|
+
`JsonRpcPeer` porte tout le plan de contrôle d'une connexion : corrélation des `id`,
|
|
338
|
+
dispatch des notifications, gestion des erreurs, `dispose()` propre. Deux points
|
|
339
|
+
d'interception y sont posés :
|
|
340
|
+
|
|
341
|
+
- `beforeDispatch` (`JsonRpcPeer.ts:172`) — un verrou **synchrone** appelé avant tout
|
|
342
|
+
traitement de frame. Il rend `true`/`false`. Un refus sur une requête produit
|
|
343
|
+
`-32001 unauthorized` ; sur une notification, la frame est jetée
|
|
344
|
+
(`JsonRpcPeer.ts:413`).
|
|
345
|
+
- `onFrameAudit` (`JsonRpcPeer.ts:210`) — la trace des événements protocolaires notables
|
|
346
|
+
(frame invalide, refusée, méthode inconnue, erreur interne).
|
|
347
|
+
|
|
348
|
+
La contrainte de synchronisme n'est pas un oubli : un `await` par frame coûterait une
|
|
349
|
+
microtâche et sérialiserait le pipeline du peer. L'identité, elle, est résolue **une fois**
|
|
350
|
+
au handshake, puis lue en O(1). C'est le compromis assumé, documenté dans
|
|
351
|
+
[la page sécurité](./securite.md).
|
|
352
|
+
|
|
353
|
+
### L'étage transport — le serveur ne lit pas la socket
|
|
354
|
+
|
|
355
|
+
Particularité contre-intuitive : côté serveur, le transport **n'écoute pas** d'événement
|
|
356
|
+
socket. C'est le pipeline HTTP de `@nodefony/http` qui appelle la route WebSocket du
|
|
357
|
+
contrôleur à chaque message reçu, laquelle pousse la charge dans
|
|
358
|
+
`WsConnectionTransport.feed()` (`WsConnectionTransport.ts:146`). La fermeture arrive de la
|
|
359
|
+
même façon, par le hook `onFinish` du contexte, qui déclenche `fireClose()`
|
|
360
|
+
(`WsConnectionTransport.ts:151`).
|
|
361
|
+
|
|
362
|
+
Ce transport porte aussi la **back-pressure**. Elle n'est pas câblée en dur : les trois
|
|
363
|
+
leviers sont des clés de configuration du serveur WebSocket, lues par le transport
|
|
364
|
+
(`WsConnectionTransport.ts:63-75`) et documentées dans `@nodefony/http`
|
|
365
|
+
(`http/nodefony/config/config.ts:625`).
|
|
366
|
+
|
|
367
|
+
| File non drainée (`bufferedAmount`) | Décision | Réglage (défaut) |
|
|
368
|
+
| --------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------- |
|
|
369
|
+
| sous le seuil | envoi normal | — |
|
|
370
|
+
| au-dessus du seuil | politique appliquée : frame **jetée**, ou fermeture | `websocket.maxBackpressure` (**4 MiB**) · `websocket.backpressurePolicy` (**`drop`**) |
|
|
371
|
+
| drops **consécutifs** au-delà du compte | fermeture `1013` (_Try Again Later_) | `websocket.backpressureCloseAfterDrops` (**1000**) |
|
|
372
|
+
|
|
373
|
+
Deux points que la formulation « deux seuils d'octets » faisait manquer : la fermeture ne
|
|
374
|
+
se déclenche pas sur un volume mais sur une **suite de frames jetées** — une seule frame
|
|
375
|
+
qui repart remet le compteur à zéro — et `drop` est un choix, `close` fermant dès le
|
|
376
|
+
premier dépassement. Jeter est acceptable parce que les canaux d'état sont « le dernier
|
|
377
|
+
gagne » : le prochain instantané remplace celui qu'on a sauté.
|
|
378
|
+
|
|
379
|
+
À ne pas confondre avec `SLOW_CONSUMER_BYTES` (`RealtimeHub.ts:63`, 1 MiB) : ce seuil-là
|
|
380
|
+
ne jette rien, il **compte** — c'est celui à partir duquel la sonde marque une connexion
|
|
381
|
+
`slowConsumer`.
|
|
382
|
+
|
|
383
|
+
Sans ces seuils, un client lent — onglet en arrière-plan, mobile sur réseau dégradé —
|
|
384
|
+
accumule sans borne côté serveur, et le multiplexage aggrave le phénomène : une connexion
|
|
385
|
+
lente retient **tous** ses canaux.
|
|
386
|
+
|
|
387
|
+
Le schéma ci-dessous rend ces étages vivants : active le temps réel et il respire au rythme
|
|
388
|
+
|
|
389
|
+
## 🔌 Le cycle de vie d'une connexion
|
|
390
|
+
|
|
391
|
+
Tout se joue dans `RealtimeController.onHandshake()` (`RealtimeController.ts:312`), appelé
|
|
392
|
+
une seule fois par connexion, en chemin froid.
|
|
393
|
+
|
|
394
|
+
```mermaid
|
|
395
|
+
sequenceDiagram
|
|
396
|
+
participant C as Client
|
|
397
|
+
participant HTTP as Pipeline HTTP
|
|
398
|
+
participant RC as RealtimeController
|
|
399
|
+
participant H as RealtimeHub
|
|
400
|
+
participant P as JsonRpcPeer
|
|
401
|
+
|
|
402
|
+
C->>HTTP: upgrade HTTP → WebSocket
|
|
403
|
+
HTTP->>RC: handleRealtime(null)
|
|
404
|
+
RC->>H: checkOrigin(origin)
|
|
405
|
+
alt origine refusée
|
|
406
|
+
RC-->>C: close 4003 « origin not allowed »
|
|
407
|
+
end
|
|
408
|
+
RC->>H: resolveAuthenticator(handshake)
|
|
409
|
+
alt authentification en échec
|
|
410
|
+
RC-->>C: close 4001 « unauthorized »
|
|
411
|
+
end
|
|
412
|
+
RC->>H: setTokenForPeer(peer, token)
|
|
413
|
+
RC->>P: register(actions décorées + surchargées)
|
|
414
|
+
RC->>H: registerConnection · registerRevocable · markBroadcastChannel
|
|
415
|
+
RC-->>C: notification « realtime:welcome »
|
|
416
|
+
C->>P: subscribe / notifications / requêtes
|
|
417
|
+
Note over RC,H: à la fermeture (hook onFinish) :<br/>désabonnement de tous les canaux,<br/>sortie des registres, dispose du peer
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
Les étapes, dans l'ordre exact du code :
|
|
421
|
+
|
|
422
|
+
1. **Contrôle de l'origine** — `RealtimeHub.checkOrigin()` (`RealtimeHub.ts:896`). Refus →
|
|
423
|
+
fermeture `4003`. La politique vient de la configuration ; sans politique, tout passe.
|
|
424
|
+
2. **Résolution de l'identité** — `RealtimeHub.resolveAuthenticator()`
|
|
425
|
+
(`RealtimeHub.ts:871`) parcourt les authentificateurs enregistrés : **le premier motif
|
|
426
|
+
qui correspond capture**. Aucun ne correspond ? Le jeton anonyme gelé est posé
|
|
427
|
+
(`ANONYMOUS_REALTIME_TOKEN`) — la lecture d'identité ne rend donc jamais `null`. Un
|
|
428
|
+
échec d'authentification ferme en `4001`.
|
|
429
|
+
3. **Création du peer et du transport**, puis association `peer → jeton`
|
|
430
|
+
(`RealtimeHub.setTokenForPeer()`, `RealtimeHub.ts:928`), stockée dans une `WeakMap` : le
|
|
431
|
+
jeton disparaît avec le peer, sans fuite.
|
|
432
|
+
4. **Enregistrement des actions** — celles des décorateurs `@RealtimeAction`, puis celles
|
|
433
|
+
de la surcharge `realtimeActions()`, qui gagne en cas de conflit. Le pont API
|
|
434
|
+
`api.request` n'est ajouté que si `realtimeApiRequest()` (`RealtimeController.ts:219`)
|
|
435
|
+
rend `true`.
|
|
436
|
+
5. **Inscription aux registres** : sonde de connexion, révocation périodique si le jeton
|
|
437
|
+
est révocable, préfixes de canaux broadcast, canaux entrants.
|
|
438
|
+
6. **Message de bienvenue** `realtime:welcome` : canaux et méthodes découvrables, plus
|
|
439
|
+
l'identité résolue (type, authentifié ou non, rôles, portées). Le client sait **qui il
|
|
440
|
+
est** sans appeler la moindre route.
|
|
441
|
+
|
|
442
|
+
À la fermeture, un unique `onFinish` (`RealtimeController.ts:542`) fait le ménage complet :
|
|
443
|
+
désabonnement de chaque canal tenu, retrait des deux registres, `fireClose()` du transport,
|
|
444
|
+
`dispose()` du peer. C'est ce qui garantit qu'aucun minuteur ni écouteur ne survit à une
|
|
445
|
+
déconnexion.
|
|
446
|
+
|
|
447
|
+
> [!IMPORTANT]
|
|
448
|
+
> Les frames reçues **pendant** l'authentification sont jetées silencieusement : le
|
|
449
|
+
> transport n'est pas encore branché. C'est au client d'attendre `realtime:welcome` avant
|
|
450
|
+
> de pousser — ce que `RealtimeClient` fait nativement.
|
|
451
|
+
|
|
452
|
+
### Deux gardes qui ferment la connexion
|
|
453
|
+
|
|
454
|
+
| Garde | Déclencheur | Effet | Ancrage |
|
|
455
|
+
| --------------------- | ---------------------------------------------------------------------- | --------------------------------------------------- | --------------------------- |
|
|
456
|
+
| Plafond de canaux | une connexion dépasse son quota d'abonnements (256 par défaut) | abonnement refusé + `realtime:denied` motif `limit` | `RealtimeController.ts:618` |
|
|
457
|
+
| Révocation d'identité | tick périodique ; `token.isValid()` rend `false` ou lève une exception | fermeture `4001` « session revoked » | `RealtimeHub.ts:48` |
|
|
458
|
+
|
|
459
|
+
La seconde mérite une explication. Le verrou de frame est synchrone et lit une identité
|
|
460
|
+
figée au handshake : il ne peut donc pas voir une session qui meurt en cours de route (une
|
|
461
|
+
déconnexion HTTP, par exemple). Un minuteur — démarré au premier inscrit, arrêté dès que le
|
|
462
|
+
registre se vide (`RealtimeHub.registerRevocable()`, `RealtimeHub.ts:704`) — relit
|
|
463
|
+
périodiquement ces identités et coupe les sockets orphelines. Seules les identités
|
|
464
|
+
**révocables** y entrent : un visiteur anonyme ne coûte rien.
|
|
465
|
+
|
|
466
|
+
## Le hub — canaux partagés et fan-out
|
|
467
|
+
|
|
468
|
+
Le hub est un singleton par process (`getRealtimeHub()`, `RealtimeHub.ts:1255`). Il ne
|
|
469
|
+
dépend de rien : ce sont les fabriques fournies par les contrôleurs qui portent les
|
|
470
|
+
dépendances.
|
|
471
|
+
|
|
472
|
+
### Le provider partagé — un ticker, pas mille
|
|
473
|
+
|
|
474
|
+
`RealtimeHub.subscribe()` (`RealtimeHub.ts:388`) applique une mécanique en trois temps :
|
|
475
|
+
|
|
476
|
+
1. Le canal existe déjà ? On ajoute simplement le sink de cette connexion. Fin.
|
|
477
|
+
2. Sinon, on inscrit le sink **avant** d'appeler la fabrique — de sorte que le tout premier
|
|
478
|
+
paquet du producteur atteigne bien ce premier abonné.
|
|
479
|
+
3. La fabrique du contrôleur rend `null` (canal inconnu de lui) ? Dernier recours : le
|
|
480
|
+
registre des **canaux système** (`RealtimeHub.registerSystemChannel()`,
|
|
481
|
+
`RealtimeHub.ts:1117`), qu'un module bas niveau alimente au démarrage. Toujours `null` →
|
|
482
|
+
l'abonnement est refusé et rien n'est alloué.
|
|
483
|
+
|
|
484
|
+
Au dernier désabonnement, `RealtimeHub.unsubscribe()` (`RealtimeHub.ts:502`) appelle le
|
|
485
|
+
`dispose` du producteur et retire le canal. Un producteur fautif qui lève une exception ne
|
|
486
|
+
bloque pas le nettoyage.
|
|
487
|
+
|
|
488
|
+
> [!WARNING]
|
|
489
|
+
> Un producteur est **partagé** et survit à la connexion qui l'a créé. Il doit capturer des
|
|
490
|
+
> valeurs à longue durée de vie (le noyau, un service, un journal) — **jamais** le contexte
|
|
491
|
+
> de la connexion créatrice, qui peut fermer alors que le producteur tourne toujours.
|
|
492
|
+
|
|
493
|
+
### `publish` et `publishLocal` — la barrière anti-boucle
|
|
494
|
+
|
|
495
|
+
Deux méthodes qui se ressemblent et ne font pas du tout la même chose :
|
|
496
|
+
|
|
497
|
+
| Méthode | Fan-out local | Propagation aux autres process | Qui l'appelle |
|
|
498
|
+
| --------------------------------------- | :-----------: | :--------------------------------: | ----------------------------------- |
|
|
499
|
+
| `publish()` (`RealtimeHub.ts:530`) | oui | oui, **si** le canal est broadcast | producteurs, contrôleurs, services |
|
|
500
|
+
| `publishLocal()` (`RealtimeHub.ts:549`) | oui | **jamais** | l'arrivée d'un message du backplane |
|
|
501
|
+
|
|
502
|
+
C'est **la** règle qui empêche la tempête : un message reçu d'un pair est réinjecté
|
|
503
|
+
localement et **ne repart pas**. Le câblage se fait une fois pour toutes dans
|
|
504
|
+
`RealtimeHub.setBackplane()` (`RealtimeHub.ts:640`), qui branche l'arrivée du backplane
|
|
505
|
+
directement sur `publishLocal`.
|
|
506
|
+
|
|
507
|
+
Le fan-out lui-même (`RealtimeHub.ts:340`) est **isolé** : chaque livraison est protégée,
|
|
508
|
+
une connexion fautive n'interrompt pas la diffusion aux autres.
|
|
509
|
+
|
|
510
|
+
### Le forward est OPT-IN — le défaut est l'isolement
|
|
511
|
+
|
|
512
|
+
Par défaut, **aucun canal ne traverse le backplane**. Il faut déclarer un préfixe, via
|
|
513
|
+
`@RealtimeBroadcast` sur ton contrôleur (`realtimeDecorators.ts:342`) ou
|
|
514
|
+
directement `RealtimeHub.markBroadcastChannel()` (`RealtimeHub.ts:594`).
|
|
515
|
+
|
|
516
|
+
Trois raisons à ce choix, qui prend à contre-pied la plupart des bibliothèques temps réel :
|
|
517
|
+
|
|
518
|
+
1. **Sûreté.** Aucune donnée propre à une instance — journaux, sondes, état interne — ne
|
|
519
|
+
fuit vers un autre process sans intention explicite.
|
|
520
|
+
2. **Justesse par défaut.** Tous les canaux d'observabilité sont per-instance : ils restent
|
|
521
|
+
corrects en cluster sans la moindre déclaration.
|
|
522
|
+
3. **Le franchissement devient une capacité qu'un canal demande**, donc une décision qu'on
|
|
523
|
+
lit dans le code du contrôleur.
|
|
524
|
+
|
|
525
|
+
Le drapeau est résolu **une fois**, au premier abonné, et mis en cache dans l'état du canal
|
|
526
|
+
— le chemin chaud ne lit qu'un booléen. Et en mono-process, la politique n'est **jamais**
|
|
527
|
+
évaluée : `publish` sort sur un simple test de nullité du backplane.
|
|
528
|
+
|
|
529
|
+
> [!WARNING]
|
|
530
|
+
> Le symptôme classique : « mon chat marche en local, mais en cluster les onglets ne se
|
|
531
|
+
> voient plus ». Cause : le préfixe `chat:` n'est pas déclaré broadcast. Le préfixe couvre
|
|
532
|
+
> la granularité, donc `chat:` attrape aussi `chat:room-42` et `chat:room-42:1000`.
|
|
533
|
+
|
|
534
|
+
Une publication part vers N destinations : le schéma suivant montre ce trajet, et ce que
|
|
535
|
+
coûte chaque abonné supplémentaire.
|
|
536
|
+
|
|
537
|
+
```nodefony-livegraph
|
|
538
|
+
{
|
|
539
|
+
"graph": "fan-out",
|
|
540
|
+
"height": 500,
|
|
541
|
+
"title": "Le fan-out, en direct",
|
|
542
|
+
"hint": "Le nombre d'abonnés et le volume diffusé viennent de la sonde ; la branche cross-worker ne s'allume que si un canal est déclaré diffusable."
|
|
543
|
+
}
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
## Le backplane — franchir la frontière du process
|
|
547
|
+
|
|
548
|
+
### Le contrat, en cinq méthodes
|
|
549
|
+
|
|
550
|
+
`IBackplane` (`IBackplane.ts:75`) est délibérément minuscule : le backplane ne connaît ni
|
|
551
|
+
les abonnés ni les canaux logiques. Tout l'état vit dans le hub.
|
|
552
|
+
|
|
553
|
+
| Membre | Rôle |
|
|
554
|
+
| ------------------------------------------------ | --------------------------------------------------------------- |
|
|
555
|
+
| `originId` (`IBackplane.ts:77`) | l'étiquette de CE process, lue par l'anti-écho |
|
|
556
|
+
| `start()` (`IBackplane.ts:115`) | ouvrir le transport. Idempotent, synchrone ou asynchrone |
|
|
557
|
+
| `publish(channel, payload)` (`IBackplane.ts:89`) | propager aux **autres** pairs. Ne refait pas le fan-out local |
|
|
558
|
+
| `onMessage(handler)` (`IBackplane.ts:127`) | recevoir des pairs. Un seul gestionnaire à la fois |
|
|
559
|
+
| `stop()` (`IBackplane.ts:130`) | libérer connexions et écouteurs. Idempotent |
|
|
560
|
+
| `describe()` (`IBackplane.ts:136`) | la carte d'identité : driver, nature, origine, cross-pod, canal |
|
|
561
|
+
|
|
562
|
+
Deux absences volontaires. Il n'y a **pas** de `subscribe`/`unsubscribe` par canal : le
|
|
563
|
+
transport porte **un seul** canal physique, et les canaux logiques voyagent **dans**
|
|
564
|
+
l'enveloppe (`IBackplaneMessage`, `IBackplane.ts:51`). Un seul abonnement au démarrage,
|
|
565
|
+
zéro (dés)abonnement dynamique — et le hub reste seul maître de la table des canaux.
|
|
566
|
+
|
|
567
|
+
`describe()`, lui, alimente **trois sorties cohérentes** depuis une source unique : la ligne
|
|
568
|
+
de journal au démarrage, le champ `backplane` de la sonde, et la vue Studio. Personne ne
|
|
569
|
+
redit « c'est quoi ce backplane ».
|
|
570
|
+
|
|
571
|
+
**Garantie de livraison : au plus une fois, au mieux.** Un pub/sub ne persiste ni ne rejoue.
|
|
572
|
+
Un process déconnecté rate les messages émis pendant sa coupure ; c'est le client qui se
|
|
573
|
+
resynchronise. Ne construis pas au-dessus une fiabilité que le support n'offre pas.
|
|
574
|
+
|
|
575
|
+
### Les drivers natifs
|
|
576
|
+
|
|
577
|
+
| Driver | Classe | Nature | Pairs | Traverse les pods |
|
|
578
|
+
| ---------- | ----------------------------------------------- | -------------- | ---------------------------------- | :---------------: |
|
|
579
|
+
| `loopback` | `LoopbackBackplane` (`LoopbackBackplane.ts:24`) | `local` | aucun (no-op complet) | non |
|
|
580
|
+
| `cluster` | `ClusterBackplane` (`ClusterBackplane.ts:89`) | `ipc` | les workers du même process maître | non |
|
|
581
|
+
| `redis` | `RedisBackplane` (`RedisBackplane.ts:161`) | `redis-pubsub` | tous les pods abonnés au canal | **oui** |
|
|
582
|
+
|
|
583
|
+
**`loopback`** est le défaut. En réalité, le hub garde son backplane à `null` en
|
|
584
|
+
mono-process : le coût est un test de nullité, pas un appel. La classe existe pour
|
|
585
|
+
matérialiser le contrat et prouver, en test, que brancher un backplane sans pair ne change
|
|
586
|
+
rien.
|
|
587
|
+
|
|
588
|
+
**`cluster`** relie les workers d'un même hôte. Un worker Node ne peut parler qu'au maître :
|
|
589
|
+
il émet vers lui (`ClusterBackplane.publish()`, `ClusterBackplane.ts:117`), le maître
|
|
590
|
+
rediffuse aux autres. Le driver ne s'active qu'en rôle worker avec la variable
|
|
591
|
+
`NF_CLUSTER=1` (`src/packages/@nodefony/realtime/index.ts:99`) ; ailleurs il rend
|
|
592
|
+
`null`. Aucune infrastructure requise — c'est le banc d'essai qui stabilise l'architecture
|
|
593
|
+
multi-process avant d'ajouter du réseau.
|
|
594
|
+
|
|
595
|
+
**`redis`** franchit la frontière de l'hôte, sans dépendre de la bibliothèque `redis` : il
|
|
596
|
+
consomme deux connexions du module `@nodefony/redis` via un adaptateur purement structurel,
|
|
597
|
+
`createRedisServiceTransport()` (`RedisBackplane.ts:100`). Deux connexions et non une, parce
|
|
598
|
+
qu'un client Redis abonné ne peut plus émettre de commandes ordinaires. Module absent ou
|
|
599
|
+
connexions indisponibles → avertissement, `null`, hub local, démarrage poursuivi
|
|
600
|
+
(`src/packages/@nodefony/realtime/index.ts:108`).
|
|
601
|
+
|
|
602
|
+
### Le registre de drivers — zéro nom en dur
|
|
603
|
+
|
|
604
|
+
Choisir le driver par une cascade de `if (driver === "redis")` trahirait la promesse de
|
|
605
|
+
brancherie. La sélection passe donc par un registre — `registerBackplaneDriver()`
|
|
606
|
+
(`backplaneRegistry.ts:55`) et `getBackplaneDriver()` (`backplaneRegistry.ts:63`) : chaque
|
|
607
|
+
driver porte son nom en membre statique, les natifs s'inscrivent au chargement du module, et
|
|
608
|
+
le câblage résout une chaîne vers une fabrique sans connaître aucun littéral
|
|
609
|
+
(`src/packages/@nodefony/realtime/index.ts:253`).
|
|
610
|
+
|
|
611
|
+
Une fabrique reçoit module, `originId`, rôle dans la topologie et configuration validée
|
|
612
|
+
(`IBackplaneFactoryContext`, `backplaneRegistry.ts:27`), puis rend une instance ou `null`
|
|
613
|
+
(« inactif ici »). Elle ne démarre rien : c'est le câblage qui appelle `start()`.
|
|
614
|
+
|
|
615
|
+
### L'anti-écho et l'`originId`
|
|
616
|
+
|
|
617
|
+
Redis renvoie à l'émetteur ce qu'il publie (le publieur et l'abonné sont deux connexions du
|
|
618
|
+
même process). Sans filtre, le fan-out local serait fait deux fois. D'où **deux barrières**,
|
|
619
|
+
et non une :
|
|
620
|
+
|
|
621
|
+
1. **Côté hub** : l'arrivée passe par `publishLocal`, jamais par `publish`. Rien ne repart.
|
|
622
|
+
2. **Côté backplane** : à la réception, on compare l'`originId` de l'enveloppe au sien et
|
|
623
|
+
on jette si c'est le même (`RedisBackplane.ts:220`, `ClusterBackplane.ts:134`).
|
|
624
|
+
|
|
625
|
+
L'étiquette elle-même est calculée par `resolveBackplaneOriginId()` (`originId.ts:24`), et
|
|
626
|
+
sa recette mérite qu'on s'y arrête :
|
|
627
|
+
|
|
628
|
+
| Étape | Valeur | Pourquoi |
|
|
629
|
+
| ------------------ | ---------------------- | ----------------------------------------------------------------------------------------------------- |
|
|
630
|
+
| 1. explicite | variable `NF_POD_NAME` | l'opérateur sait mieux que nous (API descendante Kubernetes) |
|
|
631
|
+
| 2. filet | `os.hostname()` | nom de pod en Kubernetes, identifiant de conteneur en Docker, nom de machine ailleurs |
|
|
632
|
+
| 3. toujours ajouté | suffixe `:pid` | distingue les workers d'un même hôte (cluster + driver `redis`) |
|
|
633
|
+
| 4. dernier recours | `randomUUID()` | hôte indisponible ; l'unicité à l'instant t suffit, la stabilité entre redémarrages n'est pas requise |
|
|
634
|
+
|
|
635
|
+
> [!CAUTION]
|
|
636
|
+
> Prendre le PID seul serait un piège sournois : en conteneur, l'espace de noms des PID est
|
|
637
|
+
> **par conteneur**, donc deux pods peuvent tous deux être PID 1. L'anti-écho les
|
|
638
|
+
> confondrait et jetterait **silencieusement** du fan-out légitime — la pire des pannes,
|
|
639
|
+
> celle qui ne lève aucune erreur. L'`originId` est résolu à la construction : coût nul par
|
|
640
|
+
> message.
|
|
641
|
+
|
|
642
|
+
### Le cloisonnement du canal Redis
|
|
643
|
+
|
|
644
|
+
Le canal pub/sub de base est `nodefony:realtime` (`RedisBackplane.ts:20`). Problème : le
|
|
645
|
+
numéro de base Redis **ne cloisonne pas** le pub/sub, qui est global au serveur. Deux
|
|
646
|
+
applications sur un Redis mutualisé se parleraient.
|
|
647
|
+
|
|
648
|
+
D'où le suffixe de cloison, calculé par `resolveRedisChannel()` (`RedisBackplane.ts:31`) :
|
|
649
|
+
le canal effectif devient `nodefony:realtime:<namespace>`. Le namespace vient de la
|
|
650
|
+
configuration ; à défaut, il est dérivé du nom de l'application
|
|
651
|
+
(`src/packages/@nodefony/realtime/index.ts:132`).
|
|
652
|
+
|
|
653
|
+
> [!TIP]
|
|
654
|
+
> Deux déploiements de la **même** application sur un Redis partagé (préproduction et
|
|
655
|
+
> production, par exemple) obtiennent le même nom dérivé — donc le même canal. Pose un
|
|
656
|
+
> namespace **explicite et distinct** dans ces cas-là.
|
|
657
|
+
|
|
658
|
+
L'entrée est par ailleurs blindée : un JSON malformé publié par un tiers sur le même canal
|
|
659
|
+
est ignoré, pas propagé (`RedisBackplane.ts:212`).
|
|
660
|
+
|
|
661
|
+
### Le démarrage, et ce qui se passe quand ça rate
|
|
662
|
+
|
|
663
|
+
Le module valide sa configuration par Zod à l'enregistrement
|
|
664
|
+
(`src/packages/@nodefony/realtime/index.ts:181`), puis câble tout au démarrage du noyau
|
|
665
|
+
(`src/packages/@nodefony/realtime/index.ts:213`) — après l'initialisation des services (le
|
|
666
|
+
service Redis a donc ouvert ses connexions) et avant le trafic.
|
|
667
|
+
|
|
668
|
+
Le `start()` du backplane est **attendu explicitement avant** d'être branché : un driver
|
|
669
|
+
asynchrone brancherait sinon un abonnement incomplet et perdrait ses premiers messages. Et
|
|
670
|
+
il est **borné par un délai** de cinq secondes
|
|
671
|
+
(`src/packages/@nodefony/realtime/index.ts:83`), pour une raison très concrète : un
|
|
672
|
+
transport réseau qui reste pendu gèlerait la phase de démarrage, et donc la montée des
|
|
673
|
+
serveurs HTTP eux-mêmes.
|
|
674
|
+
|
|
675
|
+
Trois issues, toutes annoncées, jamais silencieuses :
|
|
676
|
+
|
|
677
|
+
| Situation | Comportement | Journal |
|
|
678
|
+
| -------------------------------------------- | ------------------------------------ | --------- |
|
|
679
|
+
| driver actif, démarrage réussi | branché ; carte d'identité complète | `INFO` |
|
|
680
|
+
| driver inactif ici (loopback, mauvais rôle) | hub local | `INFO` |
|
|
681
|
+
| driver inconnu, ou démarrage en échec/expiré | hub local, **le démarrage continue** | `WARNING` |
|
|
682
|
+
|
|
683
|
+
Le module se déclare non critique (`src/packages/@nodefony/realtime/index.ts:144`) : son
|
|
684
|
+
échec ne tue jamais le process. C'est de la résilience **sans dégradation silencieuse** —
|
|
685
|
+
tout repli est annoncé. Enfin, `enabled: false` rend le module totalement inerte : ni API
|
|
686
|
+
d'administration, ni backplane, ni sonde.
|
|
687
|
+
|
|
688
|
+
## 🧩 Extension — brancher son propre fond de panier
|
|
689
|
+
|
|
690
|
+
Trois voies, de la plus déclarative à la plus directe.
|
|
691
|
+
|
|
692
|
+
**1. Enregistrer un driver** — la voie recommandée : ton driver devient sélectionnable par
|
|
693
|
+
son nom, comme les natifs.
|
|
694
|
+
|
|
695
|
+
```typescript
|
|
696
|
+
import { registerBackplaneDriver, type IBackplane } from "@nodefony/realtime";
|
|
697
|
+
|
|
698
|
+
class NatsBackplane implements IBackplane {
|
|
699
|
+
static readonly driver = "nats";
|
|
700
|
+
readonly originId: string;
|
|
701
|
+
/* start / publish / onMessage / stop / describe */
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
registerBackplaneDriver(
|
|
705
|
+
NatsBackplane.driver,
|
|
706
|
+
(ctx) => new NatsBackplane(/* … */ ctx.originId),
|
|
707
|
+
);
|
|
708
|
+
// puis, en configuration : backplane: { driver: "nats" }
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
**2. Déclarer un service** nommé `realtimeBackplane` dans le conteneur d'injection —
|
|
712
|
+
**la voie « instance » pour une application**. `RealtimeService.init()`
|
|
713
|
+
(`RealtimeService.ts:79`) le résout et le branche **avant** le registre : une instance
|
|
714
|
+
déjà fournie court-circuite la sélection par nom.
|
|
715
|
+
|
|
716
|
+
**3. Passer une instance** en second argument de `defineRealtimeConfig()` — réservé au
|
|
717
|
+
code qui **appelle le builder lui-même** (un `Module` sur mesure, une intégration
|
|
718
|
+
programmatique). Une application qui configure via `use("@nodefony/realtime", …)` **ne
|
|
719
|
+
l'atteint pas** : elle ne construit pas la config (le module appelle
|
|
720
|
+
`defineRealtimeConfig(this.options)` sans second argument, `index.ts:216`), et une
|
|
721
|
+
instance posée dans l'objet de config est retirée par la validation Zod (elle n'est pas
|
|
722
|
+
au schéma). Pour une app, la voie « instance » est l'option 2 ci-dessus.
|
|
723
|
+
|
|
724
|
+
Le registre s'introspecte : `listBackplaneDrivers()` (`backplaneRegistry.ts:68`) rend les
|
|
725
|
+
noms disponibles — c'est ce que le message d'erreur affiche quand un driver déclaré est
|
|
726
|
+
introuvable.
|
|
727
|
+
|
|
728
|
+
## 🔐 Où la sécurité se greffe
|
|
729
|
+
|
|
730
|
+
L'architecture prévoit des points de greffe pour que `@nodefony/security` s'y branche sans
|
|
731
|
+
refonte. Ici, seulement **où** ils sont ; le **quoi** et le **comment** sont dans
|
|
732
|
+
[la page sécurité](./securite.md).
|
|
733
|
+
|
|
734
|
+
| Point de greffe | Étage | Moment | Ancrage |
|
|
735
|
+
| ---------------------------- | --------- | -------------- | --------------------------- |
|
|
736
|
+
| Contrôle d'origine | hub | à l'upgrade | `RealtimeHub.ts:614` |
|
|
737
|
+
| Authentificateur réseau | hub | au handshake | `RealtimeHub.ts:579` |
|
|
738
|
+
| Verrou de frame | protocole | à chaque frame | `RealtimeHub.ts:675` |
|
|
739
|
+
| Politique déclarée par canal | hub | au handshake | `RealtimeHub.ts:713` |
|
|
740
|
+
| Audit de frame | protocole | sur refus | `RealtimeController.ts:404` |
|
|
741
|
+
|
|
742
|
+
Deux propriétés architecturales méritent d'être notées ici, parce qu'elles expliquent des
|
|
743
|
+
choix de conception visibles partout dans le module :
|
|
744
|
+
|
|
745
|
+
**Coût nul quand la sécurité est absente.** Le verrou de frame n'est branché sur le peer que
|
|
746
|
+
si une politique existe (`RealtimeHub.hasFrameAuthorizer()`, `RealtimeHub.ts:961`, testé une
|
|
747
|
+
fois au handshake). Sans module de sécurité, `beforeDispatch` reste indéfini et le chemin
|
|
748
|
+
chaud ne paie **rien** du tout.
|
|
749
|
+
|
|
750
|
+
**Échec bruyant plutôt que faux sentiment de sécurité.** Si des canaux déclarent une
|
|
751
|
+
politique sans qu'aucun décideur ne soit câblé, `hasUnenforcedChannelPolicies()`
|
|
752
|
+
(`RealtimeHub.ts:1020`) le détecte et un avertissement est émis une fois par process
|
|
753
|
+
(`RealtimeController.ts:487`). Un canal qui **se croit** gardé alors qu'il est ouvert est
|
|
754
|
+
bien plus dangereux qu'un canal ouvertement public.
|
|
755
|
+
|
|
756
|
+
## 📡 Observabilité — la sonde et Studio
|
|
757
|
+
|
|
758
|
+
`RealtimeHub.probe()` (`RealtimeHub.ts:775`) rend un instantané en **lecture pure** : aucune
|
|
759
|
+
allocation sur le chemin chaud, jamais d'exception. Les compteurs sont des primitives
|
|
760
|
+
incrémentées en O(1) ; ils sont **monotones**, ce qui laisse au lecteur le soin de dériver
|
|
761
|
+
un débit.
|
|
762
|
+
|
|
763
|
+
| Famille | Champs | Ce que ça dit |
|
|
764
|
+
| -------------- | -------------------------------------------------------------------- | -------------------------------- |
|
|
765
|
+
| Canaux | `channels[]` (`channel`, `subscribers`, `messages`), `channelCount` | qui vit, qui écoute |
|
|
766
|
+
| Fan-out | `publishTotal`, `fanoutTotal`, `inboundTotal` | l'amplification réelle du broker |
|
|
767
|
+
| Connexions | `connectionCount`, `bytesSentTotal`, `messagesSentTotal` | le volume |
|
|
768
|
+
| Back-pressure | `maxBufferedAmount`, `totalBufferedAmount`, `slowConsumers`, `drops` | le risque mémoire numéro un |
|
|
769
|
+
| Fond de panier | `backplane` (la carte d'identité de `describe()`) | ce qui est réellement branché |
|
|
770
|
+
|
|
771
|
+
Le contrat complet est `IRealtimeProbe` (`IRealtimeProbe.ts:61`). La version enrichie
|
|
772
|
+
`buildOwnHealth()` (`RealtimeAdminApi.ts:52`) y ajoute l'identifiant d'instance et la sonde
|
|
773
|
+
process, plus — si elles sont disponibles — les sondes ORM et les compteurs d'erreurs. Deux
|
|
774
|
+
chemins de lecture, un seul producteur :
|
|
775
|
+
|
|
776
|
+
- **HTTP** : `GET /nodefony/realtime/api/health`, exposé par `createRealtimeAdminApi()`
|
|
777
|
+
(`RealtimeAdminApi.ts:91`) ;
|
|
778
|
+
- **Canal temps réel** : `nodefony:socket`, poussé par un ticker.
|
|
779
|
+
|
|
780
|
+
### En cluster : la vue « pod » plutôt que « worker »
|
|
781
|
+
|
|
782
|
+
Interroger la santé en cluster pose un problème sournois : la requête atterrit sur **un**
|
|
783
|
+
worker au hasard, dont l'instantané ne dit rien des autres. D'où `ClusterProbeClient`
|
|
784
|
+
(`ClusterProbeClient.ts:138`), qui applique un modèle **push** — chaque worker remonte
|
|
785
|
+
périodiquement sa santé au maître (`ClusterProbeClient.ts:179`), le maître agrège et
|
|
786
|
+
rediffuse, chaque worker met en cache. **N'importe lequel** sert alors la vue pod en O(1),
|
|
787
|
+
sans latence de requête.
|
|
788
|
+
|
|
789
|
+
L'agrégation, `mergeClusterHealth()` (`ClusterProbeClient.ts:46`), est une fonction pure :
|
|
790
|
+
elle somme les scalaires, mais prend le **maximum** de `maxBufferedAmount` — la santé d'une
|
|
791
|
+
flotte se juge sur son pire membre, pas sur sa moyenne. `buildRealtimeHealth()`
|
|
792
|
+
(`RealtimeAdminApi.ts:74`) choisit ensuite la vue agrégée si elle existe, la per-instance
|
|
793
|
+
sinon.
|
|
794
|
+
|
|
795
|
+
Sonde désactivée ? Elle n'est pas muette : **elle n'existe pas**. Aucun client, donc aucun
|
|
796
|
+
minuteur, aucun écouteur, aucun message IPC
|
|
797
|
+
(`src/packages/@nodefony/realtime/index.ts:351`) — coupable par la configuration ou par une
|
|
798
|
+
variable d'environnement. Enfin, le maître peut demander à un worker précis d'enrichir sa
|
|
799
|
+
remontée (`requestEnrich()`, `ClusterProbeClient.ts:239`) : la sonde riche n'est allouée que
|
|
800
|
+
pendant l'inspection, puis libérée — on paie ce qu'on regarde.
|
|
801
|
+
|
|
802
|
+
## ⚡ Performance & mémoire
|
|
803
|
+
|
|
804
|
+
Le module suit une discipline stricte, lisible dans presque chaque champ privé du hub.
|
|
805
|
+
|
|
806
|
+
**Allocation paresseuse partout.** Table des canaux, registre des connexions, registre des
|
|
807
|
+
révocables, authentificateurs, politiques de canal, association peer→jeton : tout démarre à
|
|
808
|
+
`null`. Un process sans abonné n'alloue **rien** (`RealtimeHub.ts:141` et suivants).
|
|
809
|
+
|
|
810
|
+
**Aucun minuteur au repos.** Le tick de révocation démarre au premier inscrit et s'arrête
|
|
811
|
+
dès que le registre se vide — `RealtimeHub.unregisterRevocable()` (`RealtimeHub.ts:719`).
|
|
812
|
+
Les minuteurs sont détachés de la boucle d'événements : ils ne retiennent jamais l'arrêt du
|
|
813
|
+
process.
|
|
814
|
+
|
|
815
|
+
**Les chemins chauds sont maigres.** En mono-process, `publish` ne paie qu'un test de
|
|
816
|
+
nullité. Le drapeau de forward est calculé une fois puis lu en O(1). La lecture d'identité
|
|
817
|
+
par frame est un accès `WeakMap`. Le contrôle d'origine, la résolution
|
|
818
|
+
d'authentificateur, la lecture des métadonnées de décorateurs : tout est en chemin froid,
|
|
819
|
+
une fois par connexion.
|
|
820
|
+
|
|
821
|
+
**Le nettoyage est symétrique par construction.** Chaque inscription à un registre a son
|
|
822
|
+
retrait dans le même `onFinish` ; chaque producteur créé a son `dispose` appelé au dernier
|
|
823
|
+
désabonné.
|
|
824
|
+
|
|
825
|
+
**Le vrai point de vigilance est la back-pressure**, pas le processeur : une file d'envoi
|
|
826
|
+
non bornée multipliée par le nombre de clients, c'est la panne mémoire — et le multiplexage
|
|
827
|
+
concentre le risque. D'où les deux seuils du transport, le comptage des consommateurs lents
|
|
828
|
+
dans la sonde, et le plafond de canaux par connexion.
|
|
829
|
+
|
|
830
|
+
## ⚠️ Pièges
|
|
831
|
+
|
|
832
|
+
| Symptôme | Cause | Correction |
|
|
833
|
+
| -------------------------------------------------------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
|
834
|
+
| Le chat marche en local, plus rien en cluster | le canal n'est pas déclaré broadcast (défaut : instance-local) | déclarer le préfixe avec `@RealtimeBroadcast` |
|
|
835
|
+
| Le client ne reçoit rien alors qu'il a un handler `on(...)` | `on` reçoit, `subscribe` demande — il faut les **deux** | appeler `socket.subscribe(canal)` |
|
|
836
|
+
| Chaque message arrive **en double** en cluster | `publishLocal` court-circuité, ou `originId` non unique entre pods | ne jamais republier une arrivée backplane ; vérifier `NF_POD_NAME`/hostname |
|
|
837
|
+
| Deux applications se parlent sur un Redis mutualisé | même canal dérivé (le numéro de base Redis ne cloisonne pas le pub/sub) | poser un `backplane.namespace` explicite et distinct |
|
|
838
|
+
| Producteur planté après le départ du premier abonné | la fabrique a capturé le contexte de la connexion créatrice | ne capturer que des valeurs à longue durée de vie |
|
|
839
|
+
| Abonnement refusé avec `realtime:denied` motif `limit` | plafond de canaux par connexion atteint (256 par défaut) | regrouper les canaux, ou relever le plafond en connaissance de cause |
|
|
840
|
+
| Fermeture `1013` sur un client lent | file d'envoi ≥ 8 MiB, jugée irrécupérable | attendu ; le client se reconnecte et se resynchronise |
|
|
841
|
+
| Fermeture `4001` en cours de session | identité révoquée, détectée par le tick de re-validation | se réauthentifier ; le comportement est voulu |
|
|
842
|
+
| Frames envoyées juste après `connect()` perdues | le transport n'est pas branché tant que le handshake n'est pas fini | attendre `realtime:welcome` (`RealtimeClient` le fait déjà) |
|
|
843
|
+
| Avertissement « channel policies … NOT enforced » au démarrage | des canaux déclarent une politique sans décideur câblé | charger `@nodefony/security` avec une zone realtime |
|
|
844
|
+
| `ServerRealtimeSocket.request()` rejette systématiquement | un handle posé sur le hub n'a pas d'interlocuteur unique | utiliser `RealtimeController.requestClient()` pour un appel ciblé |
|
|
845
|
+
| `TS2550: RegExp.escape does not exist` à la compilation | `lib` du tsconfig sous ES2025 (les paquets exposent leurs types en source) | `"lib": ["ESNext", …]`, comme le tsconfig généré par `nodefony create app` |
|
|
846
|
+
|
|
847
|
+
## 🧪 Tests & couverture
|
|
848
|
+
|
|
849
|
+
Cinq familles couvrent cette architecture. Les chiffres exacts vivent dans la carte de
|
|
850
|
+
l'aperçu, régénérée depuis les résultats réels — jamais figés ici.
|
|
851
|
+
|
|
852
|
+
- **Unitaires** — le cœur du fan-out (`RealtimeHub`), le cycle de connexion
|
|
853
|
+
(`RealtimeController`), la façade d'injection (`RealtimeService`), les trois backplanes,
|
|
854
|
+
le registre de drivers, la résolution d'`originId`, les décorateurs, le transport et son
|
|
855
|
+
contrôle de back-pressure, la validation Zod de la configuration.
|
|
856
|
+
- **Contrat de types** — `realtimeSharedContract.types.test.ts` vérifie que le contrat
|
|
857
|
+
partagé serveur/client tient à la compilation.
|
|
858
|
+
- **Intégration** — le backplane Redis face à un vrai serveur.
|
|
859
|
+
- **Bout en bout** — un serveur réel : cluster IPC par `fork`, boucle loopback complète,
|
|
860
|
+
chemins de résolution du contrôleur, câblage du pare-feu, autorisation par canal, et
|
|
861
|
+
cluster Redis multi-workers.
|
|
862
|
+
- **Attaque** — c'est là qu'on lit ce que le module défend vraiment :
|
|
863
|
+
`realtimeUnenforcedPolicy` (une politique déclarée sans décideur est-elle détectée ?),
|
|
864
|
+
`realtimeRevocation` (une session révoquée ferme-t-elle la socket, y compris quand la
|
|
865
|
+
re-validation lève une exception ?), `realtimeChannelCap` (le plafond tient-il, et le
|
|
866
|
+
refus est-il observable ?).
|
|
867
|
+
|
|
868
|
+
> [!CAUTION]
|
|
869
|
+
> Plusieurs de ces suites exigent une infrastructure (Redis, cluster). **Un test qui se
|
|
870
|
+
> saute compte comme vert.** Avant de conclure « tout passe », vérifie que les variables
|
|
871
|
+
> d'environnement d'infrastructure sont bien posées — la source unique est `vitest.gates.ts`
|
|
872
|
+
> à la racine, dont le rapport s'affiche en fin d'exécution.
|
|
873
|
+
|
|
874
|
+
Couverture : `npm run coverage` dans `@nodefony/realtime`. Pour la charge et la mémoire, les
|
|
875
|
+
outils dédiés sont les skills `nodefony-load-test` et `nodefony-check-memory-health`.
|
|
876
|
+
|
|
877
|
+
## 🔗 Pour aller plus loin
|
|
878
|
+
|
|
879
|
+
- ⬆️ **Retour au hub** : [Realtime — vue d'ensemble](index.md) · [Toute la documentation](../../../../../docs/index.md)
|
|
880
|
+
- 🧭 **Pages sœurs** : [Vocabulaire](vocabulaire.md) · [Configuration](configuration.md) · [Sécurité](securite.md) · [Cookbook — un chat complet](cookbook-chat.md)
|
|
881
|
+
|
|
882
|
+
- Les termes employés ici, avec leurs analogies → [vocabulaire](./vocabulaire.md)
|
|
883
|
+
- Toutes les clés de configuration, driver par driver → [configuration](./configuration.md)
|
|
884
|
+
- Qui parle, qui a le droit, comment le pare-feu se branche → [sécurité](./securite.md)
|
|
885
|
+
- Le même chat, déroulé de bout en bout jusqu'au déploiement → [cookbook](./cookbook-chat.md)
|
|
886
|
+
- Où la couche temps réel s'insère dans le traitement d'une requête →
|
|
887
|
+
[pipeline de requête](../../../../../docs/architecture/pipeline-requete.md)
|
|
888
|
+
- Les signatures exactes ne sont jamais recopiées ici : elles vivent dans le graphe
|
|
889
|
+
symbolique `.ai/symbols.json`, régénéré depuis les TSDoc du code.
|