@nodefony/devkit 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/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorateParam.js +1 -1
- package/dist/index.js +3 -3
- package/dist/nodefony/controllers/DevkitController.js +1 -1
- package/dist/nodefony/controllers/McpController.js +2 -2
- package/dist/nodefony/controllers/ProfileSessionController.js +1 -1
- package/dist/nodefony/mcp/profileReport.js +1 -1
- package/dist/nodefony/mcp/profileRoute.js +28 -9
- package/dist/nodefony/service/DevkitService.js +4 -4
- package/docs/index.md +1 -1
- package/package.json +12 -12
- package/skills/nodefony-add-crud/SKILL.md +43 -24
- package/skills/nodefony-add-realtime-channel/SKILL.md +376 -111
- package/skills/nodefony-add-realtime-channel/references/binaire.md +191 -0
- package/skills/nodefony-add-realtime-channel/references/production.md +121 -0
- package/skills/nodefony-browser/SKILL.md +27 -2
- package/skills/nodefony-browser/references/sondes.md +13 -1
- package/skills/nodefony-browser/scripts/audit.mjs +12 -0
- package/skills/nodefony-browser/scripts/inspect.mjs +94 -88
- package/skills/nodefony-browser/scripts/lib/actions.mjs +113 -0
- package/skills/nodefony-browser/scripts/lib/browser.mjs +50 -11
- package/skills/nodefony-browser/scripts/lib/probes.mjs +56 -0
- package/skills/nodefony-browser/scripts/watch.mjs +27 -11
- package/skills/nodefony-dev/SKILL.md +11 -0
- package/skills/nodefony-dev/references/rapport-de-build.md +19 -0
- package/skills/nodefony-devops/SKILL.md +28 -21
- package/skills/nodefony-migrate-schema/SKILL.md +110 -20
- package/skills/nodefony-migrate-schema/references/verdicts.md +22 -20
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# Des octets dans le temps réel — mode d'emploi complet
|
|
2
|
+
|
|
3
|
+
> Chargé depuis `SKILL.md` dès qu'une charge contient des octets (`Uint8Array`, `Buffer`, CBOR,
|
|
4
|
+
> `$bytes`). Tout ce qui suit se vérifie dans la documentation installée :
|
|
5
|
+
> `node_modules/@nodefony/realtime/docs/protocole.md` (section « Des octets dans la charge »),
|
|
6
|
+
> `node_modules/@nodefony/realtime/docs/configuration.md` (clé `codec`),
|
|
7
|
+
> `node_modules/nodefony/docs/client.md` (« Choisir le codec de trame »),
|
|
8
|
+
> `node_modules/nodefony/docs/cbor.md` (le codec lui-même).
|
|
9
|
+
|
|
10
|
+
## Sommaire
|
|
11
|
+
|
|
12
|
+
1. [Le modèle](#1-le-modèle--une-frame-comme-les-autres)
|
|
13
|
+
2. [Mise en place](#2-mise-en-place--deux-lignes-une-par-côté)
|
|
14
|
+
3. [Publier et recevoir](#3-publier-et-recevoir)
|
|
15
|
+
4. [Canal ou flux brut ?](#4-canal-ou-flux-brut-)
|
|
16
|
+
5. [Lire des octets sans se tromper](#5-lire-des-octets-sans-se-tromper)
|
|
17
|
+
6. [Plusieurs serveurs](#6-plusieurs-serveurs)
|
|
18
|
+
7. [Chaque message, sa cause, ton geste](#7-chaque-message-sa-cause-ton-geste)
|
|
19
|
+
8. [Ce qui arrive SANS message](#8-ce-qui-arrive-sans-message)
|
|
20
|
+
9. [Prouver que ça marche](#9-prouver-que-ça-marche)
|
|
21
|
+
|
|
22
|
+
## 1. Le modèle — une frame comme les autres
|
|
23
|
+
|
|
24
|
+
- Une trame qui porte des octets est une **notification JSON-RPC ordinaire** dont la charge est, ou
|
|
25
|
+
contient, un `Uint8Array`. Il n'y a pas de seconde grammaire.
|
|
26
|
+
- Le **codec** ne dit que la forme sur le fil : en CBOR, les octets voyagent tels quels ; sur une
|
|
27
|
+
connexion restée en texte, ils voyagent sous la convention `{"$bytes":"<base64url>"}`, que le
|
|
28
|
+
client relit en `Uint8Array` sans rien importer.
|
|
29
|
+
- Le codec se **négocie** par connexion : le navigateur l'offre, le serveur choisit et l'annonce
|
|
30
|
+
dans `realtime:welcome`. Un client qui ne l'offre pas reste en texte — rien ne casse.
|
|
31
|
+
- Parce que c'est la même frame, les octets d'un canal passent par **les mêmes gardes** que le
|
|
32
|
+
texte : politique du canal, rôles, audit, sonde.
|
|
33
|
+
|
|
34
|
+
**N'invente jamais d'en-tête binaire maison** (`[n° de canal][charge]`) : il contournerait toutes
|
|
35
|
+
les gardes et serait à maintenir des deux côtés.
|
|
36
|
+
|
|
37
|
+
## 2. Mise en place — deux lignes, une par côté
|
|
38
|
+
|
|
39
|
+
Serveur (`nodefony.config.ts`) :
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
use("@nodefony/realtime", { codec: "cbor" });
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Navigateur — à la **PREMIÈRE** création de la socket partagée (souvent le noyau client) :
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { NodefonySocket } from "nodefony/client";
|
|
49
|
+
import { CBOR_FRAME_CODEC } from "nodefony/cbor";
|
|
50
|
+
|
|
51
|
+
const socket = NodefonySocket.shared({
|
|
52
|
+
url: "/api/live/realtime",
|
|
53
|
+
codec: CBOR_FRAME_CODEC,
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
| Serveur | Navigateur offre CBOR | Fil retenu | Octets reçus en |
|
|
58
|
+
| ------------------------ | --------------------- | ---------- | ---------------------------- |
|
|
59
|
+
| `codec: "cbor"` | oui | CBOR | `Uint8Array` |
|
|
60
|
+
| `codec: "cbor"` | non | texte | `Uint8Array` (via `$bytes`) |
|
|
61
|
+
| `codec: "json"` (défaut) | oui ou non | texte | 🔴 objet déformé `{"0":1,…}` |
|
|
62
|
+
|
|
63
|
+
D'où la règle : **dès que des octets circulent, le serveur passe en `codec: "cbor"`**. Côté
|
|
64
|
+
navigateur, offrir CBOR est un gain (≈ 30× plus rapide à recevoir 4 Ko d'octets, ~25 % d'octets en
|
|
65
|
+
moins), pas une obligation. Ce gain ne vaut QUE pour des octets : sur des objets ordinaires, CBOR
|
|
66
|
+
encode plus lentement que JSON, natif au moteur, et ne décode pas plus vite. N'offre le codec que si
|
|
67
|
+
des octets circulent.
|
|
68
|
+
|
|
69
|
+
## 3. Publier et recevoir
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
// serveur — depuis un service, une tâche, un minuteur
|
|
73
|
+
import { getRealtimeHub } from "@nodefony/realtime";
|
|
74
|
+
getRealtimeHub().publish("capteur:raw", {
|
|
75
|
+
seq: 42,
|
|
76
|
+
frame: new Uint8Array([1, 2, 3]),
|
|
77
|
+
});
|
|
78
|
+
getRealtimeHub().publish("capteur:raw", new Uint8Array([1, 2, 3])); // charge = les octets seuls
|
|
79
|
+
|
|
80
|
+
// serveur — dans un RealtimeController, la fabrique du canal reçoit `publish`
|
|
81
|
+
// navigateur
|
|
82
|
+
socket.subscribe("capteur:raw");
|
|
83
|
+
socket.on("capteur:raw", (charge) => {
|
|
84
|
+
/* charge.frame est un Uint8Array */
|
|
85
|
+
});
|
|
86
|
+
socket.publish("doc:42", { maj: new Uint8Array(update) }); // canal entrant déclaré côté serveur
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
- `subscribe(canal)` prend **un seul argument** ; le handler se pose par `on(canal, handler)`.
|
|
90
|
+
- Une charge est un objet, un tableau, des octets, ou absente — jamais un scalaire
|
|
91
|
+
(`publish("c", 42)` lève une `TypeError` qui nomme le canal).
|
|
92
|
+
- Un `Buffer` de Node est un `Uint8Array` : il part en octets, jamais en `{type:"Buffer",data:[…]}`.
|
|
93
|
+
- Une `Date` revient en chaîne et un `bigint` est refusé — exactement comme en JSON.
|
|
94
|
+
|
|
95
|
+
## 4. Canal ou flux brut ?
|
|
96
|
+
|
|
97
|
+
| Besoin | Voie |
|
|
98
|
+
| ------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
|
99
|
+
| Des octets qui appartiennent à un canal (cas normal) | `codec: "cbor"` + `publish` / `on(canal)` |
|
|
100
|
+
| Un protocole binaire entièrement propre à l'application | `onRealtimeBinary()` (serveur) / `onBinary()` (navigateur), connexion **texte** seulement |
|
|
101
|
+
|
|
102
|
+
🔴 **Le flux brut est HORS des gardes.** Une trame reçue par `onRealtimeBinary(data)` ne passe ni
|
|
103
|
+
par le verrou de frame ni par les politiques d'action et de canal. Seule la connexion a franchi
|
|
104
|
+
l'Origin, l'authentificateur et le pare-feu. Si tu l'emploies, **toute décision d'accès est à
|
|
105
|
+
toi** : lis l'identité par `RequestContext.getAuthenticatedUser()` et refuse explicitement.
|
|
106
|
+
|
|
107
|
+
🔴 **Le flux brut n'existe pas sur une connexion CBOR** : toute trame binaire y est une frame du
|
|
108
|
+
codec. `onRealtimeBinary()` n'y est jamais appelée, `sendBinaryToClient()` et `sendBinary()` rendent
|
|
109
|
+
`false`. Ne mélange pas les deux voies sur une même application sans le savoir.
|
|
110
|
+
|
|
111
|
+
Dans le doute, **préfère un canal + `Uint8Array`** (§3) : même politique, mêmes gardes, même bus
|
|
112
|
+
entre serveurs — le flux brut ne se justifie que pour un protocole binaire qui n'a pas de canal.
|
|
113
|
+
|
|
114
|
+
## 5. Lire des octets sans se tromper
|
|
115
|
+
|
|
116
|
+
- **`DataView` : passe la tranche entière.** `new DataView(data.buffer, data.byteOffset,
|
|
117
|
+
data.byteLength)`. Un `Buffer` de Node ne commence pas à 0 dans son tampon : `new
|
|
118
|
+
DataView(data.buffer)` lit des octets qui ne sont pas les tiens.
|
|
119
|
+
- Côté serveur, `onRealtimeBinary` reçoit un `Buffer` de `ws` typé `Uint8Array` : écris ton
|
|
120
|
+
décodeur sur `Uint8Array`, il servira tel quel dans le navigateur.
|
|
121
|
+
- Si tu appelles toi-même `decodeCbor()` sur un tampon que tu réutilises ensuite, **copie** les
|
|
122
|
+
octets décodés à garder (`slice()`) : ce sont des vues sur ce tampon.
|
|
123
|
+
|
|
124
|
+
### 🔴 Le piège du `$bytes` forgé
|
|
125
|
+
|
|
126
|
+
Quand le serveur a `codec: "cbor"` et qu'une connexion est restée en texte (la convention d'octets
|
|
127
|
+
y est armée), un objet de la forme **exacte** `{ $bytes: "<chaîne>" }` (seule clé, valeur chaîne)
|
|
128
|
+
est réservé aux octets. L'encodeur **refuse** d'en envoyer un qui ne vient pas
|
|
129
|
+
d'octets : un client texte le relirait en octets, un client CBOR en objet.
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
socket.publish("prix", { montant: { $bytes: "12 euros" } }); // 🔴 TypeError
|
|
133
|
+
socket.publish("prix", { montant: { $bytes: "12 euros", devise: "EUR" } }); // ✅ deux clés
|
|
134
|
+
socket.publish("prix", { montant: { raw: "12 euros" } }); // ✅ autre clé
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
- Navigateur : `emit` / `publish` **lèvent** à la ligne fautive.
|
|
138
|
+
- Serveur : la publication n'atteint pas les abonnés texte, une ligne `ERROR` par publication.
|
|
139
|
+
- Un tel objet **conservé** (état initial renvoyé à chaque nouvel abonné) est refusé **à chaque
|
|
140
|
+
envoi**. Renomme la clé une fois pour toutes. Ne contourne pas en encodant toi-même en base64.
|
|
141
|
+
- Avec `codec: "json"`, `$bytes` est une clé comme une autre : la règle ne s'applique pas.
|
|
142
|
+
|
|
143
|
+
## 6. Plusieurs serveurs
|
|
144
|
+
|
|
145
|
+
Les octets traversent le bus entre exemplaires (`redis`, `cluster`) **intacts**, scellés comme le
|
|
146
|
+
texte si `backplane.secret` est posé : rien à configurer de plus. Un canal suivi à la fois par des
|
|
147
|
+
connexions CBOR et texte paie un encodage par codec, pas un par abonné.
|
|
148
|
+
|
|
149
|
+
## 7. Chaque message, sa cause, ton geste
|
|
150
|
+
|
|
151
|
+
Les textes entre guillemets sont recopiés du code ; `<…>` marque une partie variable.
|
|
152
|
+
|
|
153
|
+
| Où | Message | Cause | Geste |
|
|
154
|
+
| -------------------------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- |
|
|
155
|
+
| exception (navigateur) / journal `ERROR` (serveur) | `codec JSON : objet {"$bytes": …} non issu d'octets` | objet applicatif de la forme exacte du marqueur, vers une connexion texte | envoyer un vrai `Uint8Array`, ou renommer / ajouter une clé (§5) |
|
|
156
|
+
| journal `ERROR` serveur | `rpc <action> : réponse non encodable` | une `@RealtimeAction` a rendu une valeur que le codec refuse (`$bytes` forgé, cycle, `bigint`) ; le client reçoit `-32603` générique | corriger la valeur rendue |
|
|
157
|
+
| exception | `Impossible d'encoder un BigInt — JSON.stringify le refuse aussi` | `bigint` dans la charge, sous CBOR | convertir en chaîne ou en nombre |
|
|
158
|
+
| exception | `Impossible d'encoder une structure circulaire — JSON.stringify la refuse aussi` | cycle dans la charge | aplatir la donnée |
|
|
159
|
+
| notice navigateur (`-32700`) | `Trame binaire écartée : en JSON-RPC texte elle n'a pas de canal` | le serveur écrit du binaire brut sur une connexion texte sans `onBinary()` | lire par `onBinary()`, ou passer par un canal + CBOR |
|
|
160
|
+
| notice navigateur (`-32700`) | `Trame illisible écartée : elle ne se décode pas en <protocole>` | trame que le codec ne décode pas | côté serveur : chercher qui écrit hors du codec |
|
|
161
|
+
| notice navigateur | `Codec « <protocole> » annoncé sans avoir été offert : la connexion reste en JSON-RPC texte` | serveur qui annonce un codec que ce client n'a pas offert | mettre client et serveur à la même version |
|
|
162
|
+
| notice navigateur | `Flux binaire brut indisponible sous <protocole>` | `onBinary()` / `sendBinary()` sur une connexion CBOR | publier les octets sur un canal (§4) |
|
|
163
|
+
| journal `WARNING` serveur, une fois par connexion | `WS realtime : trame binaire reçue et écartée` | binaire brut reçu sur une connexion texte, sans `onRealtimeBinary()` | passer par un canal + CBOR, ou surcharger `onRealtimeBinary()` (§4, hors gardes) |
|
|
164
|
+
| journal `WARNING` serveur, une fois par connexion | `WS realtime : sendBinaryToClient refusé` | `sendBinaryToClient()` sur une connexion CBOR | publier les octets sur un canal |
|
|
165
|
+
| journal `WARNING` serveur, une fois par nature | `WS realtime : trame binaire illisible pour <protocole> reçue et écartée` | trame CBOR malformée ou tronquée ; **aucune réponse** n'est renvoyée (pas d'oracle pour un fuzzer), connexion conservée | côté client : chercher qui écrit hors du codec |
|
|
166
|
+
| exception (`CborError`) | `CBOR refusé à l'octet <n> : <raison>` | `decodeCbor()` appelé par l'application sur des octets invalides ; `err.offset` dit où | valider la source ; ne jamais faire confiance à des octets reçus |
|
|
167
|
+
|
|
168
|
+
## 8. Ce qui arrive SANS message
|
|
169
|
+
|
|
170
|
+
- **Serveur resté en `codec: "json"`** : un `Uint8Array` part en `{"0":1,"1":2,…}` — aucune erreur.
|
|
171
|
+
C'est le piège n°1 : vérifier la clé `codec` avant tout autre diagnostic.
|
|
172
|
+
- **`codec` passé à un `shared()` qui n'est pas le premier** : l'instance existante revient,
|
|
173
|
+
réglages compris ; la connexion reste en texte (un avertissement hors production seulement).
|
|
174
|
+
- **`sendBinary()` sur un transport fermé** : rend `false`, compté dans `framesUnsent`, sans notice.
|
|
175
|
+
- **Codec non annoncé par l'accueil** : le client reste en texte, sans notice — c'est le cas
|
|
176
|
+
normal d'un serveur en `codec: "json"`.
|
|
177
|
+
- **Ne déduis jamais le codec de l'en-tête `Sec-WebSocket-Protocol`** renvoyé au handshake : la
|
|
178
|
+
bibliothèque WebSocket le renvoie qu'elle parle le sous-protocole ou non. Seul `socket.codec`
|
|
179
|
+
fait foi.
|
|
180
|
+
|
|
181
|
+
## 9. Prouver que ça marche
|
|
182
|
+
|
|
183
|
+
- `socket.codec` vaut `"nodefony.jsonrpc.cbor.v1"` une fois l'accueil reçu ; `"nodefony.jsonrpc.v1"`
|
|
184
|
+
= texte.
|
|
185
|
+
- Compteurs côté navigateur : `socket.binaryFramesDiscarded`, `socket.malformedFramesDiscarded`
|
|
186
|
+
doivent rester à 0.
|
|
187
|
+
- Un test qui le prouve publie un `Uint8Array` sur un canal et vérifie que l'abonné reçoit un
|
|
188
|
+
`Uint8Array` **identique** (`instanceof Uint8Array` et mêmes octets) — sous CBOR ET sous texte.
|
|
189
|
+
- Pour lire le fil à l'œil dans l'inspecteur réseau : `NF_REALTIME_CODEC=json` remet le serveur en
|
|
190
|
+
texte. Exception de débogage : la taille des trames et la contre-pression changent, aucune mesure
|
|
191
|
+
prise ainsi ne vaut pour la production.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Ce qui ne se voit qu'en production — plusieurs serveurs, réseau, révocation, charge
|
|
2
|
+
|
|
3
|
+
> Chargé depuis `SKILL.md` §8. Tout se vérifie dans la documentation installée :
|
|
4
|
+
> `node_modules/@nodefony/realtime/docs/configuration.md` (drivers, clés, variables),
|
|
5
|
+
> `node_modules/@nodefony/realtime/docs/securite.md` (codes de fermeture, `allow` lent),
|
|
6
|
+
> `node_modules/@nodefony/realtime/docs/cadence-et-contre-pression.md`,
|
|
7
|
+
> `node_modules/@nodefony/realtime/docs/canaux-a-membres.md` (gestes qui traversent les serveurs),
|
|
8
|
+
> `node_modules/@nodefony/realtime/docs/observabilite.md` (les compteurs qui accusent un oubli).
|
|
9
|
+
|
|
10
|
+
## 1. Plusieurs serveurs — rien ne traverse sans intention
|
|
11
|
+
|
|
12
|
+
| Déploiement | `backplane.driver` | Ce qui se passe sinon |
|
|
13
|
+
| ------------------------------------ | ----------------------------------- | ---------------------------------------------------------------------------------------------- |
|
|
14
|
+
| un seul processus | `loopback` | — |
|
|
15
|
+
| `nodefony cluster -w N`, une machine | `cluster` (défaut de l'app générée) | sans bus, un abonné sur le worker 2 ne reçoit pas ce que publie le worker 1 |
|
|
16
|
+
| plusieurs machines ou pods | `redis` (+ `@nodefony/redis`) | `loopback` à plusieurs répliques : la moitié des utilisateurs ne reçoit plus rien, sans erreur |
|
|
17
|
+
|
|
18
|
+
Un bus branché ne diffuse **que les canaux déclarés** : pose `@RealtimeBroadcast("chat:", "room:")`
|
|
19
|
+
sur la classe du contrôleur, **sur tous les serveurs** (le receveur aussi). Un préfixe couvre
|
|
20
|
+
`chat:`, `chat:room-42`, `chat:room-42:1000`. Le défaut est l'isolement : un canal d'observabilité
|
|
21
|
+
décrit SON serveur, il ne doit pas traverser. Le framework diffuse `user:` lui-même.
|
|
22
|
+
|
|
23
|
+
- L'oubli se voit : `localOnlyTotal` monte sur la sonde dès qu'un bus est branché et qu'un canal
|
|
24
|
+
non déclaré est publié ; les préfixes diffusés sont listés au démarrage.
|
|
25
|
+
- Le bus est un transport, pas un journal : un serveur absent 30 s ne rattrape rien. Redis
|
|
26
|
+
injoignable au démarrage : repli sur ce seul processus, WARNING, l'application démarre.
|
|
27
|
+
- **Secret du bus** : `NF_REALTIME_BACKPLANE_SECRET` (≥ 32 caractères), le même partout ; un
|
|
28
|
+
message non scellé est refusé et compté (`sealRejectedTotal`). Deux applications sur un même
|
|
29
|
+
Redis : `backplane.namespace` distinct — c'est une clé de sécurité, pas de confort.
|
|
30
|
+
- **Ce qui traverse, et ce qui ne traverse pas** : les publications des canaux diffusés, et deux
|
|
31
|
+
ordres qui ne font qu'enlever — `closeChannel(canal)` et `evictUser(canal, compte)`. Les gestes
|
|
32
|
+
par CONNEXION (`subscribeConnection`, `evictConnection`, `channelMembers`) sont locaux au
|
|
33
|
+
serveur qui tient la socket : appelle-les depuis une entrée client (`@RealtimeInbound`, qui
|
|
34
|
+
s'exécute par construction sur ce serveur), jamais depuis une requête HTTP routée au hasard.
|
|
35
|
+
- **Bus partagé = bus privé.** Un ordre d'éjection capté sur le bus peut être rejoué par qui y
|
|
36
|
+
écrit : l'effet reste une coupure (jamais une admission), et changer le secret rend caducs les
|
|
37
|
+
ordres captés. Risque écrit et accepté (ADR-0022 §6).
|
|
38
|
+
|
|
39
|
+
## 2. Reconnexion — ce qui est rejoué, ce qui ne l'est pas
|
|
40
|
+
|
|
41
|
+
- Le client rejoue **ses abonnements** au `realtime:welcome` (imposés compris), et chacun repasse
|
|
42
|
+
par la politique et par `allow`. D'où la règle : l'appartenance est écrite là où `allow` la lit
|
|
43
|
+
AVANT d'abonner, sinon le membre est refusé à la première coupure (sur mobile : dans la minute).
|
|
44
|
+
- Il ne rejoue **ni les messages publiés pendant la coupure, ni les requêtes en vol**. Une
|
|
45
|
+
discussion se reprend par une route (`GET /chat/history?since=<dernier id>`), dédoublonnée sur
|
|
46
|
+
l'identifiant persistant, triée sur l'identifiant croissant de la base — jamais une horloge.
|
|
47
|
+
- Un canal fermé par le serveur (`realtime:closed`, `ended` ou `evicted`) n'est pas rejoué. Un
|
|
48
|
+
rechargement de page, si : c'est `allow` qui tranche.
|
|
49
|
+
- `connected` ne veut pas dire abonné : `await socket.whenSubscribed(canal)` avant de charger
|
|
50
|
+
l'état initial, sinon un événement entre les deux est perdu.
|
|
51
|
+
|
|
52
|
+
## 3. Garanties — ce que le framework promet, et rien de plus
|
|
53
|
+
|
|
54
|
+
| Livraison | Ordre | Livraison | En cas de perte |
|
|
55
|
+
| --------- | ------------------------------------------------ | ---------------- | --------------------------------------------------------------------------- |
|
|
56
|
+
| socket | par flux et par émetteur ; jamais entre serveurs | au plus une fois | un trou, jamais un réordonnancement ; client qui ne lit plus fermé (`1013`) |
|
|
57
|
+
| flux SSE | par flux | au plus une fois | un trou ; la reprise appartient à l'action |
|
|
58
|
+
| bus | par émetteur | au plus une fois | un trou |
|
|
59
|
+
|
|
60
|
+
Une alarme machine qui ne doit pas se perdre se bâtit **au-dessus** : identifiant dans la charge,
|
|
61
|
+
accusé par une entrée client, réémission par l'application. Le socle ne stocke rien.
|
|
62
|
+
|
|
63
|
+
## 4. Révocation — une socket survit à sa session
|
|
64
|
+
|
|
65
|
+
Toutes les 30 s, chaque connexion authentifiée est revalidée. Les codes vivent dans un seul
|
|
66
|
+
registre, exporté par `nodefony` : **compare aux constantes, jamais aux nombres**.
|
|
67
|
+
|
|
68
|
+
| Code | Constante | Cause | Le client |
|
|
69
|
+
| ------ | ----------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------- |
|
|
70
|
+
| `4001` | `IDENTITY_DEAD_CLOSE_CODE` | session détruite ou expirée, jeton révoqué | s'arrête |
|
|
71
|
+
| `4003` | `ORIGIN_REFUSED_CLOSE_CODE` | origine refusée à la poignée de main | s'arrête |
|
|
72
|
+
| `4005` | `IDENTITY_REFRESH_CLOSE_CODE` | droits changés, identité vivante | revient aussitôt — lis `onIdentity()` pour rafraîchir l'écran |
|
|
73
|
+
| `4006` | `TOKEN_EXPIRED_CLOSE_CODE` | jeton PORTEUR expiré sur une socket ouverte | s'arrête : recrée la socket avec un jeton neuf AVANT `exp` |
|
|
74
|
+
| `4007` | `BACKPRESSURE_CLOSE_CODE` | émis par le client : sa file d'envoi ne se vide plus | se reconnecte |
|
|
75
|
+
| `1013` | `TRY_AGAIN_LATER_CLOSE_CODE` | serveur : client qui ne lit plus, ou authentification trop lente (10 s) | revient, avec recul |
|
|
76
|
+
|
|
77
|
+
- Un refus à la poignée de main est un **statut HTTP** (401/403) que le navigateur cache : la
|
|
78
|
+
socket sonde l'adresse en HTTP et traite 401/403/404 comme définitifs. Jamais de `4006` avant
|
|
79
|
+
authentification.
|
|
80
|
+
- Un membre retiré d'une salle reste abonné tant que sa connexion vit : l'exclusion immédiate, c'est
|
|
81
|
+
`evictUser` ; la révocation ferme la connexion entière.
|
|
82
|
+
|
|
83
|
+
## 5. Contre-pression et bornes — une règle, trois endroits
|
|
84
|
+
|
|
85
|
+
- **Serveur** (`@nodefony/http`, clés `websocket` ET `websocketSecure` — régler l'un ne règle pas
|
|
86
|
+
l'autre) : `maxBackpressure` 4 MiB, `backpressurePolicy: "drop"` (défaut) ou `"close"`,
|
|
87
|
+
`backpressureCloseAfterDrops` 1000 → fermeture `1013`. `drop` ne trie pas : une réponse RPC
|
|
88
|
+
peut partir aussi. Un flux où chaque message compte choisit `close`, ou un accusé applicatif.
|
|
89
|
+
- **Débit entrant** : `maxFramesPerSecond` (défaut `0` = désactivé), fermeture `1008`
|
|
90
|
+
sur un solde de refus — jamais au premier dépassement (une reconnexion qui rejoue ses
|
|
91
|
+
abonnements est une rafale légitime). Pour un jeu : `120` et `frameRateCloseAfterRejects: 1000`.
|
|
92
|
+
- **Navigateur** : `maxBufferedAmount` (désactivé par défaut) jette un coup périmé au lieu de
|
|
93
|
+
l'empiler, compté dans `framesDropped` ; `backpressureCloseAfterDrops` ferme en `4007`.
|
|
94
|
+
- **Cadence adaptative** : un canal d'ÉTAT se demande `base:<ms>` ; le serveur le sert par
|
|
95
|
+
`createRealtimeChannel` + `parseRate` (`nodefony`), le client par
|
|
96
|
+
`useNodefonyAdaptiveChannelData`. Jamais sur un canal d'événements.
|
|
97
|
+
- `slowConsumer.bytes` (`@nodefony/realtime`) compte, ne freine pas.
|
|
98
|
+
|
|
99
|
+
## 6. `allow` sous charge
|
|
100
|
+
|
|
101
|
+
- Rejeu au redémarrage : N clients × M canaux appels d'`allow` en rafale. Parade : une règle en
|
|
102
|
+
classe qui lit un registre partagé, ou un cache court côté application.
|
|
103
|
+
- `allowTimeoutMs` 5 s → `unavailable` (le client réessaie seul, 3 fois, gigue complète) ;
|
|
104
|
+
`maxPendingAllowsPerConnection` 8 (la demande attend son tour) ; `maxPendingAllowsTotal` 512
|
|
105
|
+
→ `unavailable` ; coupe-circuit 50 dépassements en 5 s. Compteurs : `allowTimedOutTotal`,
|
|
106
|
+
`allowSaturatedTotal`.
|
|
107
|
+
- Un `allow` qui lève = `forbidden` pour tous, sans un mot en production : une règle en classe
|
|
108
|
+
fait échouer le DÉMARRAGE si sa dépendance manque. Préfère-la dès qu'elle lit la base.
|
|
109
|
+
|
|
110
|
+
## 7. Lire la sonde avant d'accuser le code
|
|
111
|
+
|
|
112
|
+
`GET /nodefony/realtime/api/health` (rôle d'administration) ou le canal `nodefony:socket`.
|
|
113
|
+
|
|
114
|
+
| Signal | Lecture |
|
|
115
|
+
| ------------------------------------------------- | ------------------------------------------------------------------------ |
|
|
116
|
+
| `publishTotal` monte, `fanoutTotal` stagne | tu publies dans le vide : nom de canal différent entre serveur et client |
|
|
117
|
+
| `localOnlyTotal` > 0 sur un canal applicatif | `@RealtimeBroadcast` oublié |
|
|
118
|
+
| `ingressRejectedTotal`, `sealRejectedTotal` | quelqu'un d'autre écrit sur le bus, ou rotation du secret en cours |
|
|
119
|
+
| `backpressure.slowConsumers`, `drops` | un client ne suit pas ; la protection agit — ce n'est pas une panne |
|
|
120
|
+
| `allowTimedOutTotal`, `authenticateTimedOutTotal` | la base ne répond plus |
|
|
121
|
+
| `systemFloorDeniedTotal` | un client demande un canal de plateforme sans module de sécurité |
|
|
@@ -70,7 +70,10 @@ npx playwright install chromium
|
|
|
70
70
|
|
|
71
71
|
`NF_BROWSER_ENGINE=chrome` (ou `msedge`, `chromium`) impose un navigateur précis. Un choix explicite
|
|
72
72
|
n'est **jamais** complété par un repli : se rabattre en silence attribuerait la mesure au mauvais
|
|
73
|
-
moteur.
|
|
73
|
+
moteur. `NF_BROWSER_ENGINE=firefox` (ou `webkit`) change de MOTEUR — après
|
|
74
|
+
`npx playwright install firefox` : ce que dicte une norme (sécurité du contenu, rapports de
|
|
75
|
+
violation, cookies) diverge d'un moteur à l'autre, et Chromium seul n'en dit rien. L'audit
|
|
76
|
+
Lighthouse, lui, reste réservé à Chromium. ⚠️ À ne pas confondre avec `NF_BROWSER_CHANNEL`, qui désigne le canal d'un socket
|
|
74
77
|
applicatif — le mot « canal » sert aux deux dans des mondes différents.
|
|
75
78
|
|
|
76
79
|
## L'autre voie — en conteneur, et QUAND s'en servir
|
|
@@ -182,6 +185,15 @@ Réglages par variables d'environnement : `NF_BROWSER_BASE`, `NF_BROWSER_PAGE`,
|
|
|
182
185
|
(`libellé=sélecteur`, séparés par des virgules), `NF_BROWSER_FAMILIES`, `NF_BROWSER_WIDTHS`,
|
|
183
186
|
`NF_BROWSER_SEUIL_LOURD`, `NF_BROWSER_SEUIL_LENT`, `NF_BROWSER_ACTIONS`, `NF_BROWSER_FULLPAGE`. Le détail vit dans l'en-tête de chaque script.
|
|
184
187
|
|
|
188
|
+
**Lire un état que seule ton application connaît** (un store, l'instantané d'une socket) :
|
|
189
|
+
`NF_BROWSER_EVAL` prend une expression évaluée DANS la page après les actions et les mesures, et
|
|
190
|
+
rend son résultat dans le champ `evaluated` — ou `{ error }` si elle lève. Une forme fonction
|
|
191
|
+
(`async () => …`) est appelée, jamais seulement définie.
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
NF_BROWSER_EVAL='() => document.title' node scripts/inspect.mjs /tableau-de-bord
|
|
195
|
+
```
|
|
196
|
+
|
|
185
197
|
### Un écran qui n'existe qu'après un GESTE — `NF_BROWSER_ACTIONS`
|
|
186
198
|
|
|
187
199
|
Certaines pages ne sont pas un état mais un **parcours** : un formulaire qui ne déplie ses questions
|
|
@@ -196,7 +208,9 @@ Grammaire : `verbe:cible[=valeur]`, séquence séparée par `|`, verbe facultati
|
|
|
196
208
|
cible cherchée d'abord comme **texte visible** puis comme sélecteur CSS. Verbes : `clic` · `double`
|
|
197
209
|
· `droit` (clic droit) · `survol` · `saisir:Nom=valeur` · `choisir:Liste=valeur` (une option d'un
|
|
198
210
|
`<select>`, par sa valeur ou son libellé ; la cible peut être le libellé qui l'enveloppe) ·
|
|
199
|
-
`touche:Enter` · `voir` · `defiler:600` · `attendre
|
|
211
|
+
`touche:Enter` · `voir` · `defiler:600` · `attendre` · `connecter:` (se connecte dans un
|
|
212
|
+
AUTRE onglet, sans recharger la page mesurée — qui s'ouvre alors anonyme : une page déjà
|
|
213
|
+
ouverte voit-elle la session apparaître ?).
|
|
200
214
|
|
|
201
215
|
- 🔴 **`voir` plutôt qu'un clic pour amener dans la vue** : sur un formulaire, le texte d'une
|
|
202
216
|
question est un `label` — cliquer dessus coche la case qu'il décrit, et tu observes alors un écran
|
|
@@ -381,6 +395,17 @@ en bout : le message part-il, revient-il, et l'écran le reçoit-il ?
|
|
|
381
395
|
> lui, vaut pour toute attente que tu écriras : tant qu'elle n'a pas échoué une fois, elle ne
|
|
382
396
|
> discrimine rien.
|
|
383
397
|
|
|
398
|
+
### Voir la trame qu'un GESTE envoie — `NF_BROWSER_ACTIONS`
|
|
399
|
+
|
|
400
|
+
```bash
|
|
401
|
+
docker exec -e "NF_BROWSER_ACTIONS=Supprimer|Confirmer" mon-app-browser node /app/see-screen/watch.mjs /articles 4000
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
La même grammaire que pour `inspect.mjs`, jouée une fois la page ouverte, **pendant** l'observation :
|
|
405
|
+
la trame qu'un bouton envoie par la socket est enregistrée comme les autres. C'est la seule façon de
|
|
406
|
+
prouver qu'une écriture passe par la socket. Sans la trame, un client qui se replie sur HTTP en cas
|
|
407
|
+
d'échec affiche le même écran. Une cible introuvable arrête la sonde (code 65).
|
|
408
|
+
|
|
384
409
|
### Éprouver la REPRISE après une coupure réseau — `NF_BROWSER_CUT`
|
|
385
410
|
|
|
386
411
|
```bash
|
|
@@ -37,6 +37,11 @@ C'est au lecteur de juger — la sonde fournit, elle ne condamne pas ce qu'elle
|
|
|
37
37
|
Un sélecteur par élément (`NF_BROWSER_PROBES=libellé=sélecteur,…`) ; pour chacun : texte, couleur,
|
|
38
38
|
fond effectif, rapport de contraste, police, verdict WCAG, taille rendue.
|
|
39
39
|
|
|
40
|
+
- **Un widget en Shadow DOM est invisible à un sélecteur nu** — la sonde rend `absent`, et l'on
|
|
41
|
+
conclut à tort que l'élément n'existe pas. `hôte >>> sélecteur` entre dans la racine fantôme
|
|
42
|
+
OUVERTE de l'hôte (`#ma-barre >>> .bouton`), segment après segment. Le fond effectif traverse la
|
|
43
|
+
frontière de la même façon : au-dessus de la racine fantôme, c'est l'hôte qui fait fond.
|
|
44
|
+
|
|
40
45
|
- **Le fond effectif empile TOUTES les couches** jusqu'au premier ancêtre opaque, puis les compose.
|
|
41
46
|
Deux erreurs à ne pas refaire : lire `backgroundColor` sur l'élément rend `rgba(0,0,0,0)` et un
|
|
42
47
|
contraste faux ; s'arrêter à la première couche non transparente traite un voile à 13 % comme un
|
|
@@ -102,9 +107,16 @@ d'un `alt` restent un jugement humain.
|
|
|
102
107
|
## `rendu` — la page tient-elle dans son viewport, ses polices sont-elles là
|
|
103
108
|
|
|
104
109
|
- `horizontalOverflow` : la page dépasse-t-elle la largeur de la fenêtre (le défilement
|
|
105
|
-
horizontal accidentel).
|
|
110
|
+
horizontal accidentel). Il porte le verdict.
|
|
111
|
+
- `horizontalScrollers` : les conteneurs défilants (`overflow-x: auto|scroll`) dont le contenu
|
|
112
|
+
déborde — une barre de défilement horizontale DANS la page, que `horizontalOverflow` ne voit
|
|
113
|
+
pas. Il porte AUSSI le verdict. Vécu : une barre de débogage en Shadow DOM débordait de 232 px
|
|
114
|
+
pendant que la page entière se déclarait saine. Cause fréquente : une infobulle invisible
|
|
115
|
+
(`visibility: hidden`) ancrée à gauche sur un bouton de droite — invisible, elle élargit quand
|
|
116
|
+
même la zone défilable.
|
|
106
117
|
- `elementsOutsideViewport` : une **information**, pas un verdict — carrousels, tiroirs et textes
|
|
107
118
|
destinés aux lecteurs d'écran sortent du viewport légitimement.
|
|
119
|
+
- Les trois mesures descendent dans les **Shadow DOM ouverts**.
|
|
108
120
|
- `fonts` : ce que `document.fonts` a RÉELLEMENT chargé (statut par famille + graisse). Une
|
|
109
121
|
police en échec bascule le verdict — le texte s'affiche alors dans une police de repli, et toutes
|
|
110
122
|
les mesures de taille en héritent.
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
USER,
|
|
42
42
|
} from "./lib/browser.mjs";
|
|
43
43
|
import {
|
|
44
|
+
browserEngine,
|
|
44
45
|
browserOrder,
|
|
45
46
|
captureSlug,
|
|
46
47
|
colorSchemeLaunchArgs,
|
|
@@ -104,6 +105,17 @@ const profile = mkdtempSync(path.join(tmpdir(), "nf-audit-"));
|
|
|
104
105
|
// seul Chrome est installé — là même où `inspect.mjs` passait.
|
|
105
106
|
let ctx = null;
|
|
106
107
|
const failures = [];
|
|
108
|
+
// Lighthouse parle au navigateur par le protocole de débogage de Chromium :
|
|
109
|
+
// Firefox et WebKit ne l'exposent pas. Refuser en le disant, plutôt que de se
|
|
110
|
+
// rabattre sur Chromium et d'attribuer l'audit au moteur qu'on n'a pas lancé.
|
|
111
|
+
const wanted = (process.env.NF_BROWSER_ENGINE ?? "").trim();
|
|
112
|
+
if (wanted && browserEngine(wanted).engine !== "chromium") {
|
|
113
|
+
console.error(
|
|
114
|
+
`L'audit exige un navigateur de la famille Chromium (protocole de débogage) — « ${wanted} » n'en est pas un.\n` +
|
|
115
|
+
"Pour regarder la page sous ce moteur : inspect.mjs avec le même NF_BROWSER_ENGINE.",
|
|
116
|
+
);
|
|
117
|
+
process.exit(64); // EX_USAGE
|
|
118
|
+
}
|
|
107
119
|
for (const channel of browserOrder(process.env.NF_BROWSER_ENGINE)) {
|
|
108
120
|
try {
|
|
109
121
|
ctx = await chromium.launchPersistentContext(profile, {
|