@nodefony/realtime 10.0.0-beta.2 → 10.0.0-beta.4
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 +2 -2
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/index.js +18 -13
- package/dist/nodefony/config/config.js +7 -5
- package/dist/nodefony/config/configSchema.js +18 -0
- package/dist/nodefony/config/defineModuleConfig.js +43 -17
- package/dist/nodefony/decorators/realtimeDecorators.js +69 -19
- package/dist/nodefony/interfaces/IRealtimeConnectionState.js +1 -0
- package/dist/nodefony/src/backplane/ClusterBackplane.js +1 -2
- package/dist/nodefony/src/backplane/LoopbackBackplane.js +1 -2
- package/dist/nodefony/src/backplane/RedisBackplane.js +22 -6
- package/dist/nodefony/src/backplane/backplaneRegistry.js +1 -2
- package/dist/nodefony/src/backplane/bootNotices.js +42 -0
- package/dist/nodefony/src/backplane/envelope.js +1 -2
- package/dist/nodefony/src/backplane/originId.js +1 -2
- package/dist/nodefony/src/backplane/publishQueue.js +1 -5
- package/dist/nodefony/src/cluster/ClusterProbeClient.js +16 -6
- package/dist/nodefony/src/guards.js +40 -0
- package/dist/nodefony/src/server/AnonymousRealtimeToken.js +2 -6
- package/dist/nodefony/src/server/RealtimeAdminApi.js +1 -2
- package/dist/nodefony/src/server/RealtimeController.js +226 -82
- package/dist/nodefony/src/server/RealtimeHub.js +32 -54
- package/dist/nodefony/src/server/ServerRealtimeSocket.js +30 -13
- package/dist/nodefony/src/server/deniedDetail.js +1 -2
- package/dist/nodefony/src/server/subscribedAck.js +29 -0
- package/dist/nodefony/src/server/syslogUplink.js +5 -4
- package/dist/nodefony/src/server/welcomeEnv.js +1 -2
- package/dist/nodefony/src/service/RealtimeService.js +4 -4
- package/dist/nodefony/src/transport/WsConnectionTransport.js +42 -2
- package/dist/nodefony/testing/index.js +118 -5
- package/dist/types/index.d.ts +1 -0
- package/dist/types/nodefony/config/configSchema.d.ts +12 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +29 -6
- package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +19 -2
- package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +40 -0
- package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +1 -1
- package/dist/types/nodefony/interfaces/IRealtimeHandshake.d.ts +1 -1
- package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +13 -0
- package/dist/types/nodefony/interfaces/index.d.ts +1 -0
- package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +7 -0
- package/dist/types/nodefony/src/backplane/bootNotices.d.ts +25 -0
- package/dist/types/nodefony/src/guards.d.ts +32 -0
- package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +13 -0
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +82 -4
- package/dist/types/nodefony/src/server/RealtimeHub.d.ts +22 -16
- package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +18 -8
- package/dist/types/nodefony/src/server/subscribedAck.d.ts +23 -0
- package/dist/types/nodefony/src/service/RealtimeService.d.ts +1 -1
- package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +26 -1
- package/dist/types/nodefony/testing/index.d.ts +8 -3
- package/docs/actions.md +129 -45
- package/docs/architecture.md +143 -135
- package/docs/cadence-et-contre-pression.md +643 -0
- package/docs/configuration.md +126 -97
- package/docs/cookbook-chat.md +50 -43
- package/docs/index.md +63 -38
- package/docs/observabilite.md +58 -56
- package/docs/protocole.md +110 -76
- package/docs/securite.md +128 -117
- package/docs/vocabulaire.md +133 -129
- package/package.json +17 -13
- package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
- package/docs/couverture-bancs.md +0 -139
package/docs/architecture.md
CHANGED
|
@@ -124,9 +124,9 @@ Nodefony étiquette avec **JSON-RPC 2.0**, une norme publique plutôt qu'un form
|
|
|
124
124
|
| **notification** | non | non | pousser un événement, s'abonner, se désabonner |
|
|
125
125
|
|
|
126
126
|
Le point remarquable : le **même** moteur de protocole tourne des deux côtés du fil. La
|
|
127
|
-
classe `JsonRpcPeer` (`JsonRpcPeer.ts
|
|
128
|
-
l'exécute dans `
|
|
129
|
-
`RealtimeController.onHandshake()` (`RealtimeController.ts
|
|
127
|
+
classe `JsonRpcPeer` (`JsonRpcPeer.ts#JsonRpcPeer`) est du code isomorphe du cœur — le navigateur
|
|
128
|
+
l'exécute dans `NodefonySocket`, le serveur l'instancie une fois par connexion dans
|
|
129
|
+
`RealtimeController.onHandshake()` (`RealtimeController.ts#RealtimeController.onHandshake`). Le serveur peut donc
|
|
130
130
|
appeler le client, pas seulement l'inverse : c'est du vrai duplex, pas un aller-retour
|
|
131
131
|
déguisé.
|
|
132
132
|
|
|
@@ -136,16 +136,16 @@ Trois partis pris distinguent cette pile d'un simple « serveur WebSocket ».
|
|
|
136
136
|
|
|
137
137
|
**Un seul contrat de socket, des deux côtés.** Un service back qui pousse des événements
|
|
138
138
|
écrit le même code qu'une page front : `publish` / `subscribe` / `on`. Côté serveur c'est
|
|
139
|
-
`ServerRealtimeSocket` (`ServerRealtimeSocket.ts
|
|
140
|
-
(`ServerRealtimeSocket.ts
|
|
141
|
-
et explicite : `ServerRealtimeSocket.request()` (`ServerRealtimeSocket.ts
|
|
139
|
+
`ServerRealtimeSocket` (`ServerRealtimeSocket.ts#ServerRealtimeSocket`), obtenu par `serverSocket()`
|
|
140
|
+
(`ServerRealtimeSocket.ts#serverSocket`) ; côté navigateur, `NodefonySocket`. Une exception, assumée
|
|
141
|
+
et explicite : `ServerRealtimeSocket.request()` (`ServerRealtimeSocket.ts#ServerRealtimeSocket.request`) **rejette
|
|
142
142
|
toujours** — un handle posé sur le hub n'a pas d'interlocuteur unique, puisque le hub est
|
|
143
143
|
multi-clients. Pour un appel serveur→client ciblé, on passe par la connexion :
|
|
144
|
-
`RealtimeController.requestClient()` (`RealtimeController.ts
|
|
144
|
+
`RealtimeController.requestClient()` (`RealtimeController.ts#RealtimeController.requestClient`).
|
|
145
145
|
|
|
146
146
|
**Un provider par canal, pas un par client.** Si mille onglets s'abonnent au même canal de
|
|
147
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
|
|
148
|
+
abonné et le détruit au **dernier** (`RealtimeHub.subscribe()`, `RealtimeHub.ts#RealtimeHub.subscribe`) : le
|
|
149
149
|
coût suit le nombre de canaux, pas le nombre de clients.
|
|
150
150
|
|
|
151
151
|
**Le cluster est un détail de configuration.** Ni ton contrôleur ni ton client ne savent
|
|
@@ -154,7 +154,7 @@ chaîne de caractères. Passer de mono-process à multi-pods ne touche aucune li
|
|
|
154
154
|
|
|
155
155
|
**Le compromis, dit franchement** : un canal **ne franchit PAS** la frontière du process
|
|
156
156
|
tant que tu ne l'as pas déclaré. Le défaut est l'isolement (`RealtimeHub.publish()`,
|
|
157
|
-
`RealtimeHub.ts
|
|
157
|
+
`RealtimeHub.ts#RealtimeHub.publish`) — un choix de sûreté détaillé plus bas.
|
|
158
158
|
|
|
159
159
|
## 🚀 Démarrage rapide
|
|
160
160
|
|
|
@@ -164,25 +164,31 @@ trois fichiers, et il fonctionne en mono-process comme en cluster.
|
|
|
164
164
|
### 1. Charger le module et choisir le fond de panier
|
|
165
165
|
|
|
166
166
|
```ts
|
|
167
|
+
// nodefony/config/realtime.ts — le fragment du module, importé par le manifeste
|
|
168
|
+
import type { IRealtimeConfigInput } from "@nodefony/realtime";
|
|
169
|
+
|
|
170
|
+
export const realtimeConfig = () =>
|
|
171
|
+
({
|
|
172
|
+
// "loopback" = un seul process : le hub suffit, aucun transport réseau.
|
|
173
|
+
// "cluster" (workers IPC) ou "redis" (multi-pods) sans changer une ligne de code.
|
|
174
|
+
backplane: { driver: "loopback" },
|
|
175
|
+
// Défense CSRF de l'upgrade WebSocket : SameSite ne protège pas un handshake.
|
|
176
|
+
csrf: {
|
|
177
|
+
checkOrigin: {
|
|
178
|
+
enabled: true,
|
|
179
|
+
allowList: ["http://localhost:5151"],
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
}) satisfies IRealtimeConfigInput;
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
```ts ignore
|
|
167
186
|
// nodefony.config.ts
|
|
168
187
|
import { defineConfig, use } from "nodefony";
|
|
188
|
+
import { realtimeConfig } from "./nodefony/config/realtime";
|
|
169
189
|
|
|
170
190
|
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
|
-
],
|
|
191
|
+
modules: ["@nodefony/framework", use("@nodefony/realtime", realtimeConfig())],
|
|
186
192
|
}));
|
|
187
193
|
```
|
|
188
194
|
|
|
@@ -260,7 +266,7 @@ export default ChatController;
|
|
|
260
266
|
|
|
261
267
|
```ts
|
|
262
268
|
// frontend/src/chat.ts
|
|
263
|
-
import {
|
|
269
|
+
import { NodefonySocket } from "nodefony/client";
|
|
264
270
|
|
|
265
271
|
interface ChatMessage {
|
|
266
272
|
from: string;
|
|
@@ -271,7 +277,7 @@ interface ChatMessage {
|
|
|
271
277
|
const scheme = location.protocol === "https:" ? "wss" : "ws";
|
|
272
278
|
// `shared` = une seule socket par URL pour toute la page, même si dix
|
|
273
279
|
// composants la demandent (les options ne comptent qu'à la création).
|
|
274
|
-
const socket =
|
|
280
|
+
const socket = NodefonySocket.shared({
|
|
275
281
|
url: `${scheme}://${location.host}/chat/realtime`,
|
|
276
282
|
});
|
|
277
283
|
|
|
@@ -291,7 +297,7 @@ export async function start(): Promise<void> {
|
|
|
291
297
|
### Ce qu'on observe
|
|
292
298
|
|
|
293
299
|
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
|
|
300
|
+
même forme, quel que soit le driver (`src/packages/@nodefony/realtime/index.ts#Realtime.#wireBackplane`) :
|
|
295
301
|
|
|
296
302
|
```
|
|
297
303
|
realtime backplane driver=loopback kind=local cross-pod=no (hub local)
|
|
@@ -319,13 +325,13 @@ entre les deux **est** le facteur d'amplification du canal.
|
|
|
319
325
|
|
|
320
326
|
La pile se lit de haut en bas ; chaque étage ne connaît que son voisin du dessous.
|
|
321
327
|
|
|
322
|
-
| Étage | Qui
|
|
323
|
-
| ---------- |
|
|
324
|
-
| Applicatif | ton contrôleur, tes services
|
|
325
|
-
| Protocole | `JsonRpcPeer` (`JsonRpcPeer.ts
|
|
326
|
-
| Transport | `WsConnectionTransport` (`WsConnectionTransport.ts
|
|
327
|
-
| Hub | `RealtimeHub` (`RealtimeHub.ts
|
|
328
|
-
| Backplane | `IBackplane` (`IBackplane.ts
|
|
328
|
+
| Étage | Qui | Sa seule responsabilité | Ce qu'il ignore |
|
|
329
|
+
| ---------- | -------------------------------------------------------------------------- | --------------------------------------- | ----------------------------- |
|
|
330
|
+
| Applicatif | ton contrôleur, tes services | le métier : quoi publier, quoi accepter | tout le reste |
|
|
331
|
+
| Protocole | `JsonRpcPeer` (`JsonRpcPeer.ts#JsonRpcPeer`) | étiqueter, corréler, refuser une frame | par où passent les octets |
|
|
332
|
+
| Transport | `WsConnectionTransport` (`WsConnectionTransport.ts#WsConnectionTransport`) | déplacer des octets, mesurer la file | ce que veut dire un message |
|
|
333
|
+
| Hub | `RealtimeHub` (`RealtimeHub.ts#RealtimeHub`) | table des abonnés locaux + fan-out | qu'il existe d'autres process |
|
|
334
|
+
| Backplane | `IBackplane` (`IBackplane.ts#IBackplane`) | porter un message aux autres process | ce qu'est un abonné |
|
|
329
335
|
|
|
330
336
|
> [!NOTE]
|
|
331
337
|
> Une part de la documentation historique décrit cette pile en « 5 étages » en comptant
|
|
@@ -338,11 +344,11 @@ La pile se lit de haut en bas ; chaque étage ne connaît que son voisin du dess
|
|
|
338
344
|
dispatch des notifications, gestion des erreurs, `dispose()` propre. Deux points
|
|
339
345
|
d'interception y sont posés :
|
|
340
346
|
|
|
341
|
-
- `beforeDispatch` (`JsonRpcPeer.ts
|
|
347
|
+
- `beforeDispatch` (`JsonRpcPeer.ts#JsonRpcPeerOptions.beforeDispatch`) — un verrou **synchrone** appelé avant tout
|
|
342
348
|
traitement de frame. Il rend `true`/`false`. Un refus sur une requête produit
|
|
343
349
|
`-32001 unauthorized` ; sur une notification, la frame est jetée
|
|
344
|
-
(`JsonRpcPeer.ts
|
|
345
|
-
- `onFrameAudit` (`JsonRpcPeer.ts
|
|
350
|
+
(`JsonRpcPeer.ts#JsonRpcPeer.receive`).
|
|
351
|
+
- `onFrameAudit` (`JsonRpcPeer.ts#JsonRpcPeerOptions.onFrameAudit`) — la trace des événements protocolaires notables
|
|
346
352
|
(frame invalide, refusée, méthode inconnue, erreur interne).
|
|
347
353
|
|
|
348
354
|
La contrainte de synchronisme n'est pas un oubli : un `await` par frame coûterait une
|
|
@@ -355,28 +361,30 @@ au handshake, puis lue en O(1). C'est le compromis assumé, documenté dans
|
|
|
355
361
|
Particularité contre-intuitive : côté serveur, le transport **n'écoute pas** d'événement
|
|
356
362
|
socket. C'est le pipeline HTTP de `@nodefony/http` qui appelle la route WebSocket du
|
|
357
363
|
contrôleur à chaque message reçu, laquelle pousse la charge dans
|
|
358
|
-
`WsConnectionTransport.feed()` (`WsConnectionTransport.ts
|
|
364
|
+
`WsConnectionTransport.feed()` (`WsConnectionTransport.ts#WsConnectionTransport.feed`). La fermeture arrive de la
|
|
359
365
|
même façon, par le hook `onFinish` du contexte, qui déclenche `fireClose()`
|
|
360
|
-
(`WsConnectionTransport.ts
|
|
366
|
+
(`WsConnectionTransport.ts#WsConnectionTransport.fireClose`).
|
|
361
367
|
|
|
362
368
|
Ce transport porte aussi la **back-pressure**. Elle n'est pas câblée en dur : les trois
|
|
363
369
|
leviers sont des clés de configuration du serveur WebSocket, lues par le transport
|
|
364
|
-
(`WsConnectionTransport.ts
|
|
365
|
-
(`http/nodefony/config/config.ts
|
|
370
|
+
(`WsConnectionTransport.ts#WsConnectionTransport`) et documentées dans `@nodefony/http`
|
|
371
|
+
(`http/nodefony/config/config.ts#httpConfigSchema`).
|
|
366
372
|
|
|
367
|
-
| File non drainée (`bufferedAmount`)
|
|
368
|
-
|
|
|
369
|
-
| sous le seuil
|
|
370
|
-
| au-dessus du seuil
|
|
371
|
-
|
|
|
373
|
+
| File non drainée (`bufferedAmount`) | Décision | Réglage (défaut) |
|
|
374
|
+
| ----------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------- |
|
|
375
|
+
| sous le seuil | envoi normal | — |
|
|
376
|
+
| au-dessus du seuil | politique appliquée : frame **jetée**, ou fermeture | `websocket.maxBackpressure` (**4 MiB**) · `websocket.backpressurePolicy` (**`drop`**) |
|
|
377
|
+
| **solde** de refus atteint | fermeture `1013` (_Try Again Later_) | `websocket.backpressureCloseAfterDrops` (**1000**) |
|
|
372
378
|
|
|
373
379
|
Deux points que la formulation « deux seuils d'octets » faisait manquer : la fermeture ne
|
|
374
|
-
se déclenche pas sur un volume mais sur
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
+
se déclenche pas sur un volume mais sur un **solde de frames jetées** — +1 par refus, −1 par
|
|
381
|
+
envoi, si bien qu'un pic passager redescend et qu'un client qui n'absorbe plus finit coupé — et
|
|
382
|
+
`drop` est un choix, `close` fermant dès le premier dépassement. Jeter est sans conséquence sur
|
|
383
|
+
un canal d'état, où le prochain instantané remplace celui qu'on a sauté ; mais le rejet ne trie
|
|
384
|
+
rien, et une réponse RPC ou un événement jetés sont perdus. Détail et courbes :
|
|
385
|
+
[Cadence et contre-pression](./cadence-et-contre-pression.md).
|
|
386
|
+
|
|
387
|
+
À ne pas confondre avec `SLOW_CONSUMER_BYTES` (`RealtimeHub.ts#SLOW_CONSUMER_BYTES`, 1 MiB) : ce seuil-là
|
|
380
388
|
ne jette rien, il **compte** — c'est celui à partir duquel la sonde marque une connexion
|
|
381
389
|
`slowConsumer`.
|
|
382
390
|
|
|
@@ -388,7 +396,7 @@ Le schéma ci-dessous rend ces étages vivants : active le temps réel et il res
|
|
|
388
396
|
|
|
389
397
|
## 🔌 Le cycle de vie d'une connexion
|
|
390
398
|
|
|
391
|
-
Tout se joue dans `RealtimeController.onHandshake()` (`RealtimeController.ts
|
|
399
|
+
Tout se joue dans `RealtimeController.onHandshake()` (`RealtimeController.ts#RealtimeController.onHandshake`), appelé
|
|
392
400
|
une seule fois par connexion, en chemin froid.
|
|
393
401
|
|
|
394
402
|
```mermaid
|
|
@@ -419,19 +427,19 @@ sequenceDiagram
|
|
|
419
427
|
|
|
420
428
|
Les étapes, dans l'ordre exact du code :
|
|
421
429
|
|
|
422
|
-
1. **Contrôle de l'origine** — `RealtimeHub.checkOrigin()` (`RealtimeHub.ts
|
|
430
|
+
1. **Contrôle de l'origine** — `RealtimeHub.checkOrigin()` (`RealtimeHub.ts#RealtimeHub.checkOrigin`). Refus →
|
|
423
431
|
fermeture `4003`. La politique vient de la configuration ; sans politique, tout passe.
|
|
424
432
|
2. **Résolution de l'identité** — `RealtimeHub.resolveAuthenticator()`
|
|
425
|
-
(`RealtimeHub.ts
|
|
433
|
+
(`RealtimeHub.ts#RealtimeHub.resolveAuthenticator`) parcourt les authentificateurs enregistrés : **le premier motif
|
|
426
434
|
qui correspond capture**. Aucun ne correspond ? Le jeton anonyme gelé est posé
|
|
427
435
|
(`ANONYMOUS_REALTIME_TOKEN`) — la lecture d'identité ne rend donc jamais `null`. Un
|
|
428
436
|
échec d'authentification ferme en `4001`.
|
|
429
437
|
3. **Création du peer et du transport**, puis association `peer → jeton`
|
|
430
|
-
(`RealtimeHub.setTokenForPeer()`, `RealtimeHub.ts
|
|
438
|
+
(`RealtimeHub.setTokenForPeer()`, `RealtimeHub.ts#RealtimeHub.setTokenForPeer`), stockée dans une `WeakMap` : le
|
|
431
439
|
jeton disparaît avec le peer, sans fuite.
|
|
432
440
|
4. **Enregistrement des actions** — celles des décorateurs `@RealtimeAction`, puis celles
|
|
433
441
|
de la surcharge `realtimeActions()`, qui gagne en cas de conflit. Le pont API
|
|
434
|
-
`api.request` n'est ajouté que si `realtimeApiRequest()` (`RealtimeController.ts
|
|
442
|
+
`api.request` n'est ajouté que si `realtimeApiRequest()` (`RealtimeController.ts#RealtimeController.realtimeApiRequest`)
|
|
435
443
|
rend `true`.
|
|
436
444
|
5. **Inscription aux registres** : sonde de connexion, révocation périodique si le jeton
|
|
437
445
|
est révocable, préfixes de canaux broadcast, canaux entrants.
|
|
@@ -439,7 +447,7 @@ Les étapes, dans l'ordre exact du code :
|
|
|
439
447
|
l'identité résolue (type, authentifié ou non, rôles, portées). Le client sait **qui il
|
|
440
448
|
est** sans appeler la moindre route.
|
|
441
449
|
|
|
442
|
-
À la fermeture, un unique `onFinish` (`RealtimeController.ts
|
|
450
|
+
À la fermeture, un unique `onFinish` (`RealtimeController.ts#RealtimeController.onHandshake`) fait le ménage complet :
|
|
443
451
|
désabonnement de chaque canal tenu, retrait des deux registres, `fireClose()` du transport,
|
|
444
452
|
`dispose()` du peer. C'est ce qui garantit qu'aucun minuteur ni écouteur ne survit à une
|
|
445
453
|
déconnexion.
|
|
@@ -447,41 +455,41 @@ déconnexion.
|
|
|
447
455
|
> [!IMPORTANT]
|
|
448
456
|
> Les frames reçues **pendant** l'authentification sont jetées silencieusement : le
|
|
449
457
|
> transport n'est pas encore branché. C'est au client d'attendre `realtime:welcome` avant
|
|
450
|
-
> de pousser — ce que `
|
|
458
|
+
> de pousser — ce que `NodefonySocket` fait nativement.
|
|
451
459
|
|
|
452
460
|
### Deux gardes qui ferment la connexion
|
|
453
461
|
|
|
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
|
|
457
|
-
| Révocation d'identité | tick périodique ; `token.isValid()` rend `false` ou lève une exception | fermeture `4001` « session revoked » | `RealtimeHub.ts
|
|
462
|
+
| Garde | Déclencheur | Effet | Ancrage |
|
|
463
|
+
| --------------------- | ---------------------------------------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------- |
|
|
464
|
+
| Plafond de canaux | une connexion dépasse son quota d'abonnements (256 par défaut) | abonnement refusé + `realtime:denied` motif `limit` | `RealtimeController.ts#RealtimeController.onRealtimeNotification` |
|
|
465
|
+
| Révocation d'identité | tick périodique ; `token.isValid()` rend `false` ou lève une exception | fermeture `4001` « session revoked » | `RealtimeHub.ts#FrameAuthorizer` |
|
|
458
466
|
|
|
459
467
|
La seconde mérite une explication. Le verrou de frame est synchrone et lit une identité
|
|
460
468
|
figée au handshake : il ne peut donc pas voir une session qui meurt en cours de route (une
|
|
461
469
|
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
|
|
470
|
+
registre se vide (`RealtimeHub.registerRevocable()`, `RealtimeHub.ts#RealtimeHub.registerRevocable`) — relit
|
|
463
471
|
périodiquement ces identités et coupe les sockets orphelines. Seules les identités
|
|
464
472
|
**révocables** y entrent : un visiteur anonyme ne coûte rien.
|
|
465
473
|
|
|
466
474
|
## Le hub — canaux partagés et fan-out
|
|
467
475
|
|
|
468
|
-
Le hub est un singleton par process (`getRealtimeHub()`, `RealtimeHub.ts
|
|
476
|
+
Le hub est un singleton par process (`getRealtimeHub()`, `RealtimeHub.ts#getRealtimeHub`). Il ne
|
|
469
477
|
dépend de rien : ce sont les fabriques fournies par les contrôleurs qui portent les
|
|
470
478
|
dépendances.
|
|
471
479
|
|
|
472
480
|
### Le provider partagé — un ticker, pas mille
|
|
473
481
|
|
|
474
|
-
`RealtimeHub.subscribe()` (`RealtimeHub.ts
|
|
482
|
+
`RealtimeHub.subscribe()` (`RealtimeHub.ts#RealtimeHub.subscribe`) applique une mécanique en trois temps :
|
|
475
483
|
|
|
476
484
|
1. Le canal existe déjà ? On ajoute simplement le sink de cette connexion. Fin.
|
|
477
485
|
2. Sinon, on inscrit le sink **avant** d'appeler la fabrique — de sorte que le tout premier
|
|
478
486
|
paquet du producteur atteigne bien ce premier abonné.
|
|
479
487
|
3. La fabrique du contrôleur rend `null` (canal inconnu de lui) ? Dernier recours : le
|
|
480
488
|
registre des **canaux système** (`RealtimeHub.registerSystemChannel()`,
|
|
481
|
-
`RealtimeHub.ts
|
|
489
|
+
`RealtimeHub.ts#RealtimeHub.registerSystemChannel`), qu'un module bas niveau alimente au démarrage. Toujours `null` →
|
|
482
490
|
l'abonnement est refusé et rien n'est alloué.
|
|
483
491
|
|
|
484
|
-
Au dernier désabonnement, `RealtimeHub.unsubscribe()` (`RealtimeHub.ts
|
|
492
|
+
Au dernier désabonnement, `RealtimeHub.unsubscribe()` (`RealtimeHub.ts#RealtimeHub.unsubscribe`) appelle le
|
|
485
493
|
`dispose` du producteur et retire le canal. Un producteur fautif qui lève une exception ne
|
|
486
494
|
bloque pas le nettoyage.
|
|
487
495
|
|
|
@@ -494,24 +502,24 @@ bloque pas le nettoyage.
|
|
|
494
502
|
|
|
495
503
|
Deux méthodes qui se ressemblent et ne font pas du tout la même chose :
|
|
496
504
|
|
|
497
|
-
| Méthode
|
|
498
|
-
|
|
|
499
|
-
| `publish()` (`RealtimeHub.ts
|
|
500
|
-
| `publishLocal()` (`RealtimeHub.ts
|
|
505
|
+
| Méthode | Fan-out local | Propagation aux autres process | Qui l'appelle |
|
|
506
|
+
| ------------------------------------------------------------ | :-----------: | :--------------------------------: | ----------------------------------- |
|
|
507
|
+
| `publish()` (`RealtimeHub.ts#RealtimeHub.publish`) | oui | oui, **si** le canal est broadcast | producteurs, contrôleurs, services |
|
|
508
|
+
| `publishLocal()` (`RealtimeHub.ts#RealtimeHub.publishLocal`) | oui | **jamais** | l'arrivée d'un message du backplane |
|
|
501
509
|
|
|
502
510
|
C'est **la** règle qui empêche la tempête : un message reçu d'un pair est réinjecté
|
|
503
511
|
localement et **ne repart pas**. Le câblage se fait une fois pour toutes dans
|
|
504
|
-
`RealtimeHub.setBackplane()` (`RealtimeHub.ts
|
|
512
|
+
`RealtimeHub.setBackplane()` (`RealtimeHub.ts#RealtimeHub.setBackplane`), qui branche l'arrivée du backplane
|
|
505
513
|
directement sur `publishLocal`.
|
|
506
514
|
|
|
507
|
-
Le fan-out lui-même (`RealtimeHub.ts
|
|
515
|
+
Le fan-out lui-même (`RealtimeHub.ts#RealtimeHub.#channelPolicyPatterns`) est **isolé** : chaque livraison est protégée,
|
|
508
516
|
une connexion fautive n'interrompt pas la diffusion aux autres.
|
|
509
517
|
|
|
510
518
|
### Le forward est OPT-IN — le défaut est l'isolement
|
|
511
519
|
|
|
512
520
|
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
|
|
514
|
-
directement `RealtimeHub.markBroadcastChannel()` (`RealtimeHub.ts
|
|
521
|
+
`@RealtimeBroadcast` sur ton contrôleur (`realtimeDecorators.ts#RealtimeBroadcast`) ou
|
|
522
|
+
directement `RealtimeHub.markBroadcastChannel()` (`RealtimeHub.ts#RealtimeHub.markBroadcastChannel`).
|
|
515
523
|
|
|
516
524
|
Trois raisons à ce choix, qui prend à contre-pied la plupart des bibliothèques temps réel :
|
|
517
525
|
|
|
@@ -547,21 +555,21 @@ coûte chaque abonné supplémentaire.
|
|
|
547
555
|
|
|
548
556
|
### Le contrat, en cinq méthodes
|
|
549
557
|
|
|
550
|
-
`IBackplane` (`IBackplane.ts
|
|
558
|
+
`IBackplane` (`IBackplane.ts#IBackplane`) est délibérément minuscule : le backplane ne connaît ni
|
|
551
559
|
les abonnés ni les canaux logiques. Tout l'état vit dans le hub.
|
|
552
560
|
|
|
553
|
-
| Membre
|
|
554
|
-
|
|
|
555
|
-
| `originId` (`IBackplane.ts
|
|
556
|
-
| `start()` (`IBackplane.ts
|
|
557
|
-
| `publish(channel, payload)` (`IBackplane.ts
|
|
558
|
-
| `onMessage(handler)` (`IBackplane.ts
|
|
559
|
-
| `stop()` (`IBackplane.ts
|
|
560
|
-
| `describe()` (`IBackplane.ts
|
|
561
|
+
| Membre | Rôle |
|
|
562
|
+
| ---------------------------------------------------------------- | --------------------------------------------------------------- |
|
|
563
|
+
| `originId` (`IBackplane.ts#IBackplane.originId`) | l'étiquette de CE process, lue par l'anti-écho |
|
|
564
|
+
| `start()` (`IBackplane.ts#IBackplane.start`) | ouvrir le transport. Idempotent, synchrone ou asynchrone |
|
|
565
|
+
| `publish(channel, payload)` (`IBackplane.ts#IBackplane.publish`) | propager aux **autres** pairs. Ne refait pas le fan-out local |
|
|
566
|
+
| `onMessage(handler)` (`IBackplane.ts#IBackplane.onMessage`) | recevoir des pairs. Un seul gestionnaire à la fois |
|
|
567
|
+
| `stop()` (`IBackplane.ts#IBackplane.stop`) | libérer connexions et écouteurs. Idempotent |
|
|
568
|
+
| `describe()` (`IBackplane.ts#IBackplane.describe`) | la carte d'identité : driver, nature, origine, cross-pod, canal |
|
|
561
569
|
|
|
562
570
|
Deux absences volontaires. Il n'y a **pas** de `subscribe`/`unsubscribe` par canal : le
|
|
563
571
|
transport porte **un seul** canal physique, et les canaux logiques voyagent **dans**
|
|
564
|
-
l'enveloppe (`IBackplaneMessage`, `IBackplane.ts
|
|
572
|
+
l'enveloppe (`IBackplaneMessage`, `IBackplane.ts#IBackplaneMessage`). Un seul abonnement au démarrage,
|
|
565
573
|
zéro (dés)abonnement dynamique — et le hub reste seul maître de la table des canaux.
|
|
566
574
|
|
|
567
575
|
`describe()`, lui, alimente **trois sorties cohérentes** depuis une source unique : la ligne
|
|
@@ -574,11 +582,11 @@ resynchronise. Ne construis pas au-dessus une fiabilité que le support n'offre
|
|
|
574
582
|
|
|
575
583
|
### Les drivers natifs
|
|
576
584
|
|
|
577
|
-
| Driver | Classe
|
|
578
|
-
| ---------- |
|
|
579
|
-
| `loopback` | `LoopbackBackplane` (`LoopbackBackplane.ts
|
|
580
|
-
| `cluster` | `ClusterBackplane` (`ClusterBackplane.ts
|
|
581
|
-
| `redis` | `RedisBackplane` (`RedisBackplane.ts
|
|
585
|
+
| Driver | Classe | Nature | Pairs | Traverse les pods |
|
|
586
|
+
| ---------- | -------------------------------------------------------------- | -------------- | ---------------------------------- | :---------------: |
|
|
587
|
+
| `loopback` | `LoopbackBackplane` (`LoopbackBackplane.ts#LoopbackBackplane`) | `local` | aucun (no-op complet) | non |
|
|
588
|
+
| `cluster` | `ClusterBackplane` (`ClusterBackplane.ts#ClusterBackplane`) | `ipc` | les workers du même process maître | non |
|
|
589
|
+
| `redis` | `RedisBackplane` (`RedisBackplane.ts#RedisBackplane`) | `redis-pubsub` | tous les pods abonnés au canal | **oui** |
|
|
582
590
|
|
|
583
591
|
**`loopback`** est le défaut. En réalité, le hub garde son backplane à `null` en
|
|
584
592
|
mono-process : le coût est un test de nullité, pas un appel. La classe existe pour
|
|
@@ -586,30 +594,30 @@ matérialiser le contrat et prouver, en test, que brancher un backplane sans pai
|
|
|
586
594
|
rien.
|
|
587
595
|
|
|
588
596
|
**`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
|
|
597
|
+
il émet vers lui (`ClusterBackplane.publish()`, `ClusterBackplane.ts#ClusterBackplane.publish`), le maître
|
|
590
598
|
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
|
|
599
|
+
`NF_CLUSTER=1` (`src/packages/@nodefony/realtime/index.ts`) ; ailleurs il rend
|
|
592
600
|
`null`. Aucune infrastructure requise — c'est le banc d'essai qui stabilise l'architecture
|
|
593
601
|
multi-process avant d'ajouter du réseau.
|
|
594
602
|
|
|
595
603
|
**`redis`** franchit la frontière de l'hôte, sans dépendre de la bibliothèque `redis` : il
|
|
596
604
|
consomme deux connexions du module `@nodefony/redis` via un adaptateur purement structurel,
|
|
597
|
-
`createRedisServiceTransport()` (`RedisBackplane.ts
|
|
605
|
+
`createRedisServiceTransport()` (`RedisBackplane.ts#createRedisServiceTransport`). Deux connexions et non une, parce
|
|
598
606
|
qu'un client Redis abonné ne peut plus émettre de commandes ordinaires. Module absent ou
|
|
599
607
|
connexions indisponibles → avertissement, `null`, hub local, démarrage poursuivi
|
|
600
|
-
(`src/packages/@nodefony/realtime/index.ts
|
|
608
|
+
(`src/packages/@nodefony/realtime/index.ts`).
|
|
601
609
|
|
|
602
610
|
### Le registre de drivers — zéro nom en dur
|
|
603
611
|
|
|
604
612
|
Choisir le driver par une cascade de `if (driver === "redis")` trahirait la promesse de
|
|
605
613
|
brancherie. La sélection passe donc par un registre — `registerBackplaneDriver()`
|
|
606
|
-
(`backplaneRegistry.ts
|
|
614
|
+
(`backplaneRegistry.ts#registerBackplaneDriver`) et `getBackplaneDriver()` (`backplaneRegistry.ts#getBackplaneDriver`) : chaque
|
|
607
615
|
driver porte son nom en membre statique, les natifs s'inscrivent au chargement du module, et
|
|
608
616
|
le câblage résout une chaîne vers une fabrique sans connaître aucun littéral
|
|
609
|
-
(`src/packages/@nodefony/realtime/index.ts
|
|
617
|
+
(`src/packages/@nodefony/realtime/index.ts#Realtime.onKernelBoot`).
|
|
610
618
|
|
|
611
619
|
Une fabrique reçoit module, `originId`, rôle dans la topologie et configuration validée
|
|
612
|
-
(`IBackplaneFactoryContext`, `backplaneRegistry.ts
|
|
620
|
+
(`IBackplaneFactoryContext`, `backplaneRegistry.ts#IBackplaneFactoryContext`), puis rend une instance ou `null`
|
|
613
621
|
(« inactif ici »). Elle ne démarre rien : c'est le câblage qui appelle `start()`.
|
|
614
622
|
|
|
615
623
|
### L'anti-écho et l'`originId`
|
|
@@ -620,9 +628,9 @@ et non une :
|
|
|
620
628
|
|
|
621
629
|
1. **Côté hub** : l'arrivée passe par `publishLocal`, jamais par `publish`. Rien ne repart.
|
|
622
630
|
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
|
|
631
|
+
on jette si c'est le même (`RedisBackplane.ts#RedisBackplane.originId`, `ClusterBackplane.ts#ClusterBackplane.#ingress`).
|
|
624
632
|
|
|
625
|
-
L'étiquette elle-même est calculée par `resolveBackplaneOriginId()` (`originId.ts
|
|
633
|
+
L'étiquette elle-même est calculée par `resolveBackplaneOriginId()` (`originId.ts#resolveBackplaneOriginId`), et
|
|
626
634
|
sa recette mérite qu'on s'y arrête :
|
|
627
635
|
|
|
628
636
|
| Étape | Valeur | Pourquoi |
|
|
@@ -641,14 +649,14 @@ sa recette mérite qu'on s'y arrête :
|
|
|
641
649
|
|
|
642
650
|
### Le cloisonnement du canal Redis
|
|
643
651
|
|
|
644
|
-
Le canal pub/sub de base est `nodefony:realtime` (`RedisBackplane.ts
|
|
652
|
+
Le canal pub/sub de base est `nodefony:realtime` (`RedisBackplane.ts#REDIS_RT_CHANNEL`). Problème : le
|
|
645
653
|
numéro de base Redis **ne cloisonne pas** le pub/sub, qui est global au serveur. Deux
|
|
646
654
|
applications sur un Redis mutualisé se parleraient.
|
|
647
655
|
|
|
648
|
-
D'où le suffixe de cloison, calculé par `resolveRedisChannel()` (`RedisBackplane.ts
|
|
656
|
+
D'où le suffixe de cloison, calculé par `resolveRedisChannel()` (`RedisBackplane.ts#resolveRedisChannel`) :
|
|
649
657
|
le canal effectif devient `nodefony:realtime:<namespace>`. Le namespace vient de la
|
|
650
658
|
configuration ; à défaut, il est dérivé du nom de l'application
|
|
651
|
-
(`src/packages/@nodefony/realtime/index.ts
|
|
659
|
+
(`src/packages/@nodefony/realtime/index.ts`).
|
|
652
660
|
|
|
653
661
|
> [!TIP]
|
|
654
662
|
> Deux déploiements de la **même** application sur un Redis partagé (préproduction et
|
|
@@ -656,19 +664,19 @@ configuration ; à défaut, il est dérivé du nom de l'application
|
|
|
656
664
|
> namespace **explicite et distinct** dans ces cas-là.
|
|
657
665
|
|
|
658
666
|
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
|
|
667
|
+
est ignoré, pas propagé (`RedisBackplane.ts#RedisBackplane`).
|
|
660
668
|
|
|
661
669
|
### Le démarrage, et ce qui se passe quand ça rate
|
|
662
670
|
|
|
663
671
|
Le module valide sa configuration par Zod à l'enregistrement
|
|
664
|
-
(`src/packages/@nodefony/realtime/index.ts
|
|
665
|
-
(`src/packages/@nodefony/realtime/index.ts
|
|
672
|
+
(`src/packages/@nodefony/realtime/index.ts#Realtime.onKernelRegister`), puis câble tout au démarrage du noyau
|
|
673
|
+
(`src/packages/@nodefony/realtime/index.ts#Realtime.onKernelRegister`) — après l'initialisation des services (le
|
|
666
674
|
service Redis a donc ouvert ses connexions) et avant le trafic.
|
|
667
675
|
|
|
668
676
|
Le `start()` du backplane est **attendu explicitement avant** d'être branché : un driver
|
|
669
677
|
asynchrone brancherait sinon un abonnement incomplet et perdrait ses premiers messages. Et
|
|
670
678
|
il est **borné par un délai** de cinq secondes
|
|
671
|
-
(`src/packages/@nodefony/realtime/index.ts
|
|
679
|
+
(`src/packages/@nodefony/realtime/index.ts#BACKPLANE_START_TIMEOUT_MS`), pour une raison très concrète : un
|
|
672
680
|
transport réseau qui reste pendu gèlerait la phase de démarrage, et donc la montée des
|
|
673
681
|
serveurs HTTP eux-mêmes.
|
|
674
682
|
|
|
@@ -680,7 +688,7 @@ Trois issues, toutes annoncées, jamais silencieuses :
|
|
|
680
688
|
| driver inactif ici (loopback, mauvais rôle) | hub local | `INFO` |
|
|
681
689
|
| driver inconnu, ou démarrage en échec/expiré | hub local, **le démarrage continue** | `WARNING` |
|
|
682
690
|
|
|
683
|
-
Le module se déclare non critique (`src/packages/@nodefony/realtime/index.ts
|
|
691
|
+
Le module se déclare non critique (`src/packages/@nodefony/realtime/index.ts`) : son
|
|
684
692
|
échec ne tue jamais le process. C'est de la résilience **sans dégradation silencieuse** —
|
|
685
693
|
tout repli est annoncé. Enfin, `enabled: false` rend le module totalement inerte : ni API
|
|
686
694
|
d'administration, ni backplane, ni sonde.
|
|
@@ -710,18 +718,18 @@ registerBackplaneDriver(
|
|
|
710
718
|
|
|
711
719
|
**2. Déclarer un service** nommé `realtimeBackplane` dans le conteneur d'injection —
|
|
712
720
|
**la voie « instance » pour une application**. `RealtimeService.init()`
|
|
713
|
-
(`RealtimeService.ts
|
|
721
|
+
(`RealtimeService.ts#RealtimeService.init`) le résout et le branche **avant** le registre : une instance
|
|
714
722
|
déjà fournie court-circuite la sélection par nom.
|
|
715
723
|
|
|
716
724
|
**3. Passer une instance** en second argument de `defineRealtimeConfig()` — réservé au
|
|
717
725
|
code qui **appelle le builder lui-même** (un `Module` sur mesure, une intégration
|
|
718
726
|
programmatique). Une application qui configure via `use("@nodefony/realtime", …)` **ne
|
|
719
727
|
l'atteint pas** : elle ne construit pas la config (le module appelle
|
|
720
|
-
`defineRealtimeConfig(this.options)` sans second argument, `index.ts
|
|
728
|
+
`defineRealtimeConfig(this.options)` sans second argument, `index.ts#createRealtimeHarness`), et une
|
|
721
729
|
instance posée dans l'objet de config est refusée par la validation Zod (clé inconnue du
|
|
722
730
|
schéma strict). Pour une app, la voie « instance » est l'option 2 ci-dessus.
|
|
723
731
|
|
|
724
|
-
Le registre s'introspecte : `listBackplaneDrivers()` (`backplaneRegistry.ts
|
|
732
|
+
Le registre s'introspecte : `listBackplaneDrivers()` (`backplaneRegistry.ts#listBackplaneDrivers`) rend les
|
|
725
733
|
noms disponibles — c'est ce que le message d'erreur affiche quand un driver déclaré est
|
|
726
734
|
introuvable.
|
|
727
735
|
|
|
@@ -731,31 +739,31 @@ L'architecture prévoit des points de greffe pour que `@nodefony/security` s'y b
|
|
|
731
739
|
refonte. Ici, seulement **où** ils sont ; le **quoi** et le **comment** sont dans
|
|
732
740
|
[la page sécurité](./securite.md).
|
|
733
741
|
|
|
734
|
-
| Point de greffe | Étage | Moment | Ancrage
|
|
735
|
-
| ---------------------------- | --------- | -------------- |
|
|
736
|
-
| Contrôle d'origine | hub | à l'upgrade | `RealtimeHub.ts
|
|
737
|
-
| Authentificateur réseau | hub | au handshake | `RealtimeHub.ts
|
|
738
|
-
| Verrou de frame | protocole | à chaque frame | `RealtimeHub.ts
|
|
739
|
-
| Politique déclarée par canal | hub | au handshake | `RealtimeHub.ts
|
|
740
|
-
| Audit de frame | protocole | sur refus | `RealtimeController.ts
|
|
742
|
+
| Point de greffe | Étage | Moment | Ancrage |
|
|
743
|
+
| ---------------------------- | --------- | -------------- | ------------------------------------------------------ |
|
|
744
|
+
| Contrôle d'origine | hub | à l'upgrade | `RealtimeHub.ts#RealtimeHub.publishLocal` |
|
|
745
|
+
| Authentificateur réseau | hub | au handshake | `RealtimeHub.ts#RealtimeHub.unsubscribe` |
|
|
746
|
+
| Verrou de frame | protocole | à chaque frame | `RealtimeHub.ts#RealtimeHub.markBroadcastChannel` |
|
|
747
|
+
| Politique déclarée par canal | hub | au handshake | `RealtimeHub.ts#RealtimeHub.setBackplane` |
|
|
748
|
+
| Audit de frame | protocole | sur refus | `RealtimeController.ts#RealtimeController.onHandshake` |
|
|
741
749
|
|
|
742
750
|
Deux propriétés architecturales méritent d'être notées ici, parce qu'elles expliquent des
|
|
743
751
|
choix de conception visibles partout dans le module :
|
|
744
752
|
|
|
745
753
|
**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
|
|
754
|
+
si une politique existe (`RealtimeHub.hasFrameAuthorizer()`, `RealtimeHub.ts#RealtimeHub.hasFrameAuthorizer`, testé une
|
|
747
755
|
fois au handshake). Sans module de sécurité, `beforeDispatch` reste indéfini et le chemin
|
|
748
756
|
chaud ne paie **rien** du tout.
|
|
749
757
|
|
|
750
758
|
**Échec bruyant plutôt que faux sentiment de sécurité.** Si des canaux déclarent une
|
|
751
759
|
politique sans qu'aucun décideur ne soit câblé, `hasUnenforcedChannelPolicies()`
|
|
752
|
-
(`RealtimeHub.ts
|
|
753
|
-
(`RealtimeController.ts
|
|
760
|
+
(`RealtimeHub.ts#RealtimeHub.hasUnenforcedChannelPolicies`) le détecte et un avertissement est émis une fois par process
|
|
761
|
+
(`RealtimeController.ts#RealtimeController.onHandshake`). Un canal qui **se croit** gardé alors qu'il est ouvert est
|
|
754
762
|
bien plus dangereux qu'un canal ouvertement public.
|
|
755
763
|
|
|
756
764
|
## 📡 Observabilité — la sonde et Studio
|
|
757
765
|
|
|
758
|
-
`RealtimeHub.probe()` (`RealtimeHub.ts
|
|
766
|
+
`RealtimeHub.probe()` (`RealtimeHub.ts#RealtimeHub.probe`) rend un instantané en **lecture pure** : aucune
|
|
759
767
|
allocation sur le chemin chaud, jamais d'exception. Les compteurs sont des primitives
|
|
760
768
|
incrémentées en O(1) ; ils sont **monotones**, ce qui laisse au lecteur le soin de dériver
|
|
761
769
|
un débit.
|
|
@@ -768,35 +776,35 @@ un débit.
|
|
|
768
776
|
| Back-pressure | `maxBufferedAmount`, `totalBufferedAmount`, `slowConsumers`, `drops` | le risque mémoire numéro un |
|
|
769
777
|
| Fond de panier | `backplane` (la carte d'identité de `describe()`) | ce qui est réellement branché |
|
|
770
778
|
|
|
771
|
-
Le contrat complet est `IRealtimeProbe` (`IRealtimeProbe.ts
|
|
772
|
-
`buildOwnHealth()` (`RealtimeAdminApi.ts
|
|
779
|
+
Le contrat complet est `IRealtimeProbe` (`IRealtimeProbe.ts#IRealtimeProbe`). La version enrichie
|
|
780
|
+
`buildOwnHealth()` (`RealtimeAdminApi.ts#buildOwnHealth`) y ajoute l'identifiant d'instance et la sonde
|
|
773
781
|
process, plus — si elles sont disponibles — les sondes ORM et les compteurs d'erreurs. Deux
|
|
774
782
|
chemins de lecture, un seul producteur :
|
|
775
783
|
|
|
776
784
|
- **HTTP** : `GET /nodefony/realtime/api/health`, exposé par `createRealtimeAdminApi()`
|
|
777
|
-
(`RealtimeAdminApi.ts
|
|
785
|
+
(`RealtimeAdminApi.ts#createRealtimeAdminApi`) ;
|
|
778
786
|
- **Canal temps réel** : `nodefony:socket`, poussé par un ticker.
|
|
779
787
|
|
|
780
788
|
### En cluster : la vue « pod » plutôt que « worker »
|
|
781
789
|
|
|
782
790
|
Interroger la santé en cluster pose un problème sournois : la requête atterrit sur **un**
|
|
783
791
|
worker au hasard, dont l'instantané ne dit rien des autres. D'où `ClusterProbeClient`
|
|
784
|
-
(`ClusterProbeClient.ts
|
|
785
|
-
périodiquement sa santé au maître (`ClusterProbeClient.ts
|
|
792
|
+
(`ClusterProbeClient.ts#ClusterProbeClient`), qui applique un modèle **push** — chaque worker remonte
|
|
793
|
+
périodiquement sa santé au maître (`ClusterProbeClient.ts#ClusterProbeClient.constructor`), le maître agrège et
|
|
786
794
|
rediffuse, chaque worker met en cache. **N'importe lequel** sert alors la vue pod en O(1),
|
|
787
795
|
sans latence de requête.
|
|
788
796
|
|
|
789
|
-
L'agrégation, `mergeClusterHealth()` (`ClusterProbeClient.ts
|
|
797
|
+
L'agrégation, `mergeClusterHealth()` (`ClusterProbeClient.ts#mergeClusterHealth`), est une fonction pure :
|
|
790
798
|
elle somme les scalaires, mais prend le **maximum** de `maxBufferedAmount` — la santé d'une
|
|
791
799
|
flotte se juge sur son pire membre, pas sur sa moyenne. `buildRealtimeHealth()`
|
|
792
|
-
(`RealtimeAdminApi.ts
|
|
800
|
+
(`RealtimeAdminApi.ts#buildRealtimeHealth`) choisit ensuite la vue agrégée si elle existe, la per-instance
|
|
793
801
|
sinon.
|
|
794
802
|
|
|
795
803
|
Sonde désactivée ? Elle n'est pas muette : **elle n'existe pas**. Aucun client, donc aucun
|
|
796
804
|
minuteur, aucun écouteur, aucun message IPC
|
|
797
|
-
(`src/packages/@nodefony/realtime/index.ts
|
|
805
|
+
(`src/packages/@nodefony/realtime/index.ts#Realtime.#startWithTimeout`) — coupable par la configuration ou par une
|
|
798
806
|
variable d'environnement. Enfin, le maître peut demander à un worker précis d'enrichir sa
|
|
799
|
-
remontée (`requestEnrich()`, `ClusterProbeClient.ts
|
|
807
|
+
remontée (`requestEnrich()`, `ClusterProbeClient.ts#ClusterProbeClient.requestEnrich`) : la sonde riche n'est allouée que
|
|
800
808
|
pendant l'inspection, puis libérée — on paie ce qu'on regarde.
|
|
801
809
|
|
|
802
810
|
## ⚡ Performance & mémoire
|
|
@@ -805,10 +813,10 @@ Le module suit une discipline stricte, lisible dans presque chaque champ privé
|
|
|
805
813
|
|
|
806
814
|
**Allocation paresseuse partout.** Table des canaux, registre des connexions, registre des
|
|
807
815
|
révocables, authentificateurs, politiques de canal, association peer→jeton : tout démarre à
|
|
808
|
-
`null`. Un process sans abonné n'alloue **rien** (`RealtimeHub.ts
|
|
816
|
+
`null`. Un process sans abonné n'alloue **rien** (`RealtimeHub.ts#IRevocableConnection` et suivants).
|
|
809
817
|
|
|
810
818
|
**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
|
|
819
|
+
dès que le registre se vide — `RealtimeHub.unregisterRevocable()` (`RealtimeHub.ts#RealtimeHub.unregisterRevocable`).
|
|
812
820
|
Les minuteurs sont détachés de la boucle d'événements : ils ne retiennent jamais l'arrêt du
|
|
813
821
|
process.
|
|
814
822
|
|
|
@@ -839,7 +847,7 @@ dans la sonde, et le plafond de canaux par connexion.
|
|
|
839
847
|
| 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
848
|
| Fermeture `1013` sur un client lent | file au-delà de `websocket.maxBackpressure` (4 MiB) pendant 1000 frames jetées d'affilée | attendu ; le client se reconnecte et se resynchronise |
|
|
841
849
|
| 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` (`
|
|
850
|
+
| 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` (`NodefonySocket` le fait déjà) |
|
|
843
851
|
| 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
852
|
| `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
853
|
| `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` |
|
|
@@ -868,7 +876,7 @@ l'aperçu, régénérée depuis les résultats réels — jamais figés ici.
|
|
|
868
876
|
> [!CAUTION]
|
|
869
877
|
> Plusieurs de ces suites exigent une infrastructure (Redis, cluster). **Un test qui se
|
|
870
878
|
> 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
|
|
879
|
+
> d'environnement d'infrastructure sont bien posées — la source unique est `scripts/test/vitest/gates.ts`
|
|
872
880
|
> à la racine, dont le rapport s'affiche en fin d'exécution.
|
|
873
881
|
|
|
874
882
|
Couverture : `npm run coverage` dans `@nodefony/realtime`. Pour la charge et la mémoire, les
|