@nodefony/realtime 10.0.0-beta.4 → 10.0.0-beta.6
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 +79 -10
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
- package/dist/index.js +79 -12
- package/dist/nodefony/config/config.js +7 -0
- package/dist/nodefony/decorators/realtimeDecorators.js +125 -19
- package/dist/nodefony/interfaces/IRealtimeConnection.js +1 -0
- package/dist/nodefony/src/backplane/ClusterBackplane.js +32 -4
- package/dist/nodefony/src/backplane/RedisBackplane.js +143 -31
- package/dist/nodefony/src/backplane/controlOrder.js +57 -0
- package/dist/nodefony/src/backplane/envelope.js +113 -12
- package/dist/nodefony/src/cluster/ClusterProbeClient.js +2 -0
- package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
- package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
- package/dist/nodefony/src/server/RealtimeConnection.js +110 -0
- package/dist/nodefony/src/server/RealtimeController.js +344 -85
- package/dist/nodefony/src/server/RealtimeHub.js +604 -61
- package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
- package/dist/nodefony/src/server/admissionBudget.js +176 -0
- package/dist/nodefony/src/server/channelPolicyRegistry.js +73 -0
- package/dist/nodefony/src/server/connectionAdmission.js +242 -0
- package/dist/nodefony/src/server/contractRoutes.js +85 -0
- package/dist/nodefony/src/server/deniedDetail.js +10 -3
- package/dist/nodefony/src/server/frameAdmission.js +168 -0
- package/dist/nodefony/src/server/subscribedAck.js +10 -1
- package/dist/nodefony/src/server/syslogUplink.js +3 -4
- package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
- package/dist/nodefony/src/service/RealtimeService.js +42 -1
- package/dist/nodefony/src/service/publicOrigin.js +40 -0
- package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
- package/dist/nodefony/testing/backplaneConformance.js +271 -0
- package/dist/nodefony/testing/index.js +21 -4
- package/dist/types/index.d.ts +34 -7
- package/dist/types/nodefony/config/config.d.ts +9 -0
- package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +86 -6
- package/dist/types/nodefony/interfaces/IBackplane.d.ts +34 -50
- package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
- package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
- package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
- package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
- package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
- package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
- package/dist/types/nodefony/interfaces/index.d.ts +2 -1
- package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
- package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +79 -16
- package/dist/types/nodefony/src/backplane/controlOrder.d.ts +52 -0
- package/dist/types/nodefony/src/backplane/envelope.d.ts +34 -0
- package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +10 -0
- package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
- package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +17 -0
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +93 -13
- package/dist/types/nodefony/src/server/RealtimeHub.d.ts +321 -24
- package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
- package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
- package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +34 -0
- package/dist/types/nodefony/src/server/connectionAdmission.d.ts +68 -0
- package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
- package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
- package/dist/types/nodefony/src/server/frameAdmission.d.ts +70 -0
- package/dist/types/nodefony/src/server/subscribedAck.d.ts +7 -0
- package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
- package/dist/types/nodefony/src/service/RealtimeService.d.ts +10 -2
- package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
- package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
- package/dist/types/nodefony/testing/backplaneConformance.d.ts +66 -0
- package/dist/types/nodefony/testing/index.d.ts +17 -4
- package/docs/actions.md +442 -19
- package/docs/architecture.md +83 -8
- package/docs/cadence-et-contre-pression.md +87 -11
- package/docs/canaux-a-membres.md +267 -0
- package/docs/cas-d-usage.md +1702 -0
- package/docs/configuration.md +137 -17
- package/docs/cookbook-chat.md +333 -85
- package/docs/index.md +11 -1
- package/docs/observabilite.md +71 -14
- package/docs/protocole.md +133 -24
- package/docs/securite.md +294 -33
- package/docs/vocabulaire.md +13 -12
- package/package.json +14 -11
package/docs/observabilite.md
CHANGED
|
@@ -247,17 +247,17 @@ socket.subscribe("shop:health");
|
|
|
247
247
|
|
|
248
248
|
## 🧰 Ce que la sonde expose, champ par champ
|
|
249
249
|
|
|
250
|
-
Le contrat est `IRealtimeProbe` (`
|
|
250
|
+
Le contrat est `IRealtimeProbe` (`nodefony/src/realtime/platformHealth.ts#IRealtimeProbe`). Quatre familles, un discriminant.
|
|
251
251
|
|
|
252
252
|
### Canaux — qui vit, qui écoute
|
|
253
253
|
|
|
254
|
-
| Champ | Ce qu'il dit
|
|
255
|
-
| ------------------------ |
|
|
256
|
-
| `channels[]` | Une entrée par canal **actif** (au moins un abonné), cf `IRealtimeChannelStat` (`
|
|
257
|
-
| `channels[].channel` | Le nom exact souscrit, **suffixe de cadence compris** (`nodefony:orm:health:2000`).
|
|
258
|
-
| `channels[].subscribers` | Abonnés locaux vivants. Instantané, pas un cumul.
|
|
259
|
-
| `channels[].messages` | Publications cumulées sur ce canal.
|
|
260
|
-
| `channelCount` | Nombre de canaux actifs sur ce processus.
|
|
254
|
+
| Champ | Ce qu'il dit |
|
|
255
|
+
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
256
|
+
| `channels[]` | Une entrée par canal **actif** (au moins un abonné), cf `IRealtimeChannelStat` (`nodefony/src/realtime/platformHealth.ts#IRealtimeChannelStat`). |
|
|
257
|
+
| `channels[].channel` | Le nom exact souscrit, **suffixe de cadence compris** (`nodefony:orm:health:2000`). |
|
|
258
|
+
| `channels[].subscribers` | Abonnés locaux vivants. Instantané, pas un cumul. |
|
|
259
|
+
| `channels[].messages` | Publications cumulées sur ce canal. |
|
|
260
|
+
| `channelCount` | Nombre de canaux actifs sur ce processus. |
|
|
261
261
|
|
|
262
262
|
### Fan-out — l'amplification réelle du broker
|
|
263
263
|
|
|
@@ -267,6 +267,8 @@ Le contrat est `IRealtimeProbe` (`IRealtimeProbe.ts#IRealtimeProbe`). Quatre fam
|
|
|
267
267
|
| `fanoutTotal` | Livraisons effectives (`publish` × abonnés). **Ce que ça a réellement coûté.** |
|
|
268
268
|
| `inboundTotal` | Frames poussées par les clients sur les canaux entrants, via `RealtimeHub.recordInbound()` (`RealtimeHub.ts#RealtimeHub.recordInbound`). |
|
|
269
269
|
| `ingressRejectedTotal` | Messages venus du **backplane** et jetés parce que leur canal n'est pas déclaré diffusable. |
|
|
270
|
+
| `sealRejectedTotal` | Messages venus du **backplane** et jetés parce que leur sceau ne se vérifie pas. `0` sans bus scellé. |
|
|
271
|
+
| `localOnlyTotal` | Publications restées sur ce serveur alors qu'un bus est branché (canal non diffusable). `0` sans bus. |
|
|
270
272
|
|
|
271
273
|
Le rapport `fanoutTotal / publishTotal` est la mesure la plus parlante de la page : c'est la **taille
|
|
272
274
|
moyenne d'un salon**. S'il vaut 1, chaque publication ne touche qu'une personne — un canal par client,
|
|
@@ -296,14 +298,68 @@ lectures possibles quand il grimpe :
|
|
|
296
298
|
Le canal visé n'est délibérément pas exposé : un compteur d'observabilité ne doit pas devenir un
|
|
297
299
|
moyen de sonder ce que le système accepte.
|
|
298
300
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
301
|
+
Un message **mal scellé** n'entre pas dans ce compteur : le transport l'écarte avant le hub, et le
|
|
302
|
+
compte à part dans `sealRejectedTotal` (relayé au même niveau de la sonde, et dans
|
|
303
|
+
`backplane.sealRejectedTotal`). Quand celui-là grimpe, trois lectures : une rotation du secret en
|
|
304
|
+
cours (des exemplaires n'ont pas encore le même), un producteur mal configuré, ou quelqu'un qui
|
|
305
|
+
écrit sur votre bus sans connaître le secret.
|
|
306
|
+
|
|
307
|
+
### Restés sur ce serveur — l'oubli de diffusion rendu visible
|
|
308
|
+
|
|
309
|
+
`localOnlyTotal` compte les publications restées sur **ce** serveur alors qu'un bus est branché :
|
|
310
|
+
leur canal n'est pas déclaré diffusable (`@RealtimeBroadcast`). Sans bus, il vaut toujours zéro —
|
|
311
|
+
la question ne se pose pas. Coût : un `++` sur un chemin déjà froid, aucune allocation.
|
|
312
|
+
|
|
313
|
+
Deux lectures quand il grimpe :
|
|
314
|
+
|
|
315
|
+
- **voulue** — un canal propre au serveur par nature : le forage de Studio, la barre de débogage,
|
|
316
|
+
la sonde d'un exemplaire ;
|
|
317
|
+
- **oubli** — un canal applicatif (`chat:`, `alertes:`) dont les abonnés des AUTRES serveurs ne
|
|
318
|
+
reçoivent rien, sans erreur. Ajouter `@RealtimeBroadcast("chat:")` sur son contrôleur.
|
|
319
|
+
|
|
320
|
+
Au démarrage, la ligne `realtime diffusion entre serveurs : …` liste les préfixes diffusés (le
|
|
321
|
+
canal personnel `user:` y figure d'office dès qu'un bus est branché) : tout ce qui n'y est pas
|
|
322
|
+
reste local.
|
|
323
|
+
|
|
324
|
+
### Admission — les refus par cause
|
|
325
|
+
|
|
326
|
+
Quatre compteurs monotones disent POURQUOI un abonné n'est pas entré, quand la cause n'est pas son
|
|
327
|
+
droit. Un refus de droit (`forbidden`) n'est pas compté ici : c'est le comportement normal d'un canal
|
|
328
|
+
gardé. Ce que comptent ceux-ci, c'est une infrastructure qui ne suit plus, ou une configuration
|
|
329
|
+
incomplète (`nodefony/src/realtime/platformHealth.ts#IRealtimeProbe`).
|
|
330
|
+
|
|
331
|
+
<!-- prettier-ignore -->
|
|
332
|
+
| Champ | Unité | Ce qu'il compte | Quand il monte |
|
|
333
|
+
| --- | --- | --- | --- |
|
|
334
|
+
| `authenticateTimedOutTotal` | une poignée de main | connexions fermées en `1013` faute d'authentification dans `authenticateTimeoutMs` | la base ou le service qui porte les identités ne répond plus ; les clients reculent puis reviennent |
|
|
335
|
+
| `allowTimedOutTotal` | un appel d'`allow` | règles d'admission restées sans réponse dans `allowTimeoutMs` ; l'abonné a reçu `unavailable` et réessaie | la base que lisent les règles ne suit plus ; 50 en 5 s ouvrent le coupe-circuit |
|
|
336
|
+
| `allowSaturatedTotal` | une demande | demandes rendues `unavailable` SANS appeler la règle : plafond `maxPendingAllowsTotal` atteint, coupe-circuit ouvert, ou délai épuisé dans la file d'une connexion | une tempête de reconnexion, ou une base lente qui fait déborder les places |
|
|
337
|
+
| `systemFloorDeniedTotal` | un abonnement | canaux de plateforme (`syslog:`, `orm:`…) refusés faute de module de sécurité chargé | une configuration incomplète autant qu'une tentative d'accès — dans les deux cas, quelque chose est à regarder |
|
|
338
|
+
|
|
339
|
+
Les trois premiers vont ensemble : `allowTimedOutTotal` qui monte puis `allowSaturatedTotal` qui
|
|
340
|
+
s'envole, c'est la base qui a cessé de répondre et le serveur qui se protège — la base reçoit au plus
|
|
341
|
+
`maxPendingAllowsTotal` appels à la fois, jamais une rafale proportionnelle au nombre de clients. Un
|
|
342
|
+
`WARNING` limité à un par fenêtre de 10 s accompagne chaque famille dans le journal. Les réglages et
|
|
343
|
+
leur raison : [`configuration.md`](./configuration.md) ; le comportement vu par l'abonné :
|
|
344
|
+
[`securite.md`](./securite.md).
|
|
345
|
+
|
|
346
|
+
### Trames écartées à l'entrée
|
|
347
|
+
|
|
348
|
+
Deux compteurs monotones disent combien de trames reçues n'ont pas pu être lues. Elles ne reçoivent
|
|
349
|
+
aucune réponse et la connexion reste ouverte : répondre à une trame illisible offrirait à un client
|
|
350
|
+
défectueux une boucle, et à un curieux un écho de ce que le décodeur accepte. Le journal en dit la
|
|
351
|
+
première de chaque connexion, jamais les suivantes.
|
|
352
|
+
|
|
353
|
+
<!-- prettier-ignore -->
|
|
354
|
+
| Champ | Unité | Ce qu'il compte | Quand il monte |
|
|
355
|
+
| --- | --- | --- | --- |
|
|
356
|
+
| `binaryDiscardedTotal` | une trame reçue | trames binaires reçues sur une connexion texte dont le contrôleur ne lit pas les octets bruts (`onRealtimeBinary`) | un client envoie un flux que personne n'attend, ou le lecteur a été oublié côté serveur |
|
|
357
|
+
| `malformedDiscardedTotal` | une trame reçue | trames illisibles pour le codec de la connexion : JSON invalide, CBOR tronqué | un client défectueux, ou quelqu'un qui sonde le décodeur |
|
|
302
358
|
|
|
303
359
|
### Connexions et volume
|
|
304
360
|
|
|
305
361
|
`connectionCount`, `bytesSentTotal`, `messagesSentTotal` — vivants pour la première, cumulés pour les
|
|
306
|
-
deux autres. Le débit en octets par seconde se dérive de deux photos.
|
|
362
|
+
deux autres, connexions fermées comprises. Le débit en octets par seconde se dérive de deux photos.
|
|
307
363
|
|
|
308
364
|
### Back-pressure — le risque numéro un
|
|
309
365
|
|
|
@@ -358,7 +414,7 @@ Trois champs n'apparaissent que lorsqu'ils ont un sens :
|
|
|
358
414
|
### La couche d'identité, au-dessus
|
|
359
415
|
|
|
360
416
|
`buildOwnHealth()` (`RealtimeAdminApi.ts#buildOwnHealth`) enrichit ce snapshot pur pour produire
|
|
361
|
-
`IRealtimeHealth` (`
|
|
417
|
+
`IRealtimeHealth` (`nodefony/src/realtime/platformHealth.ts#IRealtimeHealth`) : `instanceId`, la santé **process** du worker
|
|
362
418
|
(CPU, mémoire, boucle d'événements), et — si elles ont été branchées — la santé ORM et les compteurs
|
|
363
419
|
d'erreurs du journal. Ces trois derniers champs sont **additifs et optionnels** : un consommateur qui
|
|
364
420
|
ne les connaît pas les ignore.
|
|
@@ -475,10 +531,11 @@ mérite d'être connue :
|
|
|
475
531
|
| ---------------------------------------------------------------- | ----------- | -------------------------------------------------------------------------- |
|
|
476
532
|
| `publishTotal`, `fanoutTotal`, `connectionCount`, octets, frames | **somme** | Le pod fait la somme de ce que font ses workers. |
|
|
477
533
|
| `ingressRejectedTotal` | somme | Un refus reste un refus, quel que soit le worker qui l'a opposé. |
|
|
534
|
+
| `localOnlyTotal` | somme | Une publication restée locale l'est sur le worker qui l'a émise. |
|
|
478
535
|
| `backpressure.maxBufferedAmount` | **maximum** | La santé d'une flotte se juge sur son **pire** membre, pas sur sa moyenne. |
|
|
479
536
|
| `slowConsumers`, `drops` | somme | Ce sont des dénombrements. |
|
|
480
537
|
|
|
481
|
-
Le discriminant est le champ `cluster: true` de `IRealtimeClusterHealth` (`
|
|
538
|
+
Le discriminant est le champ `cluster: true` de `IRealtimeClusterHealth` (`nodefony/src/realtime/platformHealth.ts#IRealtimeClusterHealth`) :
|
|
482
539
|
un consommateur sait immédiatement s'il lit une vue pod ou une vue per-instance, et `instances[]`
|
|
483
540
|
garde le détail par worker pour le forage.
|
|
484
541
|
|
package/docs/protocole.md
CHANGED
|
@@ -274,6 +274,18 @@ production** (`welcomeEnv()`, `welcomeEnv.ts#welcomeEnv`) : c'est lui que porte
|
|
|
274
274
|
en développement. Émis par le contrôleur au handshake (`peer.notify("realtime:welcome")`,
|
|
275
275
|
`RealtimeController.ts#RealtimeController.onHandshake`). Il n'y a pas de champ `version`.
|
|
276
276
|
|
|
277
|
+
Un septième champ, **`codec`**, est réservé au codec de trame retenu pour les frames qui suivent
|
|
278
|
+
(`RealtimeEventMap.ts#IRealtimeWelcome.codec`, ADR-0022 §2) : un des sous-protocoles que le client a
|
|
279
|
+
offerts au handshake. **Absent, il vaut `nodefony.jsonrpc.v1`** — JSON-RPC texte —, et le codec ne se
|
|
280
|
+
déduit jamais de l'écho `Sec-WebSocket-Protocol`, que `ws` renvoie qu'il parle le sous-protocole ou
|
|
281
|
+
non. Le client sait l'offrir et l'adopter (`frameCodecNegotiation.ts#adoptFrameCodec`) ; le serveur le
|
|
282
|
+
choisit quand l'application active `codec: "cbor"`, et renvoie ce choix dans le handshake.
|
|
283
|
+
|
|
284
|
+
Un huitième, **`bytes`**, dit qu'une connexion restée en texte reçoit les octets sous la convention
|
|
285
|
+
`{"$bytes":"<base64url>"}` (`RealtimeEventMap.ts#IRealtimeWelcome.bytes`) : le serveur l'émet quand il a
|
|
286
|
+
activé un codec binaire que le client n'a pas pris, et le client relit alors des `Uint8Array` sans rien
|
|
287
|
+
importer. Absent : JSON natif. La valeur nomme la convention ; une valeur inconnue laisse le JSON natif.
|
|
288
|
+
|
|
277
289
|
## 🔌 Anatomie d'une frame, champ par champ
|
|
278
290
|
|
|
279
291
|
### Le tronc commun
|
|
@@ -282,7 +294,7 @@ en développement. Émis par le contrôleur au handshake (`peer.notify("realtime
|
|
|
282
294
|
{
|
|
283
295
|
"jsonrpc": "2.0", // signature de protocole — OBLIGATOIRE, sinon la frame est `invalid`
|
|
284
296
|
"method": "subscribe", // ce qu'on demande — sa PRÉSENCE fait de la frame un appel
|
|
285
|
-
"params": { "channel": "nodefony:orm:health" }, // la charge
|
|
297
|
+
"params": { "channel": "nodefony:orm:health" }, // la charge : objet ou tableau, jamais fiable côté serveur
|
|
286
298
|
"id": 42, // PRÉSENT = requête (réponse due) · ABSENT = notification
|
|
287
299
|
}
|
|
288
300
|
```
|
|
@@ -291,9 +303,25 @@ en développement. Émis par le contrôleur au handshake (`peer.notify("realtime
|
|
|
291
303
|
| --------- | ------------------------- | -------------------- | ----------------------------------------------------------------------------------------- |
|
|
292
304
|
| `jsonrpc` | oui, toujours | `"2.0"` littéral | Sans lui, `JsonRpcPeer.receive()` classe `invalid` (`JsonRpcPeer.ts#JsonRpcPeer.receive`) |
|
|
293
305
|
| `method` | sur un appel | chaîne | Décide de la nature ET de l'aiguillage (canal, action, verbe pub/sub) |
|
|
294
|
-
| `params` | non |
|
|
306
|
+
| `params` | non | objet ou tableau | Charge applicative. Vient du réseau : **jamais** digne de confiance |
|
|
295
307
|
| `id` | sur une requête / réponse | nombre **ou** chaîne | Corrèle l'aller et le retour |
|
|
296
308
|
|
|
309
|
+
> [!IMPORTANT]
|
|
310
|
+
> **La charge d'un canal est un objet ou un tableau, ou absente.** JSON-RPC 2.0 (§4.2) exige un
|
|
311
|
+
> `params` structuré : une bibliothèque JSON-RPC stricte, dans un autre langage, refuserait la trame
|
|
312
|
+
> d'un scalaire. `publish` lève donc une `TypeError` qui nomme le canal et le type reçu, à la ligne
|
|
313
|
+
> fautive, des deux côtés de la socket (`payload.ts#isRealtimePayload`) :
|
|
314
|
+
>
|
|
315
|
+
> ```ts
|
|
316
|
+
> const temperature = 42;
|
|
317
|
+
> hub.publish("capteur:temp", temperature); // TypeError — un scalaire
|
|
318
|
+
> hub.publish("capteur:temp", { value: temperature }); // conforme
|
|
319
|
+
> hub.publish("build:done"); // conforme : part sans `params`, arrive `undefined`
|
|
320
|
+
> ```
|
|
321
|
+
>
|
|
322
|
+
> Des octets restent admis. Une charge absente arrive `undefined` chez l'abonné, quel que soit le bus
|
|
323
|
+
> entre les serveurs, scellé ou non.
|
|
324
|
+
|
|
297
325
|
> [!IMPORTANT]
|
|
298
326
|
> Le pair accepte un `id` **chaîne** en entrée (conforme à la norme), mais il n'attribue jamais que
|
|
299
327
|
> des `id` **numériques** à ses propres appels sortants. Une réponse portant un `id` chaîne est donc
|
|
@@ -334,6 +362,60 @@ obtenue par la socket est **identique** à ce que rendrait la même route en RES
|
|
|
334
362
|
dans le `result` casserait cette égalité. Un pair qui ne connaît pas `meta` l'ignore — le champ est
|
|
335
363
|
rétro-compatible par construction.
|
|
336
364
|
|
|
365
|
+
### Des octets dans la charge — une frame comme les autres
|
|
366
|
+
|
|
367
|
+
**Une trame binaire routée est une notification JSON-RPC dont la charge est, ou contient, un
|
|
368
|
+
`Uint8Array` ; le codec dit seulement comment il la porte sur le fil** (ADR-0022 §3). Il n'existe
|
|
369
|
+
pas de seconde grammaire, ni d'en-tête d'octets maison du type `[n° de canal][charge]` : un tel
|
|
370
|
+
en-tête aurait demandé un second verrou et une seconde sonde, qui finissent toujours par diverger
|
|
371
|
+
du premier.
|
|
372
|
+
|
|
373
|
+
```ts
|
|
374
|
+
// côté serveur — la même publication, quel que soit le codec de chaque abonné
|
|
375
|
+
const frame = new Uint8Array([0x01, 0x02, 0x03]);
|
|
376
|
+
hub.publish("capteur:raw", { seq: 42, frame });
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
| Connexion de l'abonné | Ce qui passe sur le fil | Ce que reçoit `on("capteur:raw")` |
|
|
380
|
+
| --------------------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
|
381
|
+
| CBOR | trame binaire : `frame` en chaîne d'octets native (type majeur 2) | `{ seq: 42, frame: Uint8Array }` |
|
|
382
|
+
| texte, serveur en `codec: "cbor"` | trame texte : `"frame":{"$bytes":"AQID"}` (base64url) | `{ seq: 42, frame: Uint8Array }` |
|
|
383
|
+
| texte, serveur en `codec: "json"` | trame texte, sans convention d'octets : `"frame":{"0":1,"1":2,"2":3}` | un objet déformé, plus un `Uint8Array` (`jsonFrameCodec.ts#JSON_FRAME_CODEC`) — d'où `codec: "cbor"` dès que des octets circulent |
|
|
384
|
+
|
|
385
|
+
Parce que c'est la même frame, elle passe par **les mêmes portes** que le texte : verrou de frame,
|
|
386
|
+
politique de canal, audit des refus, sonde du hub ([sécurité](./securite.md)). Le codec se négocie
|
|
387
|
+
au handshake et s'annonce dans l'accueil (champs `codec` et `bytes`, voir plus haut) ; il se règle
|
|
388
|
+
côté serveur par la clé [`codec`](./configuration.md#codec--la-forme-des-frames-sur-le-fil), côté
|
|
389
|
+
navigateur par l'option `codec` de la socket.
|
|
390
|
+
|
|
391
|
+
**La règle du `$bytes` forgé.** Sur une connexion texte, le récepteur relit en octets tout objet
|
|
392
|
+
dont la **seule** clé est `$bytes` et dont la valeur est une chaîne. Un objet applicatif qui a
|
|
393
|
+
exactement cette forme sans venir d'octets changerait donc de nature selon le codec de l'abonné :
|
|
394
|
+
objet pour un client CBOR, octets pour un client texte. L'encodeur texte le **refuse** plutôt que
|
|
395
|
+
d'envoyer une donnée qui serait mal relue (`jsonFrameCodec.ts#JSON_BYTES_FRAME_CODEC`) :
|
|
396
|
+
|
|
397
|
+
```ts ignore
|
|
398
|
+
socket.publish("prix", { montant: { $bytes: "12 euros" } });
|
|
399
|
+
// TypeError : codec JSON : objet {"$bytes": …} non issu d'octets sous la clé "montant"
|
|
400
|
+
// — le récepteur le relirait en octets ; envoyer un Uint8Array, ou ajouter une autre clé à l'objet
|
|
401
|
+
|
|
402
|
+
socket.publish("prix", { montant: { $bytes: "12 euros", devise: "EUR" } }); // conforme : deux clés
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
- **Côté navigateur**, l'émission lève à la ligne fautive.
|
|
406
|
+
- **Côté serveur**, une publication diffusée qui contient un tel objet n'atteint pas les abonnés
|
|
407
|
+
texte ; une seule ligne `ERROR` est écrite par publication, pas une par abonné
|
|
408
|
+
(`RealtimeHub.ts#ChannelFrame.claimFailure`). La clé citée est tronquée à 64 caractères et
|
|
409
|
+
échappée, pour qu'un client ne puisse pas forger de ligne de journal (CWE-117).
|
|
410
|
+
- **Limite assumée** : un tel objet **conservé** par l'application, par exemple l'état initial
|
|
411
|
+
qu'un canal renvoie à chaque nouvel abonné, sera refusé aux abonnés texte **à chaque envoi**.
|
|
412
|
+
Renommer la clé ou en ajouter une seconde règle le cas une fois pour toutes.
|
|
413
|
+
|
|
414
|
+
Le refus est préféré à un échappement (envelopper l'objet dans une seconde clé réservée) : échapper
|
|
415
|
+
coûterait un parcours de chaque trame, et imposerait une seconde convention aux deux côtés du fil.
|
|
416
|
+
Le refus ne coûte qu'une recherche de `$bytes` dans le texte déjà produit. Il ne vaut que sous la
|
|
417
|
+
convention d'octets : avec `codec: "json"`, `$bytes` est une clé comme une autre.
|
|
418
|
+
|
|
337
419
|
## 🧰 Les méthodes nominales
|
|
338
420
|
|
|
339
421
|
Ce que le serveur comprend **sans qu'aucun contrôleur ne l'ait déclaré**, plus les deux formes
|
|
@@ -381,7 +463,7 @@ sequenceDiagram
|
|
|
381
463
|
participant P as JsonRpcPeer (serveur)
|
|
382
464
|
participant H as RealtimeHub
|
|
383
465
|
C->>P: handshake WebSocket
|
|
384
|
-
P-->>C: realtime:welcome — ts, protocol, channels, methods, identity
|
|
466
|
+
P-->>C: realtime:welcome — ts, protocol, channels, methods, identity, codec?, bytes?
|
|
385
467
|
C->>P: subscribe — params.channel = "nodefony:orm:health"
|
|
386
468
|
Note over P,H: le hub crée le producteur au 1er abonné
|
|
387
469
|
P-->>C: nodefony:orm:health — params { queries: 42 }
|
|
@@ -443,7 +525,7 @@ qui alimente le journal d'audit avec le pair concerné.
|
|
|
443
525
|
### Le refus d'une notification n'est pas une erreur
|
|
444
526
|
|
|
445
527
|
C'est la subtilité la plus importante de la page. Le verrou de frame s'applique aux requêtes **et**
|
|
446
|
-
aux notifications (`beforeDispatch`, `JsonRpcPeer.ts#
|
|
528
|
+
aux notifications (`beforeDispatch`, `JsonRpcPeer.ts#IJsonRpcPeerHooks.beforeDispatch`), mais leurs conséquences diffèrent
|
|
447
529
|
radicalement :
|
|
448
530
|
|
|
449
531
|
| La frame refusée est… | Ce qui part | Pourquoi |
|
|
@@ -453,11 +535,25 @@ radicalement :
|
|
|
453
535
|
|
|
454
536
|
`IRealtimeDenied` (`RealtimeEventMap.ts#IRealtimeDenied`) porte `channel` et `reason` — plus un `detail` optionnel, posé hors production seulement — et le motif
|
|
455
537
|
est **générique**. Jamais « il te manque `ROLE_ADMIN` » : ce serait un oracle d'autorisation, un
|
|
456
|
-
attaquant y lirait la carte des droits.
|
|
457
|
-
`
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
538
|
+
attaquant y lirait la carte des droits. Quatre motifs circulent, et l'union est FERMÉE
|
|
539
|
+
(`RealtimeEventMap.ts#RealtimeDeniedReason`) — un motif inconnu reçu d'un serveur plus récent est
|
|
540
|
+
replié sur `forbidden` :
|
|
541
|
+
|
|
542
|
+
<!-- prettier-ignore -->
|
|
543
|
+
| Motif | Sens | Le client réessaie ? |
|
|
544
|
+
| --- | --- | --- |
|
|
545
|
+
| `forbidden` | le verrou ou la règle d'admission `allow` a dit non | jamais — le droit ne changera pas en réessayant |
|
|
546
|
+
| `limit` | le plafond de canaux de la connexion est atteint (`RealtimeController.ts#RealtimeController.onRealtimeNotification`) | jamais |
|
|
547
|
+
| `unknown` | aucun producteur ne sert ce nom — jamais un oracle : un canal gardé rend `forbidden`, qu'il existe ou non | jamais |
|
|
548
|
+
| `unavailable` | la règle `allow` n'a pas répondu à temps, ou le serveur protège sa base (plafond, coupe-circuit) | **oui**, le seul : trois fois au plus |
|
|
549
|
+
|
|
550
|
+
`unavailable` peut porter `retryAfterMs` : le délai, en millisecondes, pendant lequel le serveur
|
|
551
|
+
n'appellera plus la règle (coupe-circuit ouvert). Le client attend ce délai, puis ajoute un recul
|
|
552
|
+
exponentiel à **gigue complète** — un tirage uniforme entre zéro et 1 s, 2 s, puis 4 s — pour que
|
|
553
|
+
mille clients refusés au même instant ne reviennent pas au même instant. Il ne rejoue que le
|
|
554
|
+
`subscribe` concerné, jamais la connexion. Côté client,
|
|
555
|
+
`onDenied()` (`NodefonySocket.ts#NodefonySocket.onDenied`) branche un handler dessus ; il ne voit que le
|
|
556
|
+
refus DÉFINITIF, après le dernier réessai.
|
|
461
557
|
|
|
462
558
|
### L'abonnement accepté s'acquitte
|
|
463
559
|
|
|
@@ -481,6 +577,18 @@ antérieur — répute l'abonnement actif dès le rejeu, au lieu d'attendre pour
|
|
|
481
577
|
> n'est réservé par aucune norme. Le refus d'autorisation est `-32001` sur une requête, et une
|
|
482
578
|
> notification `realtime:denied` sur une notification.
|
|
483
579
|
|
|
580
|
+
### La fermeture qui dit « reviens plus tard »
|
|
581
|
+
|
|
582
|
+
Une frame refusée laisse la connexion ouverte ; certaines causes, elles, la ferment. Le code de
|
|
583
|
+
fermeture dit alors ce que le client doit faire (`closeCodes.ts`, le registre complet est dans
|
|
584
|
+
[`securite.md`](./securite.md)). Un seul est une invitation à revenir : `1013` « try again later »
|
|
585
|
+
(RFC 6455 §7.4.1). Le serveur l'émet quand l'authentification n'a pas répondu à temps à la poignée
|
|
586
|
+
de main (`authenticateTimeoutMs`), ou quand le client ne vide plus sa file d'envoi. Le client le
|
|
587
|
+
range parmi les fermetures reconnectables (`notice.ts#isReconnectableCloseCode`) : il recule, puis
|
|
588
|
+
rouvre une connexion neuve, rejoue son `welcome` et ses abonnements. Un navigateur ne peut pas
|
|
589
|
+
émettre `1013` lui-même (`WebSocket.close()` n'accepte que `1000` et `3000`-`4999`) : c'est
|
|
590
|
+
toujours le serveur qui ferme.
|
|
591
|
+
|
|
484
592
|
## 🔐 Ce que le protocole ne décide pas
|
|
485
593
|
|
|
486
594
|
Le protocole transporte et classe. Il ne décide ni de l'identité, ni des droits — trois règles qui
|
|
@@ -501,21 +609,22 @@ décrits dans [la page sécurité](./securite.md).
|
|
|
501
609
|
|
|
502
610
|
## ⚠️ Pièges
|
|
503
611
|
|
|
504
|
-
| Symptôme | Cause
|
|
505
|
-
| ---------------------------------------------------------------- |
|
|
506
|
-
| `subscribe` répond `-32601 method not found` | Envoyé **avec un `id`** : classé requête, or c'est une notification (`RealtimeController.ts#RealtimeController.onHandshake`)
|
|
507
|
-
| Le handler passé à `subscribe` n'est jamais appelé | `NodefonySocket.subscribe()` prend **un seul** argument (`NodefonySocket.ts#NodefonySocket.subscribe`)
|
|
508
|
-
| `request()` expire immédiatement, ou ignore le délai | Signature **positionnelle** `(méthode, params, ms)` (`NodefonySocket.ts#NodefonySocket.request`) — un objet d'options n'est pas lu
|
|
509
|
-
| Un tableau de frames n'obtient aucune réponse | Le batch n'est pas implémenté : un tableau n'a pas de `jsonrpc` → `invalid` (`jsonrpc/index.ts#classifyJsonRpcFrame`)
|
|
510
|
-
| Une frame malformée ne renvoie **aucune** erreur | Ni `-32700` ni `-32600` ne sont émis — silence + audit (`JsonRpcPeer.ts#JsonRpcPeer.receive`)
|
|
511
|
-
| Une trame binaire envoyée au client n'arrive à aucun handler |
|
|
512
|
-
| Une trame binaire envoyée au serveur n'arrive à aucune action | Même règle côté serveur : écartée, comptée (`WsConnectionTransport.ts#WsConnectionTransport.binaryDiscarded`), journalisée une fois en WARNING
|
|
513
|
-
|
|
|
514
|
-
|
|
|
515
|
-
|
|
|
516
|
-
|
|
|
517
|
-
|
|
|
518
|
-
|
|
|
612
|
+
| Symptôme | Cause | Correction |
|
|
613
|
+
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
614
|
+
| `subscribe` répond `-32601 method not found` | Envoyé **avec un `id`** : classé requête, or c'est une notification (`RealtimeController.ts#RealtimeController.onHandshake`) | L'émettre sans `id` — `socket.subscribe(canal)` |
|
|
615
|
+
| Le handler passé à `subscribe` n'est jamais appelé | `NodefonySocket.subscribe()` prend **un seul** argument (`NodefonySocket.ts#NodefonySocket.subscribe`) | `subscribe(canal)` **et** `on(canal, handler)`, deux gestes distincts |
|
|
616
|
+
| `request()` expire immédiatement, ou ignore le délai | Signature **positionnelle** `(méthode, params, ms)` (`NodefonySocket.ts#NodefonySocket.request`) — un objet d'options n'est pas lu | `request(m, p, 5000)` ; le défaut est 30 000 ms |
|
|
617
|
+
| Un tableau de frames n'obtient aucune réponse | Le batch n'est pas implémenté : un tableau n'a pas de `jsonrpc` → `invalid` (`jsonrpc/index.ts#classifyJsonRpcFrame`) | Une frame = un objet ; le multiplexage remplace le batch |
|
|
618
|
+
| Une frame malformée ne renvoie **aucune** erreur | Ni `-32700` ni `-32600` ne sont émis — silence + audit (`JsonRpcPeer.ts#JsonRpcPeer.receive`) | Lire le motif `invalid` côté serveur, pas la réponse |
|
|
619
|
+
| Une trame binaire envoyée au client n'arrive à aucun handler | Sur une connexion JSON-RPC texte, une trame binaire n'a pas de canal : le client l'écarte, la compte et émet une notice `-32700` (`NodefonySocket.ts#NodefonySocket.binaryFramesDiscarded`) | Un flux binaire se lit par `onBinary()` (`NodefonySocket.ts#NodefonySocket.onBinary`), hors JSON-RPC ; des octets routés par canal passent par un codec binaire (`codec`) |
|
|
620
|
+
| Une trame binaire envoyée au serveur n'arrive à aucune action | Même règle côté serveur : écartée, comptée (`WsConnectionTransport.ts#WsConnectionTransport.binaryDiscarded`), journalisée une fois en WARNING | Des octets **par canal** : `codec: "cbor"` et `publish`. Un protocole binaire propre à l'application : surcharger `onRealtimeBinary()` (`RealtimeController.ts#RealtimeController.onRealtimeBinary`), émettre par `sendBinaryToClient()` — ⚠️ hors du verrou de frame et des gardes d'action et de canal : toute décision d'accès sur ces octets appartient à l'application |
|
|
621
|
+
| `TypeError : codec JSON : objet {"$bytes": …} non issu d'octets` | Un objet applicatif a la forme exacte du marqueur d'octets ; un client texte le relirait en octets (`jsonFrameCodec.ts#JSON_BYTES_FRAME_CODEC`) | Envoyer un vrai `Uint8Array`, renommer la clé, ou en ajouter une seconde — [la règle](#des-octets-dans-la-charge--une-frame-comme-les-autres) |
|
|
622
|
+
| L'exception du serveur n'arrive jamais au client | Zero Trust : tout throw ordinaire devient `-32603` générique (`JsonRpcPeer.ts#JsonRpcPeer.handleRequest`) | Lever une `RpcError` pour exposer volontairement code et `data` |
|
|
623
|
+
| Une notification refusée disparaît sans trace côté client | Sans `id`, aucune réponse possible (`beforeDispatch`, `JsonRpcPeer.ts#IJsonRpcPeerHooks.beforeDispatch`) | Écouter `realtime:denied` via `onDenied()` (`NodefonySocket.ts#NodefonySocket.onDenied`) |
|
|
624
|
+
| `nodefony:kernel:ping` répond `-32601` sur mon endpoint | L'action `nodefony:kernel:ping` est déclarée par `@nodefony/studio` (`StudioRealtimeController.ts#StudioRealtimeController.realtimeActions`) | Déclarer la sienne, ou lire `serverMethods` avant d'appeler |
|
|
625
|
+
| Le battement de cœur ne renvoie aucun pong | La notification `ping` est un no-op serveur (`RealtimeController.ts#RealtimeController.onHandshake`) | Pour mesurer un RTT, utiliser une action RPC — `ping()` (`NodefonySocket.ts#NodefonySocket.ping`) |
|
|
626
|
+
| Une réponse reçue est ignorée sans message | Corrélation sur `id` **numériques** seulement (`JsonRpcPeer.ts#JsonRpcPeer.handleResponse`) | Ne pas fabriquer soi-même de réponse à `id` chaîne |
|
|
627
|
+
| 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à |
|
|
519
628
|
|
|
520
629
|
## 🧪 Tests & couverture
|
|
521
630
|
|