@nodefony/realtime 10.0.0-alpha.7 → 10.0.0-alpha.9
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/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +2 -2
- package/docs/actions.md +10 -9
- package/docs/architecture.md +20 -20
- package/docs/configuration.md +8 -7
- package/docs/cookbook-chat.md +13 -13
- package/docs/index.md +5 -5
- package/docs/observabilite.md +10 -9
- package/docs/protocole.md +17 -17
- package/docs/securite.md +37 -37
- package/docs/vocabulaire.md +18 -18
- package/package.json +11 -11
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region \0@oxc-project+runtime@0.
|
|
1
|
+
//#region \0@oxc-project+runtime@0.151.0/helpers/esm/decorate.js
|
|
2
2
|
function __decorate(decorators, target, key, desc) {
|
|
3
3
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
4
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region \0@oxc-project+runtime@0.
|
|
1
|
+
//#region \0@oxc-project+runtime@0.151.0/helpers/esm/decorateMetadata.js
|
|
2
2
|
function __decorateMetadata(k, v) {
|
|
3
3
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
4
4
|
}
|
package/dist/index.js
CHANGED
|
@@ -15,8 +15,8 @@ import { ClusterBackplane, processIpcTransport } from "./nodefony/src/backplane/
|
|
|
15
15
|
import { getBackplaneDriver, listBackplaneDrivers, registerBackplaneDriver } from "./nodefony/src/backplane/backplaneRegistry.js";
|
|
16
16
|
import { ClusterProbeClient, clusterProbeHealth, clusterProbeInstance, clusterProbeRequestEnrich, mergeClusterHealth, processProbeTransport, setClusterProbeClient } from "./nodefony/src/cluster/ClusterProbeClient.js";
|
|
17
17
|
import { buildOwnHealth, buildRealtimeHealth, createRealtimeAdminApi } from "./nodefony/src/server/RealtimeAdminApi.js";
|
|
18
|
-
import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.
|
|
19
|
-
import __decorate from "./_virtual/_@oxc-project_runtime@0.
|
|
18
|
+
import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
|
|
19
|
+
import __decorate from "./_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorate.js";
|
|
20
20
|
import { Kernel, Module, services, withTimeout } from "nodefony";
|
|
21
21
|
//#region index.ts
|
|
22
22
|
/**
|
package/docs/actions.md
CHANGED
|
@@ -277,7 +277,7 @@ Le trajet complet d'une requête, des étapes qu'elle traverse aux branches par
|
|
|
277
277
|
| Voie | Quand la choisir |
|
|
278
278
|
| ------------------------------------------------------ | ----------------------------------------------------------- |
|
|
279
279
|
| `@RealtimeAction("nom")` (`realtimeDecorators.ts:101`) | le cas courant — un nom fixe, une méthode, déclaratif |
|
|
280
|
-
| `realtimeActions()` (`RealtimeController.ts:
|
|
280
|
+
| `realtimeActions()` (`RealtimeController.ts:186`) | la table est **calculée** (noms dynamiques, boucle, config) |
|
|
281
281
|
|
|
282
282
|
Les deux sont fusionnées au handshake, et **l'override gagne** en cas de conflit de nom : une
|
|
283
283
|
classe peut ainsi remplacer une action héritée sans toucher au parent
|
|
@@ -287,7 +287,7 @@ allouée au premier enregistrement seulement.
|
|
|
287
287
|
### Ce que ton `return` et ton `throw` deviennent sur le fil
|
|
288
288
|
|
|
289
289
|
Le retour du handler est envoyé tel quel en `result`. Les erreurs, elles, suivent une règle Zero
|
|
290
|
-
Trust stricte, appliquée dans `JsonRpcPeer.handleRequest()` (`JsonRpcPeer.ts:
|
|
290
|
+
Trust stricte, appliquée dans `JsonRpcPeer.handleRequest()` (`JsonRpcPeer.ts:499`) :
|
|
291
291
|
|
|
292
292
|
| Côté serveur | Ce que reçoit le client | Pourquoi |
|
|
293
293
|
| ------------------------------- | ---------------------------------------- | ----------------------------------------------- |
|
|
@@ -345,21 +345,22 @@ homonyme), elle est conservée : le défaut ne rétrograde jamais une règle plu
|
|
|
345
345
|
### Situation 2 — les namespaces réservés portent un plancher
|
|
346
346
|
|
|
347
347
|
Un nom qui commence par `nodefony:` hérite d'un plancher non contournable : authentifié **et**
|
|
348
|
-
`
|
|
349
|
-
|
|
348
|
+
`ROLE_NODEFONY_ADMIN` (`SYSTEM_CHANNEL_POLICY`, `frameAuthorizer.ts:89`) — un rôle de la plateforme,
|
|
349
|
+
pas le `ROLE_ADMIN` d'une organisation. Un seul préfixe couvre donc tout
|
|
350
|
+
ce que la plateforme expose (`DEFAULT_SYSTEM_PREFIXES`, `frameAuthorizer.ts:106`). C'est ce qui
|
|
350
351
|
protège `nodefony:kernel:gc` : le nom **est** la garde, et la comparaison est insensible à la casse
|
|
351
352
|
pour qu'un `NODEFONY:kernel:gc` ne passe pas à côté.
|
|
352
353
|
|
|
353
354
|
Ce plancher **prime sur ta déclaration** — y compris sur `{ authenticated: false }`. Et il ne
|
|
354
355
|
s'arrête pas aux préfixes : tout nom **contenant** `:health` ou `:stats` est traité comme un canal
|
|
355
|
-
d'observabilité (`matchSystemPolicy`, `frameAuthorizer.ts:
|
|
356
|
+
d'observabilité (`matchSystemPolicy`, `frameAuthorizer.ts:243`). Une action `public:health` reste
|
|
356
357
|
donc réservée, quoi qu'en dise son décorateur. À savoir avant de nommer une action « ouverte ».
|
|
357
358
|
|
|
358
359
|
### Situation 3 — couvrir SES actions par la configuration
|
|
359
360
|
|
|
360
361
|
Pour exiger un rôle sur tes propres actions, on déclare une règle de préfixe dans la
|
|
361
362
|
configuration de sécurité — la même liste que pour les canaux
|
|
362
|
-
(`realtimeChannels`, `security/nodefony/config/config.ts:
|
|
363
|
+
(`realtimeChannels`, `security/nodefony/config/config.ts:1130`) :
|
|
363
364
|
|
|
364
365
|
```ts ignore
|
|
365
366
|
use("@nodefony/security", {
|
|
@@ -402,7 +403,7 @@ await socket.request("orders:export", { scope }, 120_000); // action longue
|
|
|
402
403
|
|
|
403
404
|
| Défaut | Valeur | Ancrage |
|
|
404
405
|
| --------- | --------- | -------------------- |
|
|
405
|
-
| `request` | 30 000 ms | `JsonRpcPeer.ts:
|
|
406
|
+
| `request` | 30 000 ms | `JsonRpcPeer.ts:319` |
|
|
406
407
|
|
|
407
408
|
À l'expiration, l'entrée en attente est **retirée** et la `Promise` rejetée avec
|
|
408
409
|
`RPC timeout: <méthode>` (`JsonRpcPeer.ts:455`). Conséquence à connaître : une réponse qui
|
|
@@ -444,7 +445,7 @@ fois ? »**.
|
|
|
444
445
|
|
|
445
446
|
Pour les mutations passant par le pont API, la clé n'est pas une convention : elle est **exigée
|
|
446
447
|
par la signature** de `mutate()` (`RealtimeClient.ts:797`), et c'est la garde `@Idempotent`
|
|
447
|
-
(`routerDecorators.ts:
|
|
448
|
+
(`routerDecorators.ts:1142`) qui, côté serveur, reconnaît le rejeu et rend la réponse déjà calculée
|
|
448
449
|
au lieu de refaire l'effet.
|
|
449
450
|
|
|
450
451
|
```ts ignore
|
|
@@ -529,7 +530,7 @@ passe par le pont pour tout ce qui est déjà une route. Le détail du pont vit
|
|
|
529
530
|
| Le client attend indéfiniment, aucune erreur | le handler ne rend jamais (il publie au lieu de retourner) — pas de frame `result` | toujours `return` une valeur ; publier **en plus**, jamais **à la place** |
|
|
530
531
|
| `-32601 method not found` | nom mal orthographié, ou action déclarée sur un **autre** endpoint | vérifier `socket.serverMethods` — c'est la liste réelle de CETTE connexion |
|
|
531
532
|
| `-32603 internal error` sans détail | un `throw` ordinaire est rendu opaque au client (Zero Trust) | lever une `RpcError` avec un code et un message publiables |
|
|
532
|
-
| `-32001 unauthorized` sur une action légitime | le nom commence par `nodefony:`, le namespace réservé à la plateforme | renommer hors de `nodefony:`, ou obtenir `
|
|
533
|
+
| `-32001 unauthorized` sur une action légitime | le nom commence par `nodefony:`, le namespace réservé à la plateforme | renommer hors de `nodefony:`, ou obtenir `ROLE_NODEFONY_ADMIN` |
|
|
533
534
|
| Une action sensible est appelable par un anonyme | une action applicative est **libre** tant qu'aucune politique ne la couvre | ajouter une règle de préfixe (`security/nodefony/config/config.ts:906`) |
|
|
534
535
|
| Un travail relancé crée deux jobs | action non idempotente rejouée après une reconnexion | action compagnon d'annulation, ou identifiant fourni par l'appelant + mémorisation |
|
|
535
536
|
| `abort()` introuvable sur la socket | il n'existe pas — et n'arrêterait pas le serveur de toute façon | exposer une action d'annulation qui prend l'identifiant du travail |
|
package/docs/architecture.md
CHANGED
|
@@ -145,7 +145,7 @@ multi-clients. Pour un appel serveur→client ciblé, on passe par la connexion
|
|
|
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:462`) : 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
|
|
@@ -338,7 +338,7 @@ 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:192`) — 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
344
|
(`JsonRpcPeer.ts:413`).
|
|
@@ -355,14 +355,14 @@ au handshake, puis lue en O(1). C'est le compromis assumé, documenté dans
|
|
|
355
355
|
Particularité contre-intuitive : côté serveur, le transport **n'écoute pas** d'événement
|
|
356
356
|
socket. C'est le pipeline HTTP de `@nodefony/http` qui appelle la route WebSocket du
|
|
357
357
|
contrôleur à chaque message reçu, laquelle pousse la charge dans
|
|
358
|
-
`WsConnectionTransport.feed()` (`WsConnectionTransport.ts:
|
|
358
|
+
`WsConnectionTransport.feed()` (`WsConnectionTransport.ts:150`). La fermeture arrive de la
|
|
359
359
|
même façon, par le hook `onFinish` du contexte, qui déclenche `fireClose()`
|
|
360
|
-
(`WsConnectionTransport.ts:
|
|
360
|
+
(`WsConnectionTransport.ts:155`).
|
|
361
361
|
|
|
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:1044`).
|
|
366
366
|
|
|
367
367
|
| File non drainée (`bufferedAmount`) | Décision | Réglage (défaut) |
|
|
368
368
|
| --------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------- |
|
|
@@ -439,7 +439,7 @@ Les étapes, dans l'ordre exact du code :
|
|
|
439
439
|
l'identité résolue (type, authentifié ou non, rôles, portées). Le client sait **qui il
|
|
440
440
|
est** sans appeler la moindre route.
|
|
441
441
|
|
|
442
|
-
À la fermeture, un unique `onFinish` (`RealtimeController.ts:
|
|
442
|
+
À la fermeture, un unique `onFinish` (`RealtimeController.ts:578`) fait le ménage complet :
|
|
443
443
|
désabonnement de chaque canal tenu, retrait des deux registres, `fireClose()` du transport,
|
|
444
444
|
`dispose()` du peer. C'est ce qui garantit qu'aucun minuteur ni écouteur ne survit à une
|
|
445
445
|
déconnexion.
|
|
@@ -471,7 +471,7 @@ dépendances.
|
|
|
471
471
|
|
|
472
472
|
### Le provider partagé — un ticker, pas mille
|
|
473
473
|
|
|
474
|
-
`RealtimeHub.subscribe()` (`RealtimeHub.ts:
|
|
474
|
+
`RealtimeHub.subscribe()` (`RealtimeHub.ts:462`) applique une mécanique en trois temps :
|
|
475
475
|
|
|
476
476
|
1. Le canal existe déjà ? On ajoute simplement le sink de cette connexion. Fin.
|
|
477
477
|
2. Sinon, on inscrit le sink **avant** d'appeler la fabrique — de sorte que le tout premier
|
|
@@ -511,7 +511,7 @@ une connexion fautive n'interrompt pas la diffusion aux autres.
|
|
|
511
511
|
|
|
512
512
|
Par défaut, **aucun canal ne traverse le backplane**. Il faut déclarer un préfixe, via
|
|
513
513
|
`@RealtimeBroadcast` sur ton contrôleur (`realtimeDecorators.ts:342`) ou
|
|
514
|
-
directement `RealtimeHub.markBroadcastChannel()` (`RealtimeHub.ts:
|
|
514
|
+
directement `RealtimeHub.markBroadcastChannel()` (`RealtimeHub.ts:668`).
|
|
515
515
|
|
|
516
516
|
Trois raisons à ce choix, qui prend à contre-pied la plupart des bibliothèques temps réel :
|
|
517
517
|
|
|
@@ -550,14 +550,14 @@ coûte chaque abonné supplémentaire.
|
|
|
550
550
|
`IBackplane` (`IBackplane.ts:75`) est délibérément minuscule : le backplane ne connaît ni
|
|
551
551
|
les abonnés ni les canaux logiques. Tout l'état vit dans le hub.
|
|
552
552
|
|
|
553
|
-
| Membre
|
|
554
|
-
|
|
|
555
|
-
| `originId` (`IBackplane.ts:77`)
|
|
556
|
-
| `start()` (`IBackplane.ts:115`)
|
|
557
|
-
| `publish(channel, payload)` (`IBackplane.ts:
|
|
558
|
-
| `onMessage(handler)` (`IBackplane.ts:127`)
|
|
559
|
-
| `stop()` (`IBackplane.ts:130`)
|
|
560
|
-
| `describe()` (`IBackplane.ts:136`)
|
|
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:121`) | 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
561
|
|
|
562
562
|
Deux absences volontaires. Il n'y a **pas** de `subscribe`/`unsubscribe` par canal : le
|
|
563
563
|
transport porte **un seul** canal physique, et les canaux logiques voyagent **dans**
|
|
@@ -620,7 +620,7 @@ et non une :
|
|
|
620
620
|
|
|
621
621
|
1. **Côté hub** : l'arrivée passe par `publishLocal`, jamais par `publish`. Rien ne repart.
|
|
622
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:
|
|
623
|
+
on jette si c'est le même (`RedisBackplane.ts:209`, `ClusterBackplane.ts:134`).
|
|
624
624
|
|
|
625
625
|
L'étiquette elle-même est calculée par `resolveBackplaneOriginId()` (`originId.ts:24`), et
|
|
626
626
|
sa recette mérite qu'on s'y arrête :
|
|
@@ -661,7 +661,7 @@ est ignoré, pas propagé (`RedisBackplane.ts:212`).
|
|
|
661
661
|
### Le démarrage, et ce qui se passe quand ça rate
|
|
662
662
|
|
|
663
663
|
Le module valide sa configuration par Zod à l'enregistrement
|
|
664
|
-
(`src/packages/@nodefony/realtime/index.ts:
|
|
664
|
+
(`src/packages/@nodefony/realtime/index.ts:219`), puis câble tout au démarrage du noyau
|
|
665
665
|
(`src/packages/@nodefony/realtime/index.ts:213`) — après l'initialisation des services (le
|
|
666
666
|
service Redis a donc ouvert ses connexions) et avant le trafic.
|
|
667
667
|
|
|
@@ -755,7 +755,7 @@ bien plus dangereux qu'un canal ouvertement public.
|
|
|
755
755
|
|
|
756
756
|
## 📡 Observabilité — la sonde et Studio
|
|
757
757
|
|
|
758
|
-
`RealtimeHub.probe()` (`RealtimeHub.ts:
|
|
758
|
+
`RealtimeHub.probe()` (`RealtimeHub.ts:849`) rend un instantané en **lecture pure** : aucune
|
|
759
759
|
allocation sur le chemin chaud, jamais d'exception. Les compteurs sont des primitives
|
|
760
760
|
incrémentées en O(1) ; ils sont **monotones**, ce qui laisse au lecteur le soin de dériver
|
|
761
761
|
un débit.
|
|
@@ -774,7 +774,7 @@ process, plus — si elles sont disponibles — les sondes ORM et les compteurs
|
|
|
774
774
|
chemins de lecture, un seul producteur :
|
|
775
775
|
|
|
776
776
|
- **HTTP** : `GET /nodefony/realtime/api/health`, exposé par `createRealtimeAdminApi()`
|
|
777
|
-
(`RealtimeAdminApi.ts:
|
|
777
|
+
(`RealtimeAdminApi.ts:98`) ;
|
|
778
778
|
- **Canal temps réel** : `nodefony:socket`, poussé par un ticker.
|
|
779
779
|
|
|
780
780
|
### En cluster : la vue « pod » plutôt que « worker »
|
package/docs/configuration.md
CHANGED
|
@@ -125,7 +125,7 @@ fichiers, jamais trois**.
|
|
|
125
125
|
| `nodefony/config/config.ts` | **le quoi** | le schéma Zod commenté, source **unique** des valeurs d'usine, et les défauts matérialisés |
|
|
126
126
|
| `nodefony/config/defineModuleConfig.ts` | **le comment** | le builder pur : analyse, surcharge d'environnement, gel — il ne retape jamais une valeur d'usine |
|
|
127
127
|
|
|
128
|
-
Le schéma `realtimeConfigSchema` (`config.ts:
|
|
128
|
+
Le schéma `realtimeConfigSchema` (`config.ts:259`) porte chaque valeur en `.default()` et chaque
|
|
129
129
|
explication en `.describe()`. Les défauts effectifs sont **dérivés** du schéma lui-même
|
|
130
130
|
(`realtimeConfigSchema.parse({})`, `config.ts:231`) : il n'existe pas de second endroit où une valeur
|
|
131
131
|
d'usine serait écrite, donc pas de dérive silencieuse possible entre la doc du champ et son
|
|
@@ -252,7 +252,7 @@ regarder quand un message ne traverse pas.
|
|
|
252
252
|
|
|
253
253
|
> [!TIP]
|
|
254
254
|
> Ces quatre lignes viennent du même endroit du code (`Realtime.#wireBackplane()`,
|
|
255
|
-
> `src/packages/@nodefony/realtime/index.ts:
|
|
255
|
+
> `src/packages/@nodefony/realtime/index.ts:279`) et de la carte d'identité que chaque driver publie.
|
|
256
256
|
> Aucun repli n'est silencieux : si le fan-out est dégradé, c'est écrit.
|
|
257
257
|
|
|
258
258
|
## ⚙️ Le schéma, clé par clé
|
|
@@ -378,7 +378,7 @@ d'éteindre une sonde sur un pod en incident sans redéployer une configuration.
|
|
|
378
378
|
### `slowConsumer.bytes` — un compteur, pas un frein
|
|
379
379
|
|
|
380
380
|
Seuil de `bufferedAmount` — les octets en attente d'envoi sur une socket — au-delà duquel la sonde
|
|
381
|
-
compte la connexion comme « lente » (`RealtimeHub.probe()`, `RealtimeHub.ts:
|
|
381
|
+
compte la connexion comme « lente » (`RealtimeHub.probe()`, `RealtimeHub.ts:849`).
|
|
382
382
|
|
|
383
383
|
> [!IMPORTANT]
|
|
384
384
|
> **Cette clé observe, elle n'agit pas.** Elle ne change que le compteur `slowConsumers` de la
|
|
@@ -434,7 +434,7 @@ des canaux d'état, où seul le dernier instantané compte.
|
|
|
434
434
|
Chaque canal ouvert coûte un producteur côté hub, un minuteur le plus souvent, et une entrée de table
|
|
435
435
|
côté connexion. Sans borne, **un seul** client peut s'abonner jusqu'à épuiser la mémoire du
|
|
436
436
|
processus. Le plafond est vérifié à chaque abonnement (`RealtimeController.startChannel()`,
|
|
437
|
-
`RealtimeController.ts:
|
|
437
|
+
`RealtimeController.ts:746`).
|
|
438
438
|
|
|
439
439
|
Quatre propriétés qui décident du bon réglage :
|
|
440
440
|
|
|
@@ -722,9 +722,10 @@ champ portant sa description. C'est ce que consomme la page module de Studio pou
|
|
|
722
722
|
configuration attendue. L'instance de backplane éventuelle en est absente : une classe n'a rien à
|
|
723
723
|
faire dans un schéma sérialisable.
|
|
724
724
|
|
|
725
|
-
Un message d'erreur de validation est reformaté par le
|
|
726
|
-
apparaît sous la forme `chemin: message
|
|
727
|
-
(`src/
|
|
725
|
+
Un message d'erreur de validation est reformaté par le cœur avant d'être levé (`parseModuleConfig`,
|
|
726
|
+
appelé par `defineRealtimeConfig`) : chaque problème apparaît sous la forme `chemin : message`
|
|
727
|
+
(`src/nodefony/src/kernel/moduleConfig.ts:42`), séparés par des points médians
|
|
728
|
+
(`src/nodefony/src/kernel/moduleConfig.ts:58`). Tu lis quel champ est fautif, pas une trace Zod
|
|
728
729
|
brute.
|
|
729
730
|
|
|
730
731
|
## 📡 Observabilité — relire ce qui s'applique vraiment
|
package/docs/cookbook-chat.md
CHANGED
|
@@ -1597,23 +1597,23 @@ Pour qui veut lire l'implémentation derrière chaque étape :
|
|
|
1597
1597
|
<!-- prettier-ignore -->
|
|
1598
1598
|
| Ce que tu as utilisé | Où c'est écrit |
|
|
1599
1599
|
| --- | --- |
|
|
1600
|
-
| Les trois décorateurs | `RealtimeAction()` (`realtimeDecorators.ts:101`), `RealtimeChannel()` (`realtimeDecorators.ts:
|
|
1600
|
+
| Les trois décorateurs | `RealtimeAction()` (`realtimeDecorators.ts:101`), `RealtimeChannel()` (`realtimeDecorators.ts:192`), `RealtimeInbound()` (`realtimeDecorators.ts:231`) |
|
|
1601
1601
|
| La porte WebSocket | `RealtimeController.handleRealtime()` (`RealtimeController.ts:249`) |
|
|
1602
1602
|
| Le pont HTTP ⇄ WebSocket | `RealtimeController.realtimeApiRequest()` (`RealtimeController.ts:219`) |
|
|
1603
|
-
| L'abonnement et son cycle de vie | `RealtimeHub.subscribe()` (`RealtimeHub.ts:
|
|
1604
|
-
| La publication et le fan-out | `RealtimeHub.publish()` (`RealtimeHub.ts:604`), réinjection locale `RealtimeHub.publishLocal()` (`RealtimeHub.ts:
|
|
1605
|
-
| Les canaux diffusables | `RealtimeBroadcast` (`realtimeDecorators.ts:342`), `RealtimeHub.markBroadcastChannel()` (`RealtimeHub.ts:
|
|
1603
|
+
| L'abonnement et son cycle de vie | `RealtimeHub.subscribe()` (`RealtimeHub.ts:462`), `RealtimeController.startChannel()` (`RealtimeController.ts:746`) |
|
|
1604
|
+
| La publication et le fan-out | `RealtimeHub.publish()` (`RealtimeHub.ts:604`), réinjection locale `RealtimeHub.publishLocal()` (`RealtimeHub.ts:623`) |
|
|
1605
|
+
| Les canaux diffusables | `RealtimeBroadcast` (`realtimeDecorators.ts:342`), `RealtimeHub.markBroadcastChannel()` (`RealtimeHub.ts:668`) |
|
|
1606
1606
|
| Le branchement du backplane | `RealtimeHub.setBackplane()` (`RealtimeHub.ts:714`), registre `registerBackplaneDriver()` (`backplaneRegistry.ts:55`), cloison `resolveBackplaneOriginId()` (`originId.ts:24`) |
|
|
1607
|
-
| Les politiques de canal | `IChannelPolicy` (`IChannelPolicy.ts:20`), `RealtimeHub.registerChannelPolicy()` (`RealtimeHub.ts:
|
|
1608
|
-
| Le branchement automatique du firewall | `Firewall.#wireRealtime()` (`firewall.ts:
|
|
1609
|
-
| Les refus | `IRealtimeDenied` (`RealtimeEventMap.ts:
|
|
1607
|
+
| Les politiques de canal | `IChannelPolicy` (`IChannelPolicy.ts:20`), `RealtimeHub.registerChannelPolicy()` (`RealtimeHub.ts:1127`), garde-fou `RealtimeHub.hasUnenforcedChannelPolicies()` (`RealtimeHub.ts:1127`) |
|
|
1608
|
+
| Le branchement automatique du firewall | `Firewall.#wireRealtime()` (`firewall.ts:279`) |
|
|
1609
|
+
| Les refus | `IRealtimeDenied` (`RealtimeEventMap.ts:269`), erreur `unauthorized` (`JsonRpcPeer.ts:400`), fermetures `origin not allowed` (`RealtimeController.ts:327`) et `unauthorized` (`RealtimeController.ts:349`) |
|
|
1610
1610
|
| La révocation en cours de session | `RealtimeHub.revalidateRevocable()` (`RealtimeHub.ts:810`) |
|
|
1611
1611
|
| Le consommateur lent | seuil `SLOW_CONSUMER_BYTES` (`RealtimeHub.ts:56`), fermeture `slow consumer` (`RealtimeHub.ts:63`) |
|
|
1612
1612
|
| Le plafond de canaux | `RealtimeHub.maxChannelsPerConnection` (`RealtimeHub.ts:984`) |
|
|
1613
1613
|
| L'accueil et l'identité | `IRealtimeWelcome` (`RealtimeEventMap.ts:231`) |
|
|
1614
|
-
| La sonde | `RealtimeHub.probe()` (`RealtimeHub.ts:
|
|
1615
|
-
| Le point de mesure d'administration | `createRealtimeAdminApi()` (`RealtimeAdminApi.ts:
|
|
1616
|
-
| Le service injectable | `RealtimeService.publish()` (`RealtimeService.ts:161`), `RealtimeService.probe()` (`RealtimeService.ts:
|
|
1617
|
-
| Les hooks React | `useNodefony()` (`client/react/index.ts:
|
|
1618
|
-
| Le client navigateur | `RealtimeClient.connect()` (`RealtimeClient.ts:
|
|
1619
|
-
| L'upload | `UploadedFile` (`routerDecorators.ts:
|
|
1614
|
+
| La sonde | `RealtimeHub.probe()` (`RealtimeHub.ts:849`), `IRealtimeProbe` (`IRealtimeProbe.ts:61`), `IRealtimeChannelStat` (`IRealtimeProbe.ts:47`) |
|
|
1615
|
+
| Le point de mesure d'administration | `createRealtimeAdminApi()` (`RealtimeAdminApi.ts:98`), `buildRealtimeHealth()` (`RealtimeAdminApi.ts:74`), `buildOwnHealth()` (`RealtimeAdminApi.ts:52`) |
|
|
1616
|
+
| Le service injectable | `RealtimeService.publish()` (`RealtimeService.ts:161`), `RealtimeService.probe()` (`RealtimeService.ts:213`), `RealtimeService.markBroadcastChannel()` (`RealtimeService.ts:222`) |
|
|
1617
|
+
| Les hooks React | `useNodefony()` (`client/react/index.ts:145`), `useNodefonyState()` (`client/react/index.ts:163`), `useNodefonyIdentity()` (`client/react/index.ts:182`), `useNodefonyChannel()` (`client/react/index.ts:198`), `useNodefonyChannelData()` (`client/react/index.ts:221`), `useNodefonyAdaptiveChannel()` (`client/react/index.ts:182`) |
|
|
1618
|
+
| Le client navigateur | `RealtimeClient.connect()` (`RealtimeClient.ts:389`), `RealtimeClient.subscribe()` (`RealtimeClient.ts:530`), `RealtimeClient.request()` (`RealtimeClient.ts:729`), `RealtimeClient.onDenied()` (`RealtimeClient.ts:471`) |
|
|
1619
|
+
| L'upload | `UploadedFile` (`routerDecorators.ts:1253`), `IUploadedFile` (`IUpload.ts:49`), `queryFile` (`Request.ts:185`) |
|
package/docs/index.md
CHANGED
|
@@ -125,9 +125,9 @@ pas deux applications à écrire deux fois, mais deux entrées du même pipeline
|
|
|
125
125
|
|
|
126
126
|
**Une connexion, N canaux, dans les deux sens.** Le client s'abonne à autant de canaux qu'il veut sur
|
|
127
127
|
la même socket. Trois formes de trafic coexistent : le serveur diffuse (`@RealtimeChannel`,
|
|
128
|
-
`realtimeDecorators.ts:
|
|
129
|
-
`realtimeDecorators.ts:
|
|
130
|
-
`realtimeDecorators.ts:
|
|
128
|
+
`realtimeDecorators.ts:192`), le client appelle et attend une réponse (`@RealtimeAction`,
|
|
129
|
+
`realtimeDecorators.ts:142`), le client pousse sans attendre (`@RealtimeInbound`,
|
|
130
|
+
`realtimeDecorators.ts:231`). Rien n'est ouvert qui n'ait été déclaré.
|
|
131
131
|
|
|
132
132
|
**Le travail est fait une fois par processus, pas une fois par client.** Le `RealtimeHub`
|
|
133
133
|
(`RealtimeHub.ts:213`) tient **un seul producteur par canal** : le premier abonné le démarre, le
|
|
@@ -304,7 +304,7 @@ jamais recopiées ici : elles divergeraient en silence.
|
|
|
304
304
|
## ⚙️ Configuration
|
|
305
305
|
|
|
306
306
|
Un seul point d'entrée : `use("@nodefony/realtime", { … })` dans `nodefony.config.ts`, validé au boot
|
|
307
|
-
contre le schéma du module (`realtimeConfigSchema`, `config.ts:
|
|
307
|
+
contre le schéma du module (`realtimeConfigSchema`, `config.ts:259`). Cinq blocs :
|
|
308
308
|
|
|
309
309
|
- `backplane` — le driver de fan-out et son espace de nommage. Ce cloisonnement devient
|
|
310
310
|
indispensable dès que **deux déploiements partagent le même Redis** : sans lui, leurs publications
|
|
@@ -325,7 +325,7 @@ vue de tous les workers d'un pod.
|
|
|
325
325
|
|
|
326
326
|
Le data plane admin expose `/nodefony/realtime/api/health` — canaux et abonnés, compteurs de
|
|
327
327
|
diffusion, connexions, octets et frames, pression d'écriture. C'est la même donnée que celle rendue à
|
|
328
|
-
l'écran, servie par la sonde du hub (`RealtimeHub.probe()`, `RealtimeHub.ts:
|
|
328
|
+
l'écran, servie par la sonde du hub (`RealtimeHub.probe()`, `RealtimeHub.ts:849`).
|
|
329
329
|
|
|
330
330
|
## 🧪 Tests & couverture
|
|
331
331
|
|
package/docs/observabilite.md
CHANGED
|
@@ -96,7 +96,7 @@ temps de réponse — elle mesure **ce qui n'est pas encore parti**.
|
|
|
96
96
|
Trois partis pris, tous lisibles dans le code.
|
|
97
97
|
|
|
98
98
|
**1. La sonde est une lecture pure, jamais un collecteur.** `RealtimeHub.probe()`
|
|
99
|
-
(`RealtimeHub.ts:
|
|
99
|
+
(`RealtimeHub.ts:849`) ne fait qu'additionner des primitives déjà tenues à jour par le chemin chaud.
|
|
100
100
|
Aucune entrée-sortie, aucune exception possible, aucun état de lecture conservé. Appeler la sonde
|
|
101
101
|
mille fois par seconde ne changerait rien à ce que le hub fait par ailleurs.
|
|
102
102
|
|
|
@@ -316,7 +316,7 @@ deux autres. Le débit en octets par seconde se dérive de deux photos.
|
|
|
316
316
|
|
|
317
317
|
La source par connexion est `IRealtimeConnProbe` (`IRealtimeProbe.ts:25`), implémentée par le
|
|
318
318
|
transport. Les seuils d'ACTION sont distincts du seuil de comptage, et ils sont
|
|
319
|
-
**configurables** (`@nodefony/http`, `http/nodefony/config/config.ts:
|
|
319
|
+
**configurables** (`@nodefony/http`, `http/nodefony/config/config.ts:1044`) : au-delà de
|
|
320
320
|
`websocket.maxBackpressure` (4 MiB par défaut) la politique `websocket.backpressurePolicy`
|
|
321
321
|
s'applique — `drop` par défaut, la frame est **jetée** puisque les canaux d'état sont « le
|
|
322
322
|
dernier gagne » et que le prochain instantané la remplacera. La connexion est **fermée** en
|
|
@@ -356,7 +356,7 @@ Trois champs n'apparaissent que lorsqu'ils ont un sens :
|
|
|
356
356
|
### La couche d'identité, au-dessus
|
|
357
357
|
|
|
358
358
|
`buildOwnHealth()` (`RealtimeAdminApi.ts:52`) enrichit ce snapshot pur pour produire
|
|
359
|
-
`IRealtimeHealth` (`IRealtimeProbe.ts:
|
|
359
|
+
`IRealtimeHealth` (`IRealtimeProbe.ts:121`) : `instanceId`, la santé **process** du worker
|
|
360
360
|
(CPU, mémoire, boucle d'événements), et — si elles ont été branchées — la santé ORM et les compteurs
|
|
361
361
|
d'erreurs du journal. Ces trois derniers champs sont **additifs et optionnels** : un consommateur qui
|
|
362
362
|
ne les connaît pas les ignore.
|
|
@@ -368,14 +368,14 @@ resterait vide jusqu'au premier tick.
|
|
|
368
368
|
|
|
369
369
|
### L'instantané HTTP
|
|
370
370
|
|
|
371
|
-
`GET /nodefony/realtime/api/health`, monté par `createRealtimeAdminApi()` (`RealtimeAdminApi.ts:
|
|
371
|
+
`GET /nodefony/realtime/api/health`, monté par `createRealtimeAdminApi()` (`RealtimeAdminApi.ts:98`)
|
|
372
372
|
sous le namespace `realtime` du data plane admin. Sert le premier affichage, les sondes de liveness,
|
|
373
373
|
et tout script qui ne veut pas ouvrir une socket.
|
|
374
374
|
|
|
375
375
|
### Le canal temps réel
|
|
376
376
|
|
|
377
377
|
`nodefony:socket`. Le producteur est un minuteur qui rappelle **le même endpoint** à travers le
|
|
378
|
-
courtier admin (`StudioRealtimeController.createRealtimeChannel()`, `StudioRealtimeController.ts:
|
|
378
|
+
courtier admin (`StudioRealtimeController.createRealtimeChannel()`, `StudioRealtimeController.ts:212`,
|
|
379
379
|
via `createBrokerTicker()` (`providers.ts:465`)). Rien n'est dupliqué : le canal est un endpoint
|
|
380
380
|
rejoué.
|
|
381
381
|
|
|
@@ -406,8 +406,9 @@ refusée.
|
|
|
406
406
|
| `nodefony:syslog` | `createSyslogBridge()` (`providers.ts:145`) | **événementiel** | — (coalescé) |
|
|
407
407
|
|
|
408
408
|
> [!WARNING]
|
|
409
|
-
> **`nodefony:dashboard` n'
|
|
410
|
-
> nom
|
|
409
|
+
> **`nodefony:dashboard` n'est servi par aucun fournisseur.** Le canal de supervision s'appelle
|
|
410
|
+
> `nodefony:supervision`. Le nom survit pourtant comme constante exportée, dépréciée
|
|
411
|
+
> (`PLATFORM_CHANNELS.dashboard`, `platformChannels.ts:58`, retrait en 11.0.0) — c'est ce qui le rend piégeux. S'abonner à un canal inconnu ne lève **aucune
|
|
411
412
|
> erreur** — le provider rend `null` et rien n'arrive jamais. C'est le piège numéro un de la page.
|
|
412
413
|
|
|
413
414
|
`nodefony:syslog` est le seul de la liste à ne pas être cadencé : il **relaie** au lieu de sonder. Comme
|
|
@@ -462,7 +463,7 @@ sur deux workers et sembler se contredire.
|
|
|
462
463
|
**La réponse de Nodefony est un modèle _push_.** Chaque worker remonte périodiquement sa santé au
|
|
463
464
|
maître via `ClusterProbeClient.start()` (`ClusterProbeClient.ts:170`) ; le maître fusionne et
|
|
464
465
|
rediffuse ; chaque worker met le résultat en cache. **N'importe lequel** sert alors la vue pod en temps
|
|
465
|
-
constant, sans latence de requête (`ClusterProbeClient.getClusterHealth()`, `ClusterProbeClient.ts:
|
|
466
|
+
constant, sans latence de requête (`ClusterProbeClient.getClusterHealth()`, `ClusterProbeClient.ts:291`).
|
|
466
467
|
|
|
467
468
|
La fusion, `mergeClusterHealth()` (`ClusterProbeClient.ts:46`), est une fonction pure — et sa règle
|
|
468
469
|
mérite d'être connue :
|
|
@@ -474,7 +475,7 @@ mérite d'être connue :
|
|
|
474
475
|
| `backpressure.maxBufferedAmount` | **maximum** | La santé d'une flotte se juge sur son **pire** membre, pas sur sa moyenne. |
|
|
475
476
|
| `slowConsumers`, `drops` | somme | Ce sont des dénombrements. |
|
|
476
477
|
|
|
477
|
-
Le discriminant est le champ `cluster: true` de `IRealtimeClusterHealth` (`IRealtimeProbe.ts:
|
|
478
|
+
Le discriminant est le champ `cluster: true` de `IRealtimeClusterHealth` (`IRealtimeProbe.ts:178`) :
|
|
478
479
|
un consommateur sait immédiatement s'il lit une vue pod ou une vue per-instance, et `instances[]`
|
|
479
480
|
garde le détail par worker pour le forage.
|
|
480
481
|
|
package/docs/protocole.md
CHANGED
|
@@ -106,7 +106,7 @@ Nodefony ne l'a pas inventée. Le calcul, mis à plat :
|
|
|
106
106
|
|
|
107
107
|
> [!NOTE]
|
|
108
108
|
> **Le batch est une propriété de la norme, pas du moteur Nodefony.** `JsonRpcPeer.receive()`
|
|
109
|
-
> (`JsonRpcPeer.ts:
|
|
109
|
+
> (`JsonRpcPeer.ts:390`) attend un **objet** portant `jsonrpc: "2.0"` ; un tableau de frames n'en
|
|
110
110
|
> porte pas et tombe donc en `invalid`. Une frame = un objet. Le multiplexage rend le batch peu
|
|
111
111
|
> utile ici : les canaux voyagent déjà dans la même connexion.
|
|
112
112
|
|
|
@@ -126,7 +126,7 @@ divergeaient ; c'est précisément la classe de bug que l'isomorphisme supprime.
|
|
|
126
126
|
|
|
127
127
|
**Le rôle se lit sur `method`, pas sur `id`.** La lecture naïve (« `id` présent = requête ») casse
|
|
128
128
|
sur les réponses, qui portent un `id` sans être des appels. `JsonRpcPeer.receive()`
|
|
129
|
-
(`JsonRpcPeer.ts:
|
|
129
|
+
(`JsonRpcPeer.ts:390`) teste donc `method` d'abord, puis la présence d'un `id`. Une frame `id` seul
|
|
130
130
|
est une **réponse** et ne déclenche jamais `-32601` à tort.
|
|
131
131
|
|
|
132
132
|
**Un échec ne raconte rien par défaut.** Un handler qui lève une exception ordinaire produit un
|
|
@@ -265,7 +265,7 @@ Sur le fil, dans l'inspecteur réseau du navigateur, exactement cinq frames :
|
|
|
265
265
|
```
|
|
266
266
|
|
|
267
267
|
L'accueil porte **cinq** champs : `ts`, `protocol`, `channels`, `methods`, `identity`
|
|
268
|
-
(`IRealtimeWelcome`, `RealtimeEventMap.ts:
|
|
268
|
+
(`IRealtimeWelcome`, `RealtimeEventMap.ts:231`), émis par le contrôleur au handshake
|
|
269
269
|
(`RealtimeController.ts:8`). Il n'y a pas de champ `version`.
|
|
270
270
|
|
|
271
271
|
## 🔌 Anatomie d'une frame, champ par champ
|
|
@@ -336,13 +336,13 @@ ouvertes à l'application. Colonne `id` : présent = requête (réponse due), ab
|
|
|
336
336
|
| Méthode | Direction | `id` ? | Rôle | Ancrage |
|
|
337
337
|
| ------------------ | ------------- | :-----: | --------------------------------------------------------------------- | --------------------------- |
|
|
338
338
|
| `subscribe` | client→server | non | « pousse-moi ce canal » — `params.channel` | `RealtimeController.ts:459` |
|
|
339
|
-
| `unsubscribe` | client→server | non | « arrête » — dernier abonné, le producteur est libéré | `RealtimeController.ts:
|
|
339
|
+
| `unsubscribe` | client→server | non | « arrête » — dernier abonné, le producteur est libéré | `RealtimeController.ts:727` |
|
|
340
340
|
| `ping` | client→server | non | Battement de cœur — **no-op serveur**, aucun pong | `RealtimeClient.ts:740` |
|
|
341
|
-
| `<canal>` | server→client | non | Push d'un message : le **nom du canal est la `method`** | `RealtimeController.ts:
|
|
342
|
-
| `<canal entrant>` | client→server | non | Le client pousse sur un canal déclaré entrant | `RealtimeController.ts:
|
|
343
|
-
| `realtime:welcome` | server→client | non | L'accueil : 5 champs, dont l'identité résolue | `RealtimeController.ts:
|
|
341
|
+
| `<canal>` | server→client | non | Push d'un message : le **nom du canal est la `method`** | `RealtimeController.ts:901` |
|
|
342
|
+
| `<canal entrant>` | client→server | non | Le client pousse sur un canal déclaré entrant | `RealtimeController.ts:736` |
|
|
343
|
+
| `realtime:welcome` | server→client | non | L'accueil : 5 champs, dont l'identité résolue | `RealtimeController.ts:693` |
|
|
344
344
|
| `realtime:denied` | server→client | non | Rend OBSERVABLE le refus d'une notification | `RealtimeController.ts:433` |
|
|
345
|
-
| `api.request` | client→server | **oui** | Pont API — rejoue une route HTTP sur la socket (désactivé par défaut) | `RealtimeController.ts:
|
|
345
|
+
| `api.request` | client→server | **oui** | Pont API — rejoue une route HTTP sur la socket (désactivé par défaut) | `RealtimeController.ts:534` |
|
|
346
346
|
| `<action>` | client→server | **oui** | Toute action déclarée par `@RealtimeAction` | `realtimeDecorators.ts:101` |
|
|
347
347
|
|
|
348
348
|
> [!TIP]
|
|
@@ -362,7 +362,7 @@ Les quatre formes de frame circulent en permanence sous tes yeux — ce schéma
|
|
|
362
362
|
> n'attend jamais de réponse.
|
|
363
363
|
|
|
364
364
|
`subscribe` et `unsubscribe` ne sont **pas** des actions enregistrées : elles sont traitées dans
|
|
365
|
-
`onRealtimeNotification()` (`RealtimeController.ts:
|
|
365
|
+
`onRealtimeNotification()` (`RealtimeController.ts:715`). Envoyées avec un `id`, elles seraient
|
|
366
366
|
classées « requête », ne trouveraient aucun handler et récolteraient un `-32601`.
|
|
367
367
|
|
|
368
368
|
## Une conversation type, de bout en bout
|
|
@@ -396,11 +396,11 @@ de `RpcError`.
|
|
|
396
396
|
|
|
397
397
|
| Code | Émis par | Déclencheur | Ce que voit le pair |
|
|
398
398
|
| -------- | ---------------------------------------------------------- | --------------------------------------------------- | ---------------------------------------------- |
|
|
399
|
-
| `-32601` | `JsonRpcPeer.handleRequest()` (`JsonRpcPeer.ts:
|
|
399
|
+
| `-32601` | `JsonRpcPeer.handleRequest()` (`JsonRpcPeer.ts:499`) | requête vers une action non enregistrée | `method not found: <nom>` |
|
|
400
400
|
| `-32603` | même méthode (`JsonRpcPeer.ts:528`) | le handler a levé une exception **ordinaire** | `internal error` — générique, rien d'autre |
|
|
401
401
|
| `-32001` | le refus du verrou de frame (`JsonRpcPeer.ts:400`) | `beforeDispatch` a dit non **sur une requête** | `unauthorized`, sans jamais dire pourquoi |
|
|
402
402
|
| `-32000` | défaut du constructeur de `RpcError` (`JsonRpcPeer.ts:74`) | le handler expose volontairement son refus | le message ET le `data` choisis par le handler |
|
|
403
|
-
| `-32602` | le pont API, via `RpcError` (`RealtimeController.ts:
|
|
403
|
+
| `-32602` | le pont API, via `RpcError` (`RealtimeController.ts:718`) | `api.request` appelé avec un `params.path` invalide | message explicite (l'appel est malformé) |
|
|
404
404
|
|
|
405
405
|
Et un échec qui n'est **pas** une frame : l'expiration. `startCall()` ne reçoit rien dans le délai
|
|
406
406
|
imparti, supprime l'entrée en attente et rejette localement avec `RPC timeout: <méthode>`
|
|
@@ -419,7 +419,7 @@ porte** ici :
|
|
|
419
419
|
|
|
420
420
|
Le choix est délibéré : une frame cassée n'a pas d'`id` digne de confiance, donc pas de corrélation
|
|
421
421
|
possible ; et répondre systématiquement à du bruit offre à un attaquant un amplificateur gratuit.
|
|
422
|
-
Le refus reste **traçable** — c'est le motif `invalid` de `FrameAuditReason` (`JsonRpcPeer.ts:
|
|
422
|
+
Le refus reste **traçable** — c'est le motif `invalid` de `FrameAuditReason` (`JsonRpcPeer.ts:155`),
|
|
423
423
|
qui alimente le journal d'audit avec le pair concerné.
|
|
424
424
|
|
|
425
425
|
### Le refus d'une notification n'est pas une erreur
|
|
@@ -433,10 +433,10 @@ radicalement :
|
|
|
433
433
|
| une **requête** | `-32001 "unauthorized"` (`JsonRpcPeer.ts:413`) | Un `id` existe : il y a un canal de réponse |
|
|
434
434
|
| une **notification** | la notification `realtime:denied` (`RealtimeController.ts:433`) | Aucun `id` : sans elle, le client se croirait abonné |
|
|
435
435
|
|
|
436
|
-
`IRealtimeDenied` (`RealtimeEventMap.ts:
|
|
436
|
+
`IRealtimeDenied` (`RealtimeEventMap.ts:269`) porte `channel` et `reason` — plus un `detail` optionnel, posé hors production seulement — et le motif
|
|
437
437
|
est **générique**. Jamais « il te manque `ROLE_ADMIN` » : ce serait un oracle d'autorisation, un
|
|
438
438
|
attaquant y lirait la carte des droits. Deux motifs circulent : `forbidden` (le verrou a dit non) et
|
|
439
|
-
`limit` (le plafond de canaux de la connexion est atteint, `RealtimeController.ts:
|
|
439
|
+
`limit` (le plafond de canaux de la connexion est atteint, `RealtimeController.ts:763`). Côté client,
|
|
440
440
|
`onDenied()` (`RealtimeClient.ts:469`) branche un handler dessus.
|
|
441
441
|
|
|
442
442
|
> [!CAUTION]
|
|
@@ -467,14 +467,14 @@ décrits dans [la page sécurité](./securite.md).
|
|
|
467
467
|
| Symptôme | Cause | Correction |
|
|
468
468
|
| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
|
469
469
|
| `subscribe` répond `-32601 method not found` | Envoyé **avec un `id`** : classé requête, or c'est une notification (`RealtimeController.ts:592`) | L'émettre sans `id` — `socket.subscribe(canal)` |
|
|
470
|
-
| Le handler passé à `subscribe` n'est jamais appelé | `RealtimeClient.subscribe()` prend **un seul** argument (`RealtimeClient.ts:
|
|
470
|
+
| Le handler passé à `subscribe` n'est jamais appelé | `RealtimeClient.subscribe()` prend **un seul** argument (`RealtimeClient.ts:532`) | `subscribe(canal)` **et** `on(canal, handler)`, deux gestes distincts |
|
|
471
471
|
| `request()` expire immédiatement, ou ignore le délai | Signature **positionnelle** `(méthode, params, ms)` (`RealtimeClient.ts:727`) — un objet d'options n'est pas lu | `request(m, p, 5000)` ; le défaut est 30 000 ms |
|
|
472
472
|
| Un tableau de frames n'obtient aucune réponse | Le batch n'est pas implémenté : un tableau n'a pas de `jsonrpc` → `invalid` (`JsonRpcPeer.ts:371`) | Une frame = un objet ; le multiplexage remplace le batch |
|
|
473
473
|
| Une frame malformée ne renvoie **aucune** erreur | Ni `-32700` ni `-32600` ne sont émis — silence + audit (`JsonRpcPeer.ts:396`) | Lire le motif `invalid` côté serveur, pas la réponse |
|
|
474
474
|
| L'exception du serveur n'arrive jamais au client | Zero Trust : tout throw ordinaire devient `-32603` générique (`JsonRpcPeer.ts:528`) | Lever une `RpcError` pour exposer volontairement code et `data` |
|
|
475
|
-
| Une notification refusée disparaît sans trace côté client | Sans `id`, aucune réponse possible (`beforeDispatch`, `JsonRpcPeer.ts:151`) | Écouter `realtime:denied` via `onDenied()` (`RealtimeClient.ts:
|
|
475
|
+
| Une notification refusée disparaît sans trace côté client | Sans `id`, aucune réponse possible (`beforeDispatch`, `JsonRpcPeer.ts:151`) | Écouter `realtime:denied` via `onDenied()` (`RealtimeClient.ts:471`) |
|
|
476
476
|
| `nodefony:kernel:ping` répond `-32601` sur mon endpoint | L'action `nodefony:kernel:ping` est déclarée par `@nodefony/studio` (`StudioRealtimeController.ts:114`) | Déclarer la sienne, ou lire `serverMethods` avant d'appeler |
|
|
477
|
-
| Le battement de cœur ne renvoie aucun pong | La notification `ping` est un no-op serveur (`RealtimeController.ts:
|
|
477
|
+
| Le battement de cœur ne renvoie aucun pong | La notification `ping` est un no-op serveur (`RealtimeController.ts:742`) | Pour mesurer un RTT, utiliser une action RPC — `ping()` (`RealtimeClient.ts:740`) |
|
|
478
478
|
| Une réponse reçue est ignorée sans message | Corrélation sur `id` **numériques** seulement (`JsonRpcPeer.ts:537`) | Ne pas fabriquer soi-même de réponse à `id` chaîne |
|
|
479
479
|
| Les premières frames envoyées après `connect()` semblent perdues | Le transport n'est branché qu'une fois le handshake terminé | Attendre `realtime:welcome` — le client le fait déjà |
|
|
480
480
|
|
package/docs/securite.md
CHANGED
|
@@ -93,16 +93,16 @@ Les étapes 1 et 2 appartiennent à `@nodefony/http` et `@nodefony/security` : l
|
|
|
93
93
|
Une WebSocket authentifiée est une cible de choix : elle porte une identité, vit longtemps et
|
|
94
94
|
diffuse en continu.
|
|
95
95
|
|
|
96
|
-
| Attaque | Ce qui la bloque
|
|
97
|
-
| ------------------------------------------------------------------------------------------------------ |
|
|
98
|
-
| **CSWSH** — `evil.com` fait `new WebSocket("wss://app.exemple.com/rt")`, le navigateur joint le cookie | Contrôle d'`Origin` same-origin par défaut, puis allowlist stricte
|
|
99
|
-
| **Écoute des flux internes** — un visiteur s'abonne à `nodefony:syslog` et lit les logs du pod | Plancher des namespaces réservés (authentifié + `
|
|
100
|
-
| **Élévation par canal métier** — un `ROLE_USER` s'abonne au canal admin d'un autre module | Policy déclarée sur le canal, évaluée avec la hiérarchie de rôles
|
|
101
|
-
| **Pont API plus permissif que REST** — `api.request {path}` pour contourner un 401 HTTP | Re-match de la MÊME zone firewall que `GET {path}`
|
|
102
|
-
| **Socket zombie** — un admin se déconnecte, sa socket continue de diffuser | Re-validation périodique de l'identité, fermeture `4001`
|
|
103
|
-
| **DoS mémoire par abonnements** — une connexion ouvre des milliers de canaux | Plafond de canaux par connexion (256 par défaut)
|
|
104
|
-
| **DoS mémoire par lenteur** — un client ne lit pas, la file d'envoi enfle jusqu'à l'OOM | Jet de frames à 1 MiB, fermeture `1013` à 8 MiB
|
|
105
|
-
| **Oracle d'autorisation** — sonder les canaux pour cartographier les droits | Motif de refus **générique** (`forbidden`), jamais le détail
|
|
96
|
+
| Attaque | Ce qui la bloque | Où |
|
|
97
|
+
| ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- | ------------------------------------------------------------ |
|
|
98
|
+
| **CSWSH** — `evil.com` fait `new WebSocket("wss://app.exemple.com/rt")`, le navigateur joint le cookie | Contrôle d'`Origin` same-origin par défaut, puis allowlist stricte | `@nodefony/http` transport, puis `csrf.checkOrigin` realtime |
|
|
99
|
+
| **Écoute des flux internes** — un visiteur s'abonne à `nodefony:syslog` et lit les logs du pod | Plancher des namespaces réservés (authentifié + `ROLE_NODEFONY_ADMIN`) | Verrou de frame `@nodefony/security` |
|
|
100
|
+
| **Élévation par canal métier** — un `ROLE_USER` s'abonne au canal admin d'un autre module | Policy déclarée sur le canal, évaluée avec la hiérarchie de rôles | `@RealtimeChannel(name, { roles })` + verrou de frame |
|
|
101
|
+
| **Pont API plus permissif que REST** — `api.request {path}` pour contourner un 401 HTTP | Re-match de la MÊME zone firewall que `GET {path}` | Verrou de frame, surface `api.request` |
|
|
102
|
+
| **Socket zombie** — un admin se déconnecte, sa socket continue de diffuser | Re-validation périodique de l'identité, fermeture `4001` | Tick de révocation du hub |
|
|
103
|
+
| **DoS mémoire par abonnements** — une connexion ouvre des milliers de canaux | Plafond de canaux par connexion (256 par défaut) | `limits.maxChannelsPerConnection` |
|
|
104
|
+
| **DoS mémoire par lenteur** — un client ne lit pas, la file d'envoi enfle jusqu'à l'OOM | Jet de frames à 1 MiB, fermeture `1013` à 8 MiB | Back-pressure du transport WS |
|
|
105
|
+
| **Oracle d'autorisation** — sonder les canaux pour cartographier les droits | Motif de refus **générique** (`forbidden`), jamais le détail | `realtime:denied` |
|
|
106
106
|
|
|
107
107
|
> [!IMPORTANT]
|
|
108
108
|
> Les lignes 2 à 5 supposent `@nodefony/security` chargé **avec au moins une zone protégée**. Sans
|
|
@@ -303,7 +303,7 @@ n'est jamais traitée.
|
|
|
303
303
|
4. `authenticator.authenticate(handshake)` — **async autorisé** (on est en cold path, une fois par
|
|
304
304
|
connexion : lire un store est acceptable ici, jamais par frame).
|
|
305
305
|
5. Pose du token sur la WeakMap `peer → token` via `RealtimeHub.setTokenForPeer()`
|
|
306
|
-
(`RealtimeHub.ts:
|
|
306
|
+
(`RealtimeHub.ts:1002`), **avant** l'envoi du `welcome` : le lookup est garanti dès la première
|
|
307
307
|
frame.
|
|
308
308
|
|
|
309
309
|
Un `throw` de `authenticate()` ferme la socket en `4001` « unauthorized », après un log `WARNING`
|
|
@@ -321,11 +321,11 @@ d'audit défectueux ne peut pas empêcher la fermeture.
|
|
|
321
321
|
- `host` optionnel → comparaison **stricte** (insensible à la casse) sur l'en-tête `Host`, sans
|
|
322
322
|
wildcard.
|
|
323
323
|
- Le match porte sur le **path**, query comprise, jamais sur l'URL absolue : `handshakePath()`
|
|
324
|
-
(`RealtimeController.ts:
|
|
324
|
+
(`RealtimeController.ts:1165`) extrait `pathname + search` du `WebsocketContext.url`, qui est
|
|
325
325
|
absolu. Sans cette extraction, un matcher `^/nodefony/…` ne se déclencherait jamais.
|
|
326
326
|
|
|
327
327
|
`@nodefony/security` enregistre ces matchers automatiquement dans `Firewall.#wireRealtime()`
|
|
328
|
-
(`firewall.ts:
|
|
328
|
+
(`firewall.ts:279`) : **une instance d'authenticator par zone protégée**, car le hub dédoublonne par
|
|
329
329
|
identité d'instance et une instance partagée n'enregistrerait que le premier matcher.
|
|
330
330
|
|
|
331
331
|
### Zero Trust — il y a toujours un token
|
|
@@ -439,9 +439,9 @@ mémoire : le token déjà résolu, et la cible de la frame.
|
|
|
439
439
|
| action `@RealtimeAction` | Authentifié **par défaut** ; rôle/scope si déclarés ; ouverte seulement si `{ authenticated: false }` est écrit |
|
|
440
440
|
| `ping`, `unsubscribe` | **passent** — pas de surface de données |
|
|
441
441
|
|
|
442
|
-
Deux détails évitent des faux refus : `authorizeApiRequest()` (`frameAuthorizer.ts:
|
|
442
|
+
Deux détails évitent des faux refus : `authorizeApiRequest()` (`frameAuthorizer.ts:330`) laisse
|
|
443
443
|
passer une frame au `path` invalide (le handler renverra `-32602` — le verrou ne duplique pas la
|
|
444
|
-
validation), et `authorizeChannel()` (`frameAuthorizer.ts:
|
|
444
|
+
validation), et `authorizeChannel()` (`frameAuthorizer.ts:359`) laisse passer un `channel`
|
|
445
445
|
non-chaîne (`startChannel` ignore de toute façon un canal absent).
|
|
446
446
|
|
|
447
447
|
### Le plancher système — les namespaces réservés
|
|
@@ -449,12 +449,12 @@ non-chaîne (`startChannel` ignore de toute façon un canal absent).
|
|
|
449
449
|
Certains namespaces exposent l'intérieur du pod. Ils portent une politique par défaut que la config
|
|
450
450
|
peut **resserrer**, jamais desserrer.
|
|
451
451
|
|
|
452
|
-
| Cible | Politique par défaut
|
|
453
|
-
| ------------------------------------------ |
|
|
454
|
-
| `nodefony:audit` | authentifié + `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts:99`)
|
|
455
|
-
| tout le reste de `nodefony:` | authentifié + `
|
|
456
|
-
| tout canal contenant `:health` ou `:stats` | authentifié + `
|
|
457
|
-
| tout le reste | libre, sauf policy déclarée
|
|
452
|
+
| Cible | Politique par défaut |
|
|
453
|
+
| ------------------------------------------ | -------------------------------------------------------------- |
|
|
454
|
+
| `nodefony:audit` | authentifié + `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts:99`) |
|
|
455
|
+
| tout le reste de `nodefony:` | authentifié + `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts:89`) |
|
|
456
|
+
| tout canal contenant `:health` ou `:stats` | authentifié + `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts:253`) |
|
|
457
|
+
| tout le reste | libre, sauf policy déclarée |
|
|
458
458
|
|
|
459
459
|
Le journal d'audit est un **canal précis**, pas un namespace : sa règle est posée devant celle du
|
|
460
460
|
territoire (premier match gagnant), et elle n'existe que si le hub réserve bien ce territoire.
|
|
@@ -463,12 +463,12 @@ Trois durcissements méritent d'être connus :
|
|
|
463
463
|
|
|
464
464
|
- **Match insensible à la casse, sans allocation** — `startsWithCI()` (`frameAuthorizer.ts:181`).
|
|
465
465
|
Un `NODEFONY:syslog` ne contourne pas le plancher `nodefony:` par un changement de casse.
|
|
466
|
-
- **Plancher irréductible** — `floorReserved()` (`frameAuthorizer.ts:
|
|
466
|
+
- **Plancher irréductible** — `floorReserved()` (`frameAuthorizer.ts:269`) : une règle de config qui
|
|
467
467
|
tenterait d'ouvrir un namespace réservé (`{ authenticated: false }`) se voit ré-imposer
|
|
468
468
|
`authenticated: true`. Le test porte sur le **namespace du canal**, pas sur le préfixe de la règle
|
|
469
469
|
qui a matché : un préfixe de config plus court ou altéré ne contourne rien.
|
|
470
470
|
- **La config passe avant les défauts** — les règles de `realtimeChannels`
|
|
471
|
-
(`security/nodefony/config/config.ts:
|
|
471
|
+
(`security/nodefony/config/config.ts:1130`) sont placées en tête, premier match gagnant. On peut
|
|
472
472
|
donc re-cibler `nodefony:syslog` sur `ROLE_SECURITY_AUDITOR` ; on ne peut pas l'ouvrir à l'anonyme.
|
|
473
473
|
|
|
474
474
|
Le canal du journal d'audit (`nodefony:audit`) est enregistré comme **canal système** sur le hub
|
|
@@ -479,13 +479,13 @@ jamais de canal d'audit non gardé.
|
|
|
479
479
|
|
|
480
480
|
### La policy métier — déclarer sur le canal
|
|
481
481
|
|
|
482
|
-
`@RealtimeChannel(name, policy)` (`realtimeDecorators.ts:
|
|
483
|
-
(`realtimeDecorators.ts:
|
|
482
|
+
`@RealtimeChannel(name, policy)` (`realtimeDecorators.ts:192`) et `@RealtimeInbound(name, policy)`
|
|
483
|
+
(`realtimeDecorators.ts:231`) attachent un `IChannelPolicy` au **nom** du canal. Les trois axes sont
|
|
484
484
|
cumulatifs (ET) ; un axe absent n'impose rien :
|
|
485
485
|
|
|
486
486
|
| Axe | Sens | Évalué par |
|
|
487
487
|
| --------------- | ----------------------------------------------------------------------------- | ---------------------------------------- |
|
|
488
|
-
| `authenticated` | token non anonyme | `satisfies()` (`frameAuthorizer.ts:
|
|
488
|
+
| `authenticated` | token non anonyme | `satisfies()` (`frameAuthorizer.ts:290`) |
|
|
489
489
|
| `roles` | un des rôles suffit, **hiérarchie comprise** | `Firewall.hasRole()` (`firewall.ts:482`) |
|
|
490
490
|
| `scopes` | un des scopes suffit — axe API (JWT, clé API), une session BFF n'en porte pas | comparaison directe |
|
|
491
491
|
|
|
@@ -531,7 +531,7 @@ Le verrou n'existe que si quelqu'un le pose. Deux conditions doivent être vraie
|
|
|
531
531
|
1. `@nodefony/security` est chargé, et
|
|
532
532
|
2. au moins une zone a `security: true` **et** `realtime: true`.
|
|
533
533
|
|
|
534
|
-
C'est exactement le test de `Firewall.#wireRealtime()` (`firewall.ts:
|
|
534
|
+
C'est exactement le test de `Firewall.#wireRealtime()` (`firewall.ts:279`) : sans zone qualifiante,
|
|
535
535
|
`wired` reste faux, `setFrameAuthorizer` n'est jamais appelé, et **aucune** policy de canal n'est
|
|
536
536
|
évaluée — ni métier, ni système. `nodefony:syslog` redevient un canal ordinaire.
|
|
537
537
|
|
|
@@ -545,7 +545,7 @@ firewall se construit au boot, avant tout trafic ; le cas ne se présente qu'en
|
|
|
545
545
|
|
|
546
546
|
**Le refus de dégrader en silence.** Quand des policies sont déclarées sans décideur câblé,
|
|
547
547
|
`RealtimeHub.hasUnenforcedChannelPolicies()` (`RealtimeHub.ts:1101`) renvoie `true` et le controller
|
|
548
|
-
émet un WARNING explicite, une seule fois par process (`RealtimeController.ts:
|
|
548
|
+
émet un WARNING explicite, une seule fois par process (`RealtimeController.ts:558`) :
|
|
549
549
|
|
|
550
550
|
```text
|
|
551
551
|
Realtime channel policies declared but NO frame authorizer is wired —
|
|
@@ -571,7 +571,7 @@ Le refus doit être observable sans devenir un oracle. Nodefony tranche ainsi :
|
|
|
571
571
|
|
|
572
572
|
| Type de frame refusée | Réponse au client |
|
|
573
573
|
| ----------------------------------- | -------------------------------------------------------------------------------- |
|
|
574
|
-
| Requête (avec `id`) | `-32001 "unauthorized"` — `JsonRpcPeer.receive()` (`JsonRpcPeer.ts:
|
|
574
|
+
| Requête (avec `id`) | `-32001 "unauthorized"` — `JsonRpcPeer.receive()` (`JsonRpcPeer.ts:390`) |
|
|
575
575
|
| Notification (`subscribe`, inbound) | `realtime:denied { channel, reason: "forbidden" }` (`RealtimeController.ts:432`) |
|
|
576
576
|
| Dépassement du plafond de canaux | `realtime:denied { channel, reason: "limit" }` |
|
|
577
577
|
| Canal sans aucun producteur | `realtime:denied { channel, reason: "unknown" }` |
|
|
@@ -608,7 +608,7 @@ ou changée → `-32000` avec `status: 401`, et le client bascule sur un `fetch`
|
|
|
608
608
|
courant. Une erreur de re-validation vaut refus (fail-closed).
|
|
609
609
|
|
|
610
610
|
**Sur les canaux**, le hub n'inscrit au registre de révocation que les connexions dont le token porte
|
|
611
|
-
`isValid` (`RealtimeController.ts:
|
|
611
|
+
`isValid` (`RealtimeController.ts:586`) — anonymes et JWT n'y entrent jamais, coût nul.
|
|
612
612
|
`RealtimeHub.registerRevocable()` (`RealtimeHub.ts:778`) démarre un `setInterval` `unref` au premier
|
|
613
613
|
inscrit et l'arrête dès que le registre se vide : zéro timer au repos. Période :
|
|
614
614
|
`REVOCATION_REVALIDATE_MS` (`RealtimeHub.ts:111`), 30 s, alignée sur le heartbeat WS.
|
|
@@ -625,7 +625,7 @@ Ce que `realtimeRevocation.attack.test.ts` prouve exactement :
|
|
|
625
625
|
| registre vide | tick no-op, aucun crash |
|
|
626
626
|
|
|
627
627
|
La source de vérité côté security est `buildSessionRevalidator()`
|
|
628
|
-
(`FirewallRealtimeAuthenticator.ts:
|
|
628
|
+
(`FirewallRealtimeAuthenticator.ts:240`) : il relit la session BFF **par son id capturé au handshake**
|
|
629
629
|
et vérifie qu'elle est toujours vivante **et toujours celle du même utilisateur** — ce second point
|
|
630
630
|
attrape le changement de compte sur un navigateur partagé.
|
|
631
631
|
|
|
@@ -652,12 +652,12 @@ pas** borné.
|
|
|
652
652
|
Chaque canal ouvert coûte un provider, un ticker et une entrée de Map. Sans borne, une connexion
|
|
653
653
|
peut abonner jusqu'à l'OOM — un déni de service mémoire déclenché par **un seul** client.
|
|
654
654
|
|
|
655
|
-
`RealtimeController.startChannel()` (`RealtimeController.ts:
|
|
655
|
+
`RealtimeController.startChannel()` (`RealtimeController.ts:746`) refuse au-delà de
|
|
656
656
|
`limits.maxChannelsPerConnection` (`realtime/nodefony/config/config.ts:142`), défaut **256**,
|
|
657
657
|
`null` pour illimité. Points prouvés par `realtimeChannelCap.attack.test.ts` :
|
|
658
658
|
|
|
659
659
|
- le plafond est actif **même sans `RealtimeService`** — le hub porte le défaut 256
|
|
660
|
-
(`RealtimeHub.ts:
|
|
660
|
+
(`RealtimeHub.ts:374`), il n'y a pas de fenêtre où la garde n'existerait pas ;
|
|
661
661
|
- au-delà, le hub **n'est jamais appelé** : le canal n'est pas ouvert, aucun provider ne démarre ;
|
|
662
662
|
- le refus est **observable** (`realtime:denied`, motif `limit`) ;
|
|
663
663
|
- un re-`subscribe` d'un canal déjà tenu **ne consomme pas de slot** (l'idempotence est vérifiée
|
|
@@ -672,7 +672,7 @@ amplificateur.
|
|
|
672
672
|
|
|
673
673
|
Un onglet en arrière-plan, un mobile en zone blanche, une fenêtre TCP pleine : la file d'envoi
|
|
674
674
|
grossit sans borne, et le multiplexage concentre le risque (une socket lente bloque tous ses
|
|
675
|
-
canaux). `WsConnectionTransport.send()` (`WsConnectionTransport.ts:
|
|
675
|
+
canaux). `WsConnectionTransport.send()` (`WsConnectionTransport.ts:82`) applique deux seuils :
|
|
676
676
|
|
|
677
677
|
| `bufferedAmount` | Action |
|
|
678
678
|
| ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
|
|
@@ -681,7 +681,7 @@ canaux). `WsConnectionTransport.send()` (`WsConnectionTransport.ts:76`) applique
|
|
|
681
681
|
|
|
682
682
|
### Taille des messages entrants
|
|
683
683
|
|
|
684
|
-
Portée par `@nodefony/http` : `websocket.maxPayload` (`http/nodefony/config/config.ts:
|
|
684
|
+
Portée par `@nodefony/http` : `websocket.maxPayload` (`http/nodefony/config/config.ts:543`), défaut
|
|
685
685
|
**1 MiB**, au-delà fermeture RFC 6455 `1009` « Message Too Big ». C'est un durcissement par rapport
|
|
686
686
|
au défaut de la librairie `ws`.
|
|
687
687
|
|
|
@@ -773,10 +773,10 @@ n'est pas appliquée.
|
|
|
773
773
|
## 📡 Observabilité — Studio
|
|
774
774
|
|
|
775
775
|
- **Santé de la socket** : `/nodefony/realtime/api/health`, alimenté par `RealtimeHub.probe()`
|
|
776
|
-
(`RealtimeHub.ts:
|
|
776
|
+
(`RealtimeHub.ts:849`) — canaux, abonnés, fan-out, connexions, back-pressure et carte d'identité
|
|
777
777
|
du backplane. Un `drops` qui grimpe signale des clients en souffrance ; un `slowConsumers` non nul
|
|
778
778
|
précède souvent une fermeture `1013`. Même snapshot en flux sur le canal `nodefony:socket`
|
|
779
|
-
(namespace réservé : `
|
|
779
|
+
(namespace réservé : `ROLE_NODEFONY_ADMIN`).
|
|
780
780
|
- **Journal d'audit** : les refus de frame arrivent sur le canal `nodefony:audit`
|
|
781
781
|
(`ROLE_NODEFONY_ADMIN`) et dans le data plane d'audit de `@nodefony/security`.
|
|
782
782
|
- **Page module** : `/nodefony/modules/realtime` — la config effective (dont `csrf.checkOrigin` et
|
package/docs/vocabulaire.md
CHANGED
|
@@ -229,7 +229,7 @@ Un **tuyau bidirectionnel nommé**, multiplexé sur la connexion : `"chat:room-4
|
|
|
229
229
|
`"nodefony:orm:health"`, `"nodefony:socket"`. Convention de nommage par `:` (espace de noms, puis
|
|
230
230
|
précision). Ce n'est pas une classe — c'est une **chaîne de caractères indexée par le hub**.
|
|
231
231
|
|
|
232
|
-
`RealtimeHub.subscribe()` (`RealtimeHub.ts:
|
|
232
|
+
`RealtimeHub.subscribe()` (`RealtimeHub.ts:462`) l'indexe ; `IRealtimeSocket.subscribe()`
|
|
233
233
|
(`IRealtimeSocket.ts:132`) le demande. Plusieurs canaux cohabitent sur **une** connexion : c'est le
|
|
234
234
|
multiplexage.
|
|
235
235
|
|
|
@@ -269,7 +269,7 @@ Un handler qui lève une erreur rend un `-32603` **générique** ; seule une `Rp
|
|
|
269
269
|
### `canal entrant` (inbound) — le client pousse
|
|
270
270
|
|
|
271
271
|
Un canal où le **client a le droit d'émettre** vers le serveur. Défaut sûr : **aucun**. Un canal
|
|
272
|
-
n'accepte d'entrée que déclaré explicitement, par `@RealtimeInbound` (`realtimeDecorators.ts:
|
|
272
|
+
n'accepte d'entrée que déclaré explicitement, par `@RealtimeInbound` (`realtimeDecorators.ts:231`) ou
|
|
273
273
|
par l'override `realtimeInbound()` (`RealtimeController.ts:205`).
|
|
274
274
|
|
|
275
275
|
Le handler reçoit `(params, reply)` — `params` vient du réseau, donc **jamais fiable** :
|
|
@@ -347,7 +347,7 @@ L'action de diffuser : une charge publiée sur un canal part vers **tous** les s
|
|
|
347
347
|
localement. Une connexion fautive ne casse pas la diffusion aux autres — chaque livraison est isolée.
|
|
348
348
|
|
|
349
349
|
`RealtimeHub.publish()` (`RealtimeHub.ts:604`) pour le chemin complet, `publishLocal()`
|
|
350
|
-
(`RealtimeHub.ts:
|
|
350
|
+
(`RealtimeHub.ts:623`) pour la diffusion **strictement locale** (voie d'entrée du backplane).
|
|
351
351
|
|
|
352
352
|
### `sink` — le point de livraison d'une connexion
|
|
353
353
|
|
|
@@ -371,7 +371,7 @@ Le modèle de coût du module : là où une implémentation naïve crée un tick
|
|
|
371
371
|
Nodefony en crée **un par canal et par pod** et se contente d'ajouter un sink par abonné. Mille
|
|
372
372
|
spectateurs d'un tableau de bord coûtent un timer, pas mille.
|
|
373
373
|
|
|
374
|
-
`RealtimeHub.subscribe()` (`RealtimeHub.ts:
|
|
374
|
+
`RealtimeHub.subscribe()` (`RealtimeHub.ts:462`).
|
|
375
375
|
|
|
376
376
|
### `canal passif` — écouter n'est pas posséder
|
|
377
377
|
|
|
@@ -403,7 +403,7 @@ Un canal **déclaré** comme franchissant la frontière du process : chat, prés
|
|
|
403
403
|
déclaration se fait par **préfixe**, ce qui couvre les variantes de cadence (`chat:` couvre
|
|
404
404
|
`chat:room-42:1000`).
|
|
405
405
|
|
|
406
|
-
`RealtimeHub.markBroadcastChannel()` (`RealtimeHub.ts:
|
|
406
|
+
`RealtimeHub.markBroadcastChannel()` (`RealtimeHub.ts:668`), déclaré côté contrôleur par
|
|
407
407
|
`@RealtimeBroadcast` (`realtimeDecorators.ts:342`).
|
|
408
408
|
|
|
409
409
|
### `canal instance-local` — le défaut
|
|
@@ -469,14 +469,14 @@ Client et serveur sont des **pairs** : classer une frame, router, corréler les
|
|
|
469
469
|
travail des deux côtés. Nodefony l'écrit **une seule fois** ; chaque côté l'entoure de son transport
|
|
470
470
|
et de ses handlers. Aucune dépendance Node — le même fichier tourne dans le navigateur.
|
|
471
471
|
|
|
472
|
-
`JsonRpcPeer` (`JsonRpcPeer.ts:
|
|
472
|
+
`JsonRpcPeer` (`JsonRpcPeer.ts:280`), contrat `IRealtimePeer` (`JsonRpcPeer.ts:213`).
|
|
473
473
|
|
|
474
474
|
### `dispatch` — classer puis router
|
|
475
475
|
|
|
476
476
|
Le geste du pair sur une frame entrante : décider de sa nature, trouver le handler, l'appeler,
|
|
477
477
|
renvoyer la réponse s'il en faut une. Ce n'est pas une méthode publique — c'est un **moment**, et
|
|
478
478
|
c'est précisément là que se greffe le [verrou de frame](#verrou-de-frame--la-décision-par-frame),
|
|
479
|
-
par le crochet `beforeDispatch` (`JsonRpcPeer.ts:
|
|
479
|
+
par le crochet `beforeDispatch` (`JsonRpcPeer.ts:192`).
|
|
480
480
|
|
|
481
481
|
### `transport` — la couche octets
|
|
482
482
|
|
|
@@ -534,7 +534,7 @@ Le **fond de panier** d'un rack : la carte qui relie toutes les autres. Ici, le
|
|
|
534
534
|
publications d'un process aux autres. Un contrat volontairement minuscule — `publish`, `onMessage`,
|
|
535
535
|
`start`, `stop`, `describe` — parce que tout ce qui est riche appartient au hub.
|
|
536
536
|
|
|
537
|
-
`IBackplane` (`IBackplane.ts:
|
|
537
|
+
`IBackplane` (`IBackplane.ts:107`), message `IBackplaneMessage` (`IBackplane.ts:51`), branchement
|
|
538
538
|
`RealtimeHub.setBackplane()` (`RealtimeHub.ts:714`).
|
|
539
539
|
|
|
540
540
|
> [!TIP]
|
|
@@ -681,7 +681,7 @@ Le contrôle de l'en-tête `Origin` à l'ouverture (RFC 6455 §10.2) : un site t
|
|
|
681
681
|
ouvrir une connexion authentifiée dans le dos de l'utilisateur. Correspondance **exacte**, aucun
|
|
682
682
|
caractère générique.
|
|
683
683
|
|
|
684
|
-
`OriginGuard` (`RealtimeHub.ts:
|
|
684
|
+
`OriginGuard` (`RealtimeHub.ts:33`), configuration `csrf.checkOrigin` (`config.ts:255`).
|
|
685
685
|
|
|
686
686
|
### `révocation` — la connexion qui survit à sa session
|
|
687
687
|
|
|
@@ -708,7 +708,7 @@ Le signal émis sur les événements protocolaires qui méritent une trace : fra
|
|
|
708
708
|
refusée, méthode inconnue, erreur interne. Émis sans attente, avec le pair — ce qui permet de
|
|
709
709
|
retrouver **qui** a été refusé, pas seulement d'où venait le paquet.
|
|
710
710
|
|
|
711
|
-
`FrameAuditReason` (`JsonRpcPeer.ts:
|
|
711
|
+
`FrameAuditReason` (`JsonRpcPeer.ts:155`), crochet `onFrameAudit` (`JsonRpcPeer.ts:210`).
|
|
712
712
|
|
|
713
713
|
### `seam` — le point de greffe
|
|
714
714
|
|
|
@@ -716,8 +716,8 @@ Littéralement une **couture** : un point prévu dans une couche basse pour qu'u
|
|
|
716
716
|
greffe du comportement **sans modifier la couche basse**. Le module en expose cinq, et c'est ce qui
|
|
717
717
|
permet à la couche sécurité de se brancher sans qu'aucune ligne de realtime ne la connaisse.
|
|
718
718
|
|
|
719
|
-
Les deux du protocole sont `beforeDispatch` (`JsonRpcPeer.ts:
|
|
720
|
-
(`JsonRpcPeer.ts:
|
|
719
|
+
Les deux du protocole sont `beforeDispatch` (`JsonRpcPeer.ts:192`) et `onFrameAudit`
|
|
720
|
+
(`JsonRpcPeer.ts:210`) ; les trois du hub sont l'authenticator, le matcher et le contrôle d'origine.
|
|
721
721
|
|
|
722
722
|
→ [Sécurité](./securite.md) pour le détail de chacun.
|
|
723
723
|
|
|
@@ -747,7 +747,7 @@ L'oscilloscope du module : « voici mon état, maintenant ». Canaux et abonnés
|
|
|
747
747
|
livraisons, connexions, octets, back-pressure, carte d'identité du backplane. Lecture **pure**, sans
|
|
748
748
|
allocation sur le chemin chaud.
|
|
749
749
|
|
|
750
|
-
`RealtimeHub.probe()` (`RealtimeHub.ts:
|
|
750
|
+
`RealtimeHub.probe()` (`RealtimeHub.ts:849`) rend un `IRealtimeProbe` (`IRealtimeProbe.ts:61`).
|
|
751
751
|
|
|
752
752
|
> [!WARNING]
|
|
753
753
|
> `IRealtimeProbe` est la **forme des données**, pas une interface à implémenter avec une méthode
|
|
@@ -769,8 +769,8 @@ décide de rien. Les seuils d'ACTION, eux, sont des réglages : `websocket.maxBa
|
|
|
769
769
|
Une connexion dont le back-pressure dépasse le seuil de comptage. C'est une **métrique**, pas une
|
|
770
770
|
sanction : le seuil de comptage se règle indépendamment des seuils d'action.
|
|
771
771
|
|
|
772
|
-
`slowConsumer.bytes` (`config.ts:
|
|
773
|
-
(`IRealtimeProbe.ts:
|
|
772
|
+
`slowConsumer.bytes` (`config.ts:121`), champ `backpressure.slowConsumers`
|
|
773
|
+
(`IRealtimeProbe.ts:102`).
|
|
774
774
|
|
|
775
775
|
### `compteurs de canal` — mesurés à l'arrivée
|
|
776
776
|
|
|
@@ -794,16 +794,16 @@ Le producteur qui expose la sonde sous `/nodefony/realtime/api/health`, selon la
|
|
|
794
794
|
routage des administrations de modules. C'est de l'**auto-observabilité** : la socket se regarde
|
|
795
795
|
elle-même par le même chemin que les autres modules.
|
|
796
796
|
|
|
797
|
-
`createRealtimeAdminApi()` (`RealtimeAdminApi.ts:
|
|
797
|
+
`createRealtimeAdminApi()` (`RealtimeAdminApi.ts:98`), construction `buildOwnHealth()`
|
|
798
798
|
(`RealtimeAdminApi.ts:52`).
|
|
799
799
|
|
|
800
800
|
## ⚠️ Pièges — les faux-amis du vocabulaire
|
|
801
801
|
|
|
802
802
|
| Symptôme | Cause — le mot pris pour un autre | Correction |
|
|
803
803
|
| ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
|
804
|
-
| « Je suis abonné mais je ne reçois rien » | `subscribe` confondu avec `on` : le flux est demandé, aucun écouteur ne le lit | Les deux : `subscribe(canal)` **et** `on(canal, handler)` (`IRealtimeSocket.ts:
|
|
804
|
+
| « Je suis abonné mais je ne reçois rien » | `subscribe` confondu avec `on` : le flux est demandé, aucun écouteur ne le lit | Les deux : `subscribe(canal)` **et** `on(canal, handler)` (`IRealtimeSocket.ts:131`) |
|
|
805
805
|
| « Ça marche en local, plus rien dès qu'on passe à plusieurs pods » | canal resté **instance-local** — le défaut. Traverser le process est une capacité qu'on **demande** | Déclarer le préfixe dans `@RealtimeBroadcast` (`realtimeDecorators.ts:342`) |
|
|
806
|
-
| « Le client publie, le serveur ignore » | canal non déclaré **entrant**. Un client ne peut rien pousser tant qu'aucun handler n'existe | `@RealtimeInbound("mon:canal")` (`realtimeDecorators.ts:
|
|
806
|
+
| « Le client publie, le serveur ignore » | canal non déclaré **entrant**. Un client ne peut rien pousser tant qu'aucun handler n'existe | `@RealtimeInbound("mon:canal")` (`realtimeDecorators.ts:231`) |
|
|
807
807
|
| Deux déploiements se mélangent sur un même serveur Redis | pas de **cloison** — le numéro de base ne cloisonne pas le pub/sub | Poser `backplane.namespace` (`config.ts:59`) |
|
|
808
808
|
| Le fan-out disparaît entre conteneurs identiques | **origine** dérivée du seul identifiant de processus : deux conteneurs sont tous deux le n° 1, l'anti-écho avale tout | `resolveBackplaneOriginId()` (`originId.ts:24`) dérive du pod ou de l'hôte |
|
|
809
809
|
| Deux tickers pour le même tableau de bord | **cadence** différente = **canal** différent, jamais réconcilié | Fabriquer le nom via `rateChannel()` (`channelRate.ts:44`) des deux côtés |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nodefony/realtime",
|
|
3
|
-
"version": "10.0.0-alpha.
|
|
3
|
+
"version": "10.0.0-alpha.9",
|
|
4
4
|
"description": "Couche temps réel serveur de Nodefony : canaux, appels bidirectionnels JSON-RPC 2.0, contre-pression, et diffusion entre plusieurs instances (boucle locale, IPC de grappe, Redis)",
|
|
5
5
|
"author": "Christophe CAMENSULI <ccamensuli@gmail.com>",
|
|
6
6
|
"type": "module",
|
|
@@ -20,10 +20,11 @@
|
|
|
20
20
|
"./package.json": "./package.json"
|
|
21
21
|
},
|
|
22
22
|
"scripts": {
|
|
23
|
-
"build": "
|
|
23
|
+
"build": "rolldown -c rolldown.config.ts && tsgo -p tsconfig.declarations.json",
|
|
24
24
|
"dev": "rolldown -c rolldown.config.ts --watch",
|
|
25
|
-
"clean": "
|
|
25
|
+
"clean": "node --eval \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
26
26
|
"test": "vitest run",
|
|
27
|
+
"test:cluster": "vitest run --config vitest.cluster.config.ts",
|
|
27
28
|
"coverage": "vitest run --coverage",
|
|
28
29
|
"lint": "tsc --noEmit",
|
|
29
30
|
"typecheck": "tsgo --noEmit -p tsconfig.json && tsgo --noEmit -p tsconfig.tests.json"
|
|
@@ -40,20 +41,19 @@
|
|
|
40
41
|
"esm"
|
|
41
42
|
],
|
|
42
43
|
"peerDependencies": {
|
|
43
|
-
"@nodefony/framework": "^10.0.0-alpha.
|
|
44
|
-
"@nodefony/http": "^10.0.0-alpha.
|
|
45
|
-
"nodefony": "^10.0.0-alpha.
|
|
44
|
+
"@nodefony/framework": "^10.0.0-alpha.9",
|
|
45
|
+
"@nodefony/http": "^10.0.0-alpha.9",
|
|
46
|
+
"nodefony": "^10.0.0-alpha.9",
|
|
46
47
|
"reflect-metadata": "0.2.2",
|
|
47
48
|
"zod": "^4.6.5"
|
|
48
49
|
},
|
|
49
50
|
"devDependencies": {
|
|
50
|
-
"@nodefony/framework": "^10.0.0-alpha.
|
|
51
|
-
"@nodefony/http": "^10.0.0-alpha.
|
|
52
|
-
"@types/node": "26.6.
|
|
51
|
+
"@nodefony/framework": "^10.0.0-alpha.9",
|
|
52
|
+
"@nodefony/http": "^10.0.0-alpha.9",
|
|
53
|
+
"@types/node": "26.6.2",
|
|
53
54
|
"@vitest/coverage-v8": "5.0.1",
|
|
54
|
-
"nodefony": "^10.0.0-alpha.
|
|
55
|
+
"nodefony": "^10.0.0-alpha.9",
|
|
55
56
|
"reflect-metadata": "0.2.2",
|
|
56
|
-
"rimraf": "6.1.3",
|
|
57
57
|
"vitest": "5.0.1"
|
|
58
58
|
},
|
|
59
59
|
"private": false,
|