@nodefony/realtime 10.0.0-beta.1 → 10.0.0-beta.3
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.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +17 -10
- package/dist/nodefony/config/config.js +6 -3
- package/dist/nodefony/config/configSchema.js +19 -0
- package/dist/nodefony/config/defineModuleConfig.js +42 -15
- package/dist/nodefony/decorators/realtimeDecorators.js +68 -17
- package/dist/nodefony/src/backplane/RedisBackplane.js +21 -4
- package/dist/nodefony/src/backplane/bootNotices.js +43 -0
- package/dist/nodefony/src/backplane/publishQueue.js +1 -4
- package/dist/nodefony/src/cluster/ClusterProbeClient.js +15 -4
- package/dist/nodefony/src/guards.js +41 -0
- package/dist/nodefony/src/server/AnonymousRealtimeToken.js +1 -4
- package/dist/nodefony/src/server/RealtimeController.js +120 -50
- package/dist/nodefony/src/server/RealtimeHub.js +29 -51
- package/dist/nodefony/src/server/ServerRealtimeSocket.js +13 -5
- package/dist/nodefony/src/server/syslogUplink.js +4 -2
- package/dist/nodefony/src/service/RealtimeService.js +3 -2
- package/dist/nodefony/testing/index.js +41 -1
- 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/IRealtimeController.d.ts +1 -1
- package/dist/types/nodefony/interfaces/IRealtimeHandshake.d.ts +1 -1
- 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 +24 -4
- package/dist/types/nodefony/src/server/RealtimeHub.d.ts +22 -16
- package/dist/types/nodefony/src/service/RealtimeService.d.ts +1 -1
- package/dist/types/nodefony/testing/index.d.ts +2 -2
- package/docs/actions.md +124 -41
- package/docs/architecture.md +93 -91
- package/docs/cadence-et-contre-pression.md +634 -0
- package/docs/configuration.md +66 -56
- package/docs/cookbook-chat.md +39 -33
- package/docs/index.md +47 -31
- package/docs/observabilite.md +21 -18
- package/docs/protocole.md +79 -65
- package/docs/securite.md +69 -65
- package/docs/vocabulaire.md +80 -79
- package/package.json +16 -12
- 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:311`) 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:450`). 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
|
|
|
@@ -137,15 +137,15 @@ Trois partis pris distinguent cette pile d'un simple « serveur WebSocket ».
|
|
|
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
139
|
`ServerRealtimeSocket` (`ServerRealtimeSocket.ts:43`), obtenu par `serverSocket()`
|
|
140
|
-
(`ServerRealtimeSocket.ts:
|
|
141
|
-
et explicite : `ServerRealtimeSocket.request()` (`ServerRealtimeSocket.ts:
|
|
140
|
+
(`ServerRealtimeSocket.ts:243`) ; côté navigateur, `NodefonySocket`. Une exception, assumée
|
|
141
|
+
et explicite : `ServerRealtimeSocket.request()` (`ServerRealtimeSocket.ts:150`) **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:395`).
|
|
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:464`) : 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:610`) — un choix de sûreté détaillé plus bas.
|
|
158
158
|
|
|
159
159
|
## 🚀 Démarrage rapide
|
|
160
160
|
|
|
@@ -260,7 +260,7 @@ export default ChatController;
|
|
|
260
260
|
|
|
261
261
|
```ts
|
|
262
262
|
// frontend/src/chat.ts
|
|
263
|
-
import {
|
|
263
|
+
import { NodefonySocket } from "nodefony/client";
|
|
264
264
|
|
|
265
265
|
interface ChatMessage {
|
|
266
266
|
from: string;
|
|
@@ -271,7 +271,7 @@ interface ChatMessage {
|
|
|
271
271
|
const scheme = location.protocol === "https:" ? "wss" : "ws";
|
|
272
272
|
// `shared` = une seule socket par URL pour toute la page, même si dix
|
|
273
273
|
// composants la demandent (les options ne comptent qu'à la création).
|
|
274
|
-
const socket =
|
|
274
|
+
const socket = NodefonySocket.shared({
|
|
275
275
|
url: `${scheme}://${location.host}/chat/realtime`,
|
|
276
276
|
});
|
|
277
277
|
|
|
@@ -291,7 +291,7 @@ export async function start(): Promise<void> {
|
|
|
291
291
|
### Ce qu'on observe
|
|
292
292
|
|
|
293
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:
|
|
294
|
+
même forme, quel que soit le driver (`src/packages/@nodefony/realtime/index.ts:316`) :
|
|
295
295
|
|
|
296
296
|
```
|
|
297
297
|
realtime backplane driver=loopback kind=local cross-pod=no (hub local)
|
|
@@ -322,9 +322,9 @@ La pile se lit de haut en bas ; chaque étage ne connaît que son voisin du dess
|
|
|
322
322
|
| Étage | Qui | Sa seule responsabilité | Ce qu'il ignore |
|
|
323
323
|
| ---------- | ------------------------------------------------------- | --------------------------------------- | ----------------------------- |
|
|
324
324
|
| Applicatif | ton contrôleur, tes services | le métier : quoi publier, quoi accepter | tout le reste |
|
|
325
|
-
| Protocole | `JsonRpcPeer` (`JsonRpcPeer.ts:
|
|
325
|
+
| Protocole | `JsonRpcPeer` (`JsonRpcPeer.ts:311`) | étiqueter, corréler, refuser une frame | par où passent les octets |
|
|
326
326
|
| Transport | `WsConnectionTransport` (`WsConnectionTransport.ts:46`) | déplacer des octets, mesurer la file | ce que veut dire un message |
|
|
327
|
-
| Hub | `RealtimeHub` (`RealtimeHub.ts:
|
|
327
|
+
| Hub | `RealtimeHub` (`RealtimeHub.ts:214`) | table des abonnés locaux + fan-out | qu'il existe d'autres process |
|
|
328
328
|
| Backplane | `IBackplane` (`IBackplane.ts:107`) | porter un message aux autres process | ce qu'est un abonné |
|
|
329
329
|
|
|
330
330
|
> [!NOTE]
|
|
@@ -338,11 +338,11 @@ La pile se lit de haut en bas ; chaque étage ne connaît que son voisin du dess
|
|
|
338
338
|
dispatch des notifications, gestion des erreurs, `dispose()` propre. Deux points
|
|
339
339
|
d'interception y sont posés :
|
|
340
340
|
|
|
341
|
-
- `beforeDispatch` (`JsonRpcPeer.ts:
|
|
341
|
+
- `beforeDispatch` (`JsonRpcPeer.ts:219`) — un verrou **synchrone** appelé avant tout
|
|
342
342
|
traitement de frame. Il rend `true`/`false`. Un refus sur une requête produit
|
|
343
343
|
`-32001 unauthorized` ; sur une notification, la frame est jetée
|
|
344
|
-
(`JsonRpcPeer.ts:
|
|
345
|
-
- `onFrameAudit` (`JsonRpcPeer.ts:
|
|
344
|
+
(`JsonRpcPeer.ts:449`).
|
|
345
|
+
- `onFrameAudit` (`JsonRpcPeer.ts:236`) — la trace des événements protocolaires notables
|
|
346
346
|
(frame invalide, refusée, méthode inconnue, erreur interne).
|
|
347
347
|
|
|
348
348
|
La contrainte de synchronisme n'est pas un oubli : un `await` par frame coûterait une
|
|
@@ -362,21 +362,23 @@ même façon, par le hook `onFinish` du contexte, qui déclenche `fireClose()`
|
|
|
362
362
|
Ce transport porte aussi la **back-pressure**. Elle n'est pas câblée en dur : les trois
|
|
363
363
|
leviers sont des clés de configuration du serveur WebSocket, lues par le transport
|
|
364
364
|
(`WsConnectionTransport.ts:63-75`) et documentées dans `@nodefony/http`
|
|
365
|
-
(`http/nodefony/config/config.ts:
|
|
365
|
+
(`http/nodefony/config/config.ts:1226`).
|
|
366
366
|
|
|
367
|
-
| File non drainée (`bufferedAmount`)
|
|
368
|
-
|
|
|
369
|
-
| sous le seuil
|
|
370
|
-
| au-dessus du seuil
|
|
371
|
-
|
|
|
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
|
+
| **solde** de refus atteint | fermeture `1013` (_Try Again Later_) | `websocket.backpressureCloseAfterDrops` (**1000**) |
|
|
372
372
|
|
|
373
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
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
374
|
+
se déclenche pas sur un volume mais sur un **solde de frames jetées** — +1 par refus, −1 par
|
|
375
|
+
envoi, si bien qu'un pic passager redescend et qu'un client qui n'absorbe plus finit coupé — et
|
|
376
|
+
`drop` est un choix, `close` fermant dès le premier dépassement. Jeter est sans conséquence sur
|
|
377
|
+
un canal d'état, où le prochain instantané remplace celui qu'on a sauté ; mais le rejet ne trie
|
|
378
|
+
rien, et une réponse RPC ou un événement jetés sont perdus. Détail et courbes :
|
|
379
|
+
[Cadence et contre-pression](./cadence-et-contre-pression.md).
|
|
380
|
+
|
|
381
|
+
À ne pas confondre avec `SLOW_CONSUMER_BYTES` (`RealtimeHub.ts:64`, 1 MiB) : ce seuil-là
|
|
380
382
|
ne jette rien, il **compte** — c'est celui à partir duquel la sonde marque une connexion
|
|
381
383
|
`slowConsumer`.
|
|
382
384
|
|
|
@@ -388,7 +390,7 @@ Le schéma ci-dessous rend ces étages vivants : active le temps réel et il res
|
|
|
388
390
|
|
|
389
391
|
## 🔌 Le cycle de vie d'une connexion
|
|
390
392
|
|
|
391
|
-
Tout se joue dans `RealtimeController.onHandshake()` (`RealtimeController.ts:
|
|
393
|
+
Tout se joue dans `RealtimeController.onHandshake()` (`RealtimeController.ts:450`), appelé
|
|
392
394
|
une seule fois par connexion, en chemin froid.
|
|
393
395
|
|
|
394
396
|
```mermaid
|
|
@@ -419,19 +421,19 @@ sequenceDiagram
|
|
|
419
421
|
|
|
420
422
|
Les étapes, dans l'ordre exact du code :
|
|
421
423
|
|
|
422
|
-
1. **Contrôle de l'origine** — `RealtimeHub.checkOrigin()` (`RealtimeHub.ts:
|
|
424
|
+
1. **Contrôle de l'origine** — `RealtimeHub.checkOrigin()` (`RealtimeHub.ts:967`). Refus →
|
|
423
425
|
fermeture `4003`. La politique vient de la configuration ; sans politique, tout passe.
|
|
424
426
|
2. **Résolution de l'identité** — `RealtimeHub.resolveAuthenticator()`
|
|
425
|
-
(`RealtimeHub.ts:
|
|
427
|
+
(`RealtimeHub.ts:943`) parcourt les authentificateurs enregistrés : **le premier motif
|
|
426
428
|
qui correspond capture**. Aucun ne correspond ? Le jeton anonyme gelé est posé
|
|
427
429
|
(`ANONYMOUS_REALTIME_TOKEN`) — la lecture d'identité ne rend donc jamais `null`. Un
|
|
428
430
|
échec d'authentification ferme en `4001`.
|
|
429
431
|
3. **Création du peer et du transport**, puis association `peer → jeton`
|
|
430
|
-
(`RealtimeHub.setTokenForPeer()`, `RealtimeHub.ts:
|
|
432
|
+
(`RealtimeHub.setTokenForPeer()`, `RealtimeHub.ts:999`), stockée dans une `WeakMap` : le
|
|
431
433
|
jeton disparaît avec le peer, sans fuite.
|
|
432
434
|
4. **Enregistrement des actions** — celles des décorateurs `@RealtimeAction`, puis celles
|
|
433
435
|
de la surcharge `realtimeActions()`, qui gagne en cas de conflit. Le pont API
|
|
434
|
-
`api.request` n'est ajouté que si `realtimeApiRequest()` (`RealtimeController.ts:
|
|
436
|
+
`api.request` n'est ajouté que si `realtimeApiRequest()` (`RealtimeController.ts:321`)
|
|
435
437
|
rend `true`.
|
|
436
438
|
5. **Inscription aux registres** : sonde de connexion, révocation périodique si le jeton
|
|
437
439
|
est révocable, préfixes de canaux broadcast, canaux entrants.
|
|
@@ -439,7 +441,7 @@ Les étapes, dans l'ordre exact du code :
|
|
|
439
441
|
l'identité résolue (type, authentifié ou non, rôles, portées). Le client sait **qui il
|
|
440
442
|
est** sans appeler la moindre route.
|
|
441
443
|
|
|
442
|
-
À la fermeture, un unique `onFinish` (`RealtimeController.ts:
|
|
444
|
+
À la fermeture, un unique `onFinish` (`RealtimeController.ts:771`) fait le ménage complet :
|
|
443
445
|
désabonnement de chaque canal tenu, retrait des deux registres, `fireClose()` du transport,
|
|
444
446
|
`dispose()` du peer. C'est ce qui garantit qu'aucun minuteur ni écouteur ne survit à une
|
|
445
447
|
déconnexion.
|
|
@@ -447,41 +449,41 @@ déconnexion.
|
|
|
447
449
|
> [!IMPORTANT]
|
|
448
450
|
> Les frames reçues **pendant** l'authentification sont jetées silencieusement : le
|
|
449
451
|
> transport n'est pas encore branché. C'est au client d'attendre `realtime:welcome` avant
|
|
450
|
-
> de pousser — ce que `
|
|
452
|
+
> de pousser — ce que `NodefonySocket` fait nativement.
|
|
451
453
|
|
|
452
454
|
### Deux gardes qui ferment la connexion
|
|
453
455
|
|
|
454
456
|
| Garde | Déclencheur | Effet | Ancrage |
|
|
455
457
|
| --------------------- | ---------------------------------------------------------------------- | --------------------------------------------------- | --------------------------- |
|
|
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:
|
|
458
|
+
| Plafond de canaux | une connexion dépasse son quota d'abonnements (256 par défaut) | abonnement refusé + `realtime:denied` motif `limit` | `RealtimeController.ts:852` |
|
|
459
|
+
| Révocation d'identité | tick périodique ; `token.isValid()` rend `false` ou lève une exception | fermeture `4001` « session revoked » | `RealtimeHub.ts:49` |
|
|
458
460
|
|
|
459
461
|
La seconde mérite une explication. Le verrou de frame est synchrone et lit une identité
|
|
460
462
|
figée au handshake : il ne peut donc pas voir une session qui meurt en cours de route (une
|
|
461
463
|
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:
|
|
464
|
+
registre se vide (`RealtimeHub.registerRevocable()`, `RealtimeHub.ts:796`) — relit
|
|
463
465
|
périodiquement ces identités et coupe les sockets orphelines. Seules les identités
|
|
464
466
|
**révocables** y entrent : un visiteur anonyme ne coûte rien.
|
|
465
467
|
|
|
466
468
|
## Le hub — canaux partagés et fan-out
|
|
467
469
|
|
|
468
|
-
Le hub est un singleton par process (`getRealtimeHub()`, `RealtimeHub.ts:
|
|
470
|
+
Le hub est un singleton par process (`getRealtimeHub()`, `RealtimeHub.ts:1432`). Il ne
|
|
469
471
|
dépend de rien : ce sont les fabriques fournies par les contrôleurs qui portent les
|
|
470
472
|
dépendances.
|
|
471
473
|
|
|
472
474
|
### Le provider partagé — un ticker, pas mille
|
|
473
475
|
|
|
474
|
-
`RealtimeHub.subscribe()` (`RealtimeHub.ts:
|
|
476
|
+
`RealtimeHub.subscribe()` (`RealtimeHub.ts:464`) applique une mécanique en trois temps :
|
|
475
477
|
|
|
476
478
|
1. Le canal existe déjà ? On ajoute simplement le sink de cette connexion. Fin.
|
|
477
479
|
2. Sinon, on inscrit le sink **avant** d'appeler la fabrique — de sorte que le tout premier
|
|
478
480
|
paquet du producteur atteigne bien ce premier abonné.
|
|
479
481
|
3. La fabrique du contrôleur rend `null` (canal inconnu de lui) ? Dernier recours : le
|
|
480
482
|
registre des **canaux système** (`RealtimeHub.registerSystemChannel()`,
|
|
481
|
-
`RealtimeHub.ts:
|
|
483
|
+
`RealtimeHub.ts:1278`), qu'un module bas niveau alimente au démarrage. Toujours `null` →
|
|
482
484
|
l'abonnement est refusé et rien n'est alloué.
|
|
483
485
|
|
|
484
|
-
Au dernier désabonnement, `RealtimeHub.unsubscribe()` (`RealtimeHub.ts:
|
|
486
|
+
Au dernier désabonnement, `RealtimeHub.unsubscribe()` (`RealtimeHub.ts:578`) appelle le
|
|
485
487
|
`dispose` du producteur et retire le canal. Un producteur fautif qui lève une exception ne
|
|
486
488
|
bloque pas le nettoyage.
|
|
487
489
|
|
|
@@ -496,22 +498,22 @@ Deux méthodes qui se ressemblent et ne font pas du tout la même chose :
|
|
|
496
498
|
|
|
497
499
|
| Méthode | Fan-out local | Propagation aux autres process | Qui l'appelle |
|
|
498
500
|
| --------------------------------------- | :-----------: | :--------------------------------: | ----------------------------------- |
|
|
499
|
-
| `publish()` (`RealtimeHub.ts:
|
|
500
|
-
| `publishLocal()` (`RealtimeHub.ts:
|
|
501
|
+
| `publish()` (`RealtimeHub.ts:610`) | oui | oui, **si** le canal est broadcast | producteurs, contrôleurs, services |
|
|
502
|
+
| `publishLocal()` (`RealtimeHub.ts:629`) | oui | **jamais** | l'arrivée d'un message du backplane |
|
|
501
503
|
|
|
502
504
|
C'est **la** règle qui empêche la tempête : un message reçu d'un pair est réinjecté
|
|
503
505
|
localement et **ne repart pas**. Le câblage se fait une fois pour toutes dans
|
|
504
|
-
`RealtimeHub.setBackplane()` (`RealtimeHub.ts:
|
|
506
|
+
`RealtimeHub.setBackplane()` (`RealtimeHub.ts:720`), qui branche l'arrivée du backplane
|
|
505
507
|
directement sur `publishLocal`.
|
|
506
508
|
|
|
507
|
-
Le fan-out lui-même (`RealtimeHub.ts:
|
|
509
|
+
Le fan-out lui-même (`RealtimeHub.ts:346`) est **isolé** : chaque livraison est protégée,
|
|
508
510
|
une connexion fautive n'interrompt pas la diffusion aux autres.
|
|
509
511
|
|
|
510
512
|
### Le forward est OPT-IN — le défaut est l'isolement
|
|
511
513
|
|
|
512
514
|
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:
|
|
515
|
+
`@RealtimeBroadcast` sur ton contrôleur (`realtimeDecorators.ts:420`) ou
|
|
516
|
+
directement `RealtimeHub.markBroadcastChannel()` (`RealtimeHub.ts:674`).
|
|
515
517
|
|
|
516
518
|
Trois raisons à ce choix, qui prend à contre-pied la plupart des bibliothèques temps réel :
|
|
517
519
|
|
|
@@ -578,7 +580,7 @@ resynchronise. Ne construis pas au-dessus une fiabilité que le support n'offre
|
|
|
578
580
|
| ---------- | ----------------------------------------------- | -------------- | ---------------------------------- | :---------------: |
|
|
579
581
|
| `loopback` | `LoopbackBackplane` (`LoopbackBackplane.ts:24`) | `local` | aucun (no-op complet) | non |
|
|
580
582
|
| `cluster` | `ClusterBackplane` (`ClusterBackplane.ts:89`) | `ipc` | les workers du même process maître | non |
|
|
581
|
-
| `redis` | `RedisBackplane` (`RedisBackplane.ts:
|
|
583
|
+
| `redis` | `RedisBackplane` (`RedisBackplane.ts:219`) | `redis-pubsub` | tous les pods abonnés au canal | **oui** |
|
|
582
584
|
|
|
583
585
|
**`loopback`** est le défaut. En réalité, le hub garde son backplane à `null` en
|
|
584
586
|
mono-process : le coût est un test de nullité, pas un appel. La classe existe pour
|
|
@@ -588,16 +590,16 @@ rien.
|
|
|
588
590
|
**`cluster`** relie les workers d'un même hôte. Un worker Node ne peut parler qu'au maître :
|
|
589
591
|
il émet vers lui (`ClusterBackplane.publish()`, `ClusterBackplane.ts:117`), le maître
|
|
590
592
|
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:
|
|
593
|
+
`NF_CLUSTER=1` (`src/packages/@nodefony/realtime/index.ts:106`) ; ailleurs il rend
|
|
592
594
|
`null`. Aucune infrastructure requise — c'est le banc d'essai qui stabilise l'architecture
|
|
593
595
|
multi-process avant d'ajouter du réseau.
|
|
594
596
|
|
|
595
597
|
**`redis`** franchit la frontière de l'hôte, sans dépendre de la bibliothèque `redis` : il
|
|
596
598
|
consomme deux connexions du module `@nodefony/redis` via un adaptateur purement structurel,
|
|
597
|
-
`createRedisServiceTransport()` (`RedisBackplane.ts:
|
|
599
|
+
`createRedisServiceTransport()` (`RedisBackplane.ts:137`). Deux connexions et non une, parce
|
|
598
600
|
qu'un client Redis abonné ne peut plus émettre de commandes ordinaires. Module absent ou
|
|
599
601
|
connexions indisponibles → avertissement, `null`, hub local, démarrage poursuivi
|
|
600
|
-
(`src/packages/@nodefony/realtime/index.ts:
|
|
602
|
+
(`src/packages/@nodefony/realtime/index.ts:115`).
|
|
601
603
|
|
|
602
604
|
### Le registre de drivers — zéro nom en dur
|
|
603
605
|
|
|
@@ -606,7 +608,7 @@ brancherie. La sélection passe donc par un registre — `registerBackplaneDrive
|
|
|
606
608
|
(`backplaneRegistry.ts:55`) et `getBackplaneDriver()` (`backplaneRegistry.ts:63`) : chaque
|
|
607
609
|
driver porte son nom en membre statique, les natifs s'inscrivent au chargement du module, et
|
|
608
610
|
le câblage résout une chaîne vers une fabrique sans connaître aucun littéral
|
|
609
|
-
(`src/packages/@nodefony/realtime/index.ts:
|
|
611
|
+
(`src/packages/@nodefony/realtime/index.ts:260`).
|
|
610
612
|
|
|
611
613
|
Une fabrique reçoit module, `originId`, rôle dans la topologie et configuration validée
|
|
612
614
|
(`IBackplaneFactoryContext`, `backplaneRegistry.ts:27`), puis rend une instance ou `null`
|
|
@@ -620,7 +622,7 @@ et non une :
|
|
|
620
622
|
|
|
621
623
|
1. **Côté hub** : l'arrivée passe par `publishLocal`, jamais par `publish`. Rien ne repart.
|
|
622
624
|
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:
|
|
625
|
+
on jette si c'est le même (`RedisBackplane.ts:235`, `ClusterBackplane.ts:134`).
|
|
624
626
|
|
|
625
627
|
L'étiquette elle-même est calculée par `resolveBackplaneOriginId()` (`originId.ts:24`), et
|
|
626
628
|
sa recette mérite qu'on s'y arrête :
|
|
@@ -648,7 +650,7 @@ applications sur un Redis mutualisé se parleraient.
|
|
|
648
650
|
D'où le suffixe de cloison, calculé par `resolveRedisChannel()` (`RedisBackplane.ts:31`) :
|
|
649
651
|
le canal effectif devient `nodefony:realtime:<namespace>`. Le namespace vient de la
|
|
650
652
|
configuration ; à défaut, il est dérivé du nom de l'application
|
|
651
|
-
(`src/packages/@nodefony/realtime/index.ts:
|
|
653
|
+
(`src/packages/@nodefony/realtime/index.ts:139`).
|
|
652
654
|
|
|
653
655
|
> [!TIP]
|
|
654
656
|
> Deux déploiements de la **même** application sur un Redis partagé (préproduction et
|
|
@@ -661,14 +663,14 @@ est ignoré, pas propagé (`RedisBackplane.ts:212`).
|
|
|
661
663
|
### Le démarrage, et ce qui se passe quand ça rate
|
|
662
664
|
|
|
663
665
|
Le module valide sa configuration par Zod à l'enregistrement
|
|
664
|
-
(`src/packages/@nodefony/realtime/index.ts:
|
|
665
|
-
(`src/packages/@nodefony/realtime/index.ts:
|
|
666
|
+
(`src/packages/@nodefony/realtime/index.ts:226`), puis câble tout au démarrage du noyau
|
|
667
|
+
(`src/packages/@nodefony/realtime/index.ts:220`) — après l'initialisation des services (le
|
|
666
668
|
service Redis a donc ouvert ses connexions) et avant le trafic.
|
|
667
669
|
|
|
668
670
|
Le `start()` du backplane est **attendu explicitement avant** d'être branché : un driver
|
|
669
671
|
asynchrone brancherait sinon un abonnement incomplet et perdrait ses premiers messages. Et
|
|
670
672
|
il est **borné par un délai** de cinq secondes
|
|
671
|
-
(`src/packages/@nodefony/realtime/index.ts:
|
|
673
|
+
(`src/packages/@nodefony/realtime/index.ts:90`), pour une raison très concrète : un
|
|
672
674
|
transport réseau qui reste pendu gèlerait la phase de démarrage, et donc la montée des
|
|
673
675
|
serveurs HTTP eux-mêmes.
|
|
674
676
|
|
|
@@ -680,7 +682,7 @@ Trois issues, toutes annoncées, jamais silencieuses :
|
|
|
680
682
|
| driver inactif ici (loopback, mauvais rôle) | hub local | `INFO` |
|
|
681
683
|
| driver inconnu, ou démarrage en échec/expiré | hub local, **le démarrage continue** | `WARNING` |
|
|
682
684
|
|
|
683
|
-
Le module se déclare non critique (`src/packages/@nodefony/realtime/index.ts:
|
|
685
|
+
Le module se déclare non critique (`src/packages/@nodefony/realtime/index.ts:151`) : son
|
|
684
686
|
échec ne tue jamais le process. C'est de la résilience **sans dégradation silencieuse** —
|
|
685
687
|
tout repli est annoncé. Enfin, `enabled: false` rend le module totalement inerte : ni API
|
|
686
688
|
d'administration, ni backplane, ni sonde.
|
|
@@ -718,8 +720,8 @@ code qui **appelle le builder lui-même** (un `Module` sur mesure, une intégrat
|
|
|
718
720
|
programmatique). Une application qui configure via `use("@nodefony/realtime", …)` **ne
|
|
719
721
|
l'atteint pas** : elle ne construit pas la config (le module appelle
|
|
720
722
|
`defineRealtimeConfig(this.options)` sans second argument, `index.ts:216`), et une
|
|
721
|
-
instance posée dans l'objet de config est
|
|
722
|
-
|
|
723
|
+
instance posée dans l'objet de config est refusée par la validation Zod (clé inconnue du
|
|
724
|
+
schéma strict). Pour une app, la voie « instance » est l'option 2 ci-dessus.
|
|
723
725
|
|
|
724
726
|
Le registre s'introspecte : `listBackplaneDrivers()` (`backplaneRegistry.ts:68`) rend les
|
|
725
727
|
noms disponibles — c'est ce que le message d'erreur affiche quand un driver déclaré est
|
|
@@ -733,29 +735,29 @@ refonte. Ici, seulement **où** ils sont ; le **quoi** et le **comment** sont da
|
|
|
733
735
|
|
|
734
736
|
| Point de greffe | Étage | Moment | Ancrage |
|
|
735
737
|
| ---------------------------- | --------- | -------------- | --------------------------- |
|
|
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:
|
|
738
|
+
| Contrôle d'origine | hub | à l'upgrade | `RealtimeHub.ts:620` |
|
|
739
|
+
| Authentificateur réseau | hub | au handshake | `RealtimeHub.ts:585` |
|
|
740
|
+
| Verrou de frame | protocole | à chaque frame | `RealtimeHub.ts:681` |
|
|
741
|
+
| Politique déclarée par canal | hub | au handshake | `RealtimeHub.ts:719` |
|
|
742
|
+
| Audit de frame | protocole | sur refus | `RealtimeController.ts:522` |
|
|
741
743
|
|
|
742
744
|
Deux propriétés architecturales méritent d'être notées ici, parce qu'elles expliquent des
|
|
743
745
|
choix de conception visibles partout dans le module :
|
|
744
746
|
|
|
745
747
|
**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:
|
|
748
|
+
si une politique existe (`RealtimeHub.hasFrameAuthorizer()`, `RealtimeHub.ts:1039`, testé une
|
|
747
749
|
fois au handshake). Sans module de sécurité, `beforeDispatch` reste indéfini et le chemin
|
|
748
750
|
chaud ne paie **rien** du tout.
|
|
749
751
|
|
|
750
752
|
**Échec bruyant plutôt que faux sentiment de sécurité.** Si des canaux déclarent une
|
|
751
753
|
politique sans qu'aucun décideur ne soit câblé, `hasUnenforcedChannelPolicies()`
|
|
752
|
-
(`RealtimeHub.ts:
|
|
753
|
-
(`RealtimeController.ts:
|
|
754
|
+
(`RealtimeHub.ts:1098`) le détecte et un avertissement est émis une fois par process
|
|
755
|
+
(`RealtimeController.ts:639`). Un canal qui **se croit** gardé alors qu'il est ouvert est
|
|
754
756
|
bien plus dangereux qu'un canal ouvertement public.
|
|
755
757
|
|
|
756
758
|
## 📡 Observabilité — la sonde et Studio
|
|
757
759
|
|
|
758
|
-
`RealtimeHub.probe()` (`RealtimeHub.ts:
|
|
760
|
+
`RealtimeHub.probe()` (`RealtimeHub.ts:847`) rend un instantané en **lecture pure** : aucune
|
|
759
761
|
allocation sur le chemin chaud, jamais d'exception. Les compteurs sont des primitives
|
|
760
762
|
incrémentées en O(1) ; ils sont **monotones**, ce qui laisse au lecteur le soin de dériver
|
|
761
763
|
un débit.
|
|
@@ -786,7 +788,7 @@ périodiquement sa santé au maître (`ClusterProbeClient.ts:179`), le maître a
|
|
|
786
788
|
rediffuse, chaque worker met en cache. **N'importe lequel** sert alors la vue pod en O(1),
|
|
787
789
|
sans latence de requête.
|
|
788
790
|
|
|
789
|
-
L'agrégation, `mergeClusterHealth()` (`ClusterProbeClient.ts:
|
|
791
|
+
L'agrégation, `mergeClusterHealth()` (`ClusterProbeClient.ts:60`), est une fonction pure :
|
|
790
792
|
elle somme les scalaires, mais prend le **maximum** de `maxBufferedAmount` — la santé d'une
|
|
791
793
|
flotte se juge sur son pire membre, pas sur sa moyenne. `buildRealtimeHealth()`
|
|
792
794
|
(`RealtimeAdminApi.ts:74`) choisit ensuite la vue agrégée si elle existe, la per-instance
|
|
@@ -794,9 +796,9 @@ sinon.
|
|
|
794
796
|
|
|
795
797
|
Sonde désactivée ? Elle n'est pas muette : **elle n'existe pas**. Aucun client, donc aucun
|
|
796
798
|
minuteur, aucun écouteur, aucun message IPC
|
|
797
|
-
(`src/packages/@nodefony/realtime/index.ts:
|
|
799
|
+
(`src/packages/@nodefony/realtime/index.ts:364`) — coupable par la configuration ou par une
|
|
798
800
|
variable d'environnement. Enfin, le maître peut demander à un worker précis d'enrichir sa
|
|
799
|
-
remontée (`requestEnrich()`, `ClusterProbeClient.ts:
|
|
801
|
+
remontée (`requestEnrich()`, `ClusterProbeClient.ts:259`) : la sonde riche n'est allouée que
|
|
800
802
|
pendant l'inspection, puis libérée — on paie ce qu'on regarde.
|
|
801
803
|
|
|
802
804
|
## ⚡ Performance & mémoire
|
|
@@ -805,10 +807,10 @@ Le module suit une discipline stricte, lisible dans presque chaque champ privé
|
|
|
805
807
|
|
|
806
808
|
**Allocation paresseuse partout.** Table des canaux, registre des connexions, registre des
|
|
807
809
|
révocables, authentificateurs, politiques de canal, association peer→jeton : tout démarre à
|
|
808
|
-
`null`. Un process sans abonné n'alloue **rien** (`RealtimeHub.ts:
|
|
810
|
+
`null`. Un process sans abonné n'alloue **rien** (`RealtimeHub.ts:142` et suivants).
|
|
809
811
|
|
|
810
812
|
**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:
|
|
813
|
+
dès que le registre se vide — `RealtimeHub.unregisterRevocable()` (`RealtimeHub.ts:804`).
|
|
812
814
|
Les minuteurs sont détachés de la boucle d'événements : ils ne retiennent jamais l'arrêt du
|
|
813
815
|
process.
|
|
814
816
|
|
|
@@ -824,25 +826,25 @@ désabonné.
|
|
|
824
826
|
|
|
825
827
|
**Le vrai point de vigilance est la back-pressure**, pas le processeur : une file d'envoi
|
|
826
828
|
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
|
|
829
|
+
concentre le risque. D'où les réglages de contre-pression du transport, le comptage des consommateurs lents
|
|
828
830
|
dans la sonde, et le plafond de canaux par connexion.
|
|
829
831
|
|
|
830
832
|
## ⚠️ Pièges
|
|
831
833
|
|
|
832
|
-
| Symptôme | Cause
|
|
833
|
-
| -------------------------------------------------------------- |
|
|
834
|
-
| Le chat marche en local, plus rien en cluster | le canal n'est pas déclaré broadcast (défaut : instance-local)
|
|
835
|
-
| Le client ne reçoit rien alors qu'il a un handler `on(...)` | `on` reçoit, `subscribe` demande — il faut les **deux**
|
|
836
|
-
| Chaque message arrive **en double** en cluster | `publishLocal` court-circuité, ou `originId` non unique entre pods
|
|
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)
|
|
838
|
-
| Producteur planté après le départ du premier abonné | la fabrique a capturé le contexte de la connexion créatrice
|
|
839
|
-
| Abonnement refusé avec `realtime:denied` motif `limit` | plafond de canaux par connexion atteint (256 par défaut)
|
|
840
|
-
| Fermeture `1013` sur un client lent | file
|
|
841
|
-
| Fermeture `4001` en cours de session | identité révoquée, détectée par le tick de re-validation
|
|
842
|
-
| Frames envoyées juste après `connect()` perdues | le transport n'est pas branché tant que le handshake n'est pas fini
|
|
843
|
-
| Avertissement « channel policies … NOT enforced » au démarrage | des canaux déclarent une politique sans décideur câblé
|
|
844
|
-
| `ServerRealtimeSocket.request()` rejette systématiquement | un handle posé sur le hub n'a pas d'interlocuteur unique
|
|
845
|
-
| `TS2550: RegExp.escape does not exist` à la compilation | `lib` du tsconfig sous ES2025 (les paquets exposent leurs types en source)
|
|
834
|
+
| Symptôme | Cause | Correction |
|
|
835
|
+
| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
|
836
|
+
| 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` |
|
|
837
|
+
| 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)` |
|
|
838
|
+
| 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 |
|
|
839
|
+
| 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 |
|
|
840
|
+
| 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 |
|
|
841
|
+
| 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 |
|
|
842
|
+
| 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 |
|
|
843
|
+
| 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 |
|
|
844
|
+
| 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à) |
|
|
845
|
+
| 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 |
|
|
846
|
+
| `ServerRealtimeSocket.request()` rejette systématiquement | un handle posé sur le hub n'a pas d'interlocuteur unique | utiliser `RealtimeController.requestClient()` pour un appel ciblé |
|
|
847
|
+
| `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
848
|
|
|
847
849
|
## 🧪 Tests & couverture
|
|
848
850
|
|
|
@@ -868,7 +870,7 @@ l'aperçu, régénérée depuis les résultats réels — jamais figés ici.
|
|
|
868
870
|
> [!CAUTION]
|
|
869
871
|
> Plusieurs de ces suites exigent une infrastructure (Redis, cluster). **Un test qui se
|
|
870
872
|
> 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
|
|
873
|
+
> d'environnement d'infrastructure sont bien posées — la source unique est `scripts/test/vitest/gates.ts`
|
|
872
874
|
> à la racine, dont le rapport s'affiche en fin d'exécution.
|
|
873
875
|
|
|
874
876
|
Couverture : `npm run coverage` dans `@nodefony/realtime`. Pour la charge et la mémoire, les
|