@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
|
@@ -1,161 +1,426 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: nodefony-add-realtime-channel
|
|
3
|
+
metadata:
|
|
4
|
+
version: 2.1.1
|
|
3
5
|
description: >
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
6
|
+
Choisit et branche la brique temps réel que Nodefony fournit déjà — canal, entrée client,
|
|
7
|
+
action, canal personnel, canal à membres, flux SSE, contrat typé, octets — au lieu d'un
|
|
8
|
+
WebSocket, d'une liste noire ou d'un contrôle d'accès écrits à la main. Part du besoin (salon
|
|
9
|
+
privé avec exclusion, jeu, sondes, tableau de bord, notification à une personne, tâche longue)
|
|
10
|
+
et mène à la ligne à écrire, à l'exemple installé et à son test. Porte ce qui ne se voit qu'en
|
|
11
|
+
production : plusieurs serveurs, reconnexion, révocation, contre-pression. À charger AVANT
|
|
12
|
+
d'écrire un canal, un abonnement client ou un flux.
|
|
13
|
+
Déclencheurs : "temps réel", "websocket", "canal", "push", "notifications en direct",
|
|
14
|
+
"abonnement", "salon privé", "exclure un membre", "réserver un canal à un rôle", "mon canal
|
|
15
|
+
est public", "prévenir un utilisateur", "progression d'une tâche", "SSE", "EventSource",
|
|
16
|
+
"réponse en streaming", "contrat temps réel", "-32601", "CBOR",
|
|
17
|
+
"Uint8Array", "ça marche en local, plus en cluster", "l'exclu revient".
|
|
14
18
|
---
|
|
15
19
|
|
|
16
|
-
# add-realtime-channel — le temps réel par sa
|
|
20
|
+
# add-realtime-channel — le temps réel par sa brique
|
|
17
21
|
|
|
18
|
-
> 🧭 **
|
|
19
|
-
>
|
|
20
|
-
>
|
|
22
|
+
> 🧭 **Arrivé ici directement ? Charge aussi `nodefony-dev`** : il porte la conduite commune (par
|
|
23
|
+
> où commencer, comment prouver, les pièges du serveur et du front). Cette page ne couvre QUE le
|
|
24
|
+
> temps réel.
|
|
21
25
|
>
|
|
22
|
-
>
|
|
23
|
-
>
|
|
24
|
-
>
|
|
25
|
-
>
|
|
26
|
-
|
|
27
|
-
>
|
|
28
|
-
>
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
26
|
+
> Les pages de référence sont INSTALLÉES (`rg` ne descend pas dans `node_modules`). Une commande
|
|
27
|
+
> les trouve et rend leur chemin — **exécute-la, puis lis la page** :
|
|
28
|
+
>
|
|
29
|
+
> ```bash
|
|
30
|
+
> node node_modules/@nodefony/devkit/skills/nodefony-dev/scripts/docs.mjs --open cas-d-usage
|
|
31
|
+
> node node_modules/@nodefony/devkit/skills/nodefony-dev/scripts/docs.mjs salon allow evict
|
|
32
|
+
> ```
|
|
33
|
+
|
|
34
|
+
> ⚖️ **La confiance n'exclut pas le contrôle.** Un **canal** sans politique est public par
|
|
35
|
+
> construction ; une **action** sans politique est fermée (connexion authentifiée exigée). Ce qui
|
|
36
|
+
> n'est pas déclaré n'est pas gardé — et ce que le framework porte déjà ne se réécrit pas.
|
|
37
|
+
|
|
38
|
+
## 1. Quel besoin → quelle brique
|
|
39
|
+
|
|
40
|
+
Lis la ligne qui ressemble à ton besoin, écris ce qu'elle dit, puis ouvre la page citée. Pour
|
|
41
|
+
chaque cas, `cas-d-usage.md` montre trois fichiers — le serveur, le navigateur, le test —
|
|
42
|
+
exécutés par la suite du framework : **copie le test avec le serveur**.
|
|
43
|
+
|
|
44
|
+
| Je veux… | La brique (et rien d'autre) | Page installée |
|
|
45
|
+
| ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
|
|
46
|
+
| pousser un flux que des écrans lisent | `@RealtimeChannel("nom")` ; un seul producteur par canal et par serveur, créé au premier abonné | `cas-d-usage.md` (tableau de bord) |
|
|
47
|
+
| le réserver à un rôle | `@RealtimeChannel("nom", { roles: ["ROLE_X"] })` | `securite.md` |
|
|
48
|
+
| une famille de canaux (`room:<id>`, `task:<id>`) | motif `"room:*"` + `createRealtimeChannel()` qui sert (ou refuse, `null`) chaque nom | `securite.md` (nom exact vs dérivés) |
|
|
49
|
+
| **ce compte-là et lui seul** (salon privé, invitation) | `allow: (channel, who) => …` sur le motif — l'appartenance vit en base, `allow` la lit | `cas-d-usage.md` (salon privé) |
|
|
50
|
+
| exclure quelqu'un, pour de bon | retirer l'appartenance (`allow` dira non), PUIS `getRealtimeHub().evictUser(canal, id)` — jamais de liste noire | `canaux-a-membres.md` |
|
|
51
|
+
| placer quelqu'un dans un canal sans qu'il s'abonne | `getRealtimeHub().subscribeConnection(canal, connection)` depuis une entrée client, après avoir écrit l'appartenance | `cas-d-usage.md` (recherche de partie) |
|
|
52
|
+
| prévenir UNE personne, tous ses onglets, tous les serveurs | `getRealtimeHub().publishToUser(id, charge)` / `socket.onUser(h)` — rien à déclarer | `cas-d-usage.md` (notification) |
|
|
53
|
+
| suivre une tâche longue (agent, export) | le serveur nomme `task:<uuid>`, impose l'abonnement, publie, puis `closeChannel()` → `ended` | `cas-d-usage.md` (tâche d'agent) |
|
|
54
|
+
| recevoir ce que le client pousse (coups de jeu, mesures) | `@RealtimeInbound("game:move", politique)` — `params` non fiable, `connection.userId` = l'auteur | `cas-d-usage.md` (jeu, sondes) |
|
|
55
|
+
| un appel avec réponse (un devis, un historique) | `@RealtimeAction("x:y")` (fermée par défaut) — ou une ROUTE appelée par `socket.post()` si ça change un état | `actions.md` |
|
|
56
|
+
| le serveur pousse, le client écoute, sur UNE requête | `this.renderSse()` dans une action ordinaire ; `new NodefonySse(url)` côté client | `http/docs/sse.md` |
|
|
57
|
+
| typer les deux bouts une fois | `interface C extends IRealtimeContract` + `NodefonySocket.fromContract<C>()` + `ContractRealtimeController<C>` | `actions.md` (contrat) |
|
|
58
|
+
| des octets (document partagé, audio, image, mesures binaires) | `use("@nodefony/realtime", { codec: "cbor" })` et `Uint8Array` dans la charge — même canal, mêmes gardes | `references/binaire.md` |
|
|
59
|
+
| ça marche avec deux onglets, plus en cluster | `@RealtimeBroadcast("prefix:")` sur la classe (défaut : un canal reste sur son serveur) | `references/production.md` |
|
|
60
|
+
|
|
61
|
+
Les chemins complets : `node_modules/@nodefony/realtime/docs/<page>.md`,
|
|
62
|
+
`node_modules/@nodefony/http/docs/sse.md`, `node_modules/nodefony/docs/client.md`.
|
|
63
|
+
|
|
64
|
+
**Canal ou route ?** Le canal porte les FLUX (diffusés, éphémères, attribués par l'identité de la
|
|
65
|
+
connexion). Un GESTE qui change un état (envoyer un message conservé, payer, inviter) est une
|
|
66
|
+
**route** `POST` avec `@IsGranted` et `@Idempotent`, que le navigateur appelle par
|
|
67
|
+
`socket.post("/chemin", corps)` : identité revérifiée à chaque appel, validation, rejeu sans
|
|
68
|
+
doublon. Détail : `actions.md` (« Le pont API »).
|
|
69
|
+
|
|
70
|
+
## 2. Le geste
|
|
32
71
|
|
|
33
72
|
```bash
|
|
34
|
-
npx nodefony create controller
|
|
73
|
+
npx nodefony create controller Room --kind realtime
|
|
35
74
|
```
|
|
36
75
|
|
|
37
|
-
Produit une sous-classe de `RealtimeController
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
76
|
+
Produit une sous-classe de `RealtimeController`, avec un canal de démonstration, une action, une
|
|
77
|
+
entrée client, et **son test** (`createRealtimeHarness`). Nomme le contrôleur par son USAGE
|
|
78
|
+
(`RoomChat`, `Arena`), pas par une entité : `create entity Room` après `create controller Room`
|
|
79
|
+
est refusé (nom déjà référencé). Une application générée sert déjà `/api/live/realtime`
|
|
80
|
+
(`LiveController`, canal `live:events`) : c'est le canal public témoin, il doit continuer de
|
|
81
|
+
répondre après ta modification.
|
|
41
82
|
|
|
42
|
-
|
|
83
|
+
**Le client ouvre la socket du contrôleur qui DÉCLARE ses canaux** — `/api/room/realtime` pour
|
|
84
|
+
`create controller Room`. Une socket ne sert que les canaux de SON contrôleur : ouvert sur
|
|
85
|
+
`/api/live/realtime`, `room:42` est refusé (`unknown`). Pour une seule socket par page, regroupe
|
|
86
|
+
les canaux dans un même contrôleur ; seul `user:<id>` est fourni par toutes.
|
|
43
87
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
| La réponse d'UNE requête arrive par morceaux (modèle, export) | **SSE** sur cette requête |
|
|
48
|
-
| Échange dans les deux sens, canaux, état partagé entre onglets | **WebSocket** — le reste de cette page |
|
|
88
|
+
**N'écris ni `new WebSocket(...)` côté serveur, ni `ws.on(...)`, ni un `WebSocketServer`** : le
|
|
89
|
+
bas niveau existe, le framework l'emploie ; le reprendre te prive du routage par canal, de
|
|
90
|
+
l'identité partagée avec HTTP, de la reconnexion et de la contre-pression.
|
|
49
91
|
|
|
50
|
-
|
|
51
|
-
|
|
92
|
+
**Si tu retires ou renommes le canal de démonstration du contrôleur généré, adapte son test dans
|
|
93
|
+
la foulée** — ne laisse pas un banc rouge que tu finirais par ignorer.
|
|
94
|
+
|
|
95
|
+
## 3. Les quatre mots du serveur
|
|
52
96
|
|
|
53
97
|
```ts
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
98
|
+
import type { ContextType } from "@nodefony/http";
|
|
99
|
+
import {
|
|
100
|
+
RealtimeAction,
|
|
101
|
+
RealtimeChannel,
|
|
102
|
+
RealtimeController,
|
|
103
|
+
RealtimeInbound,
|
|
104
|
+
getRealtimeHub,
|
|
105
|
+
} from "@nodefony/realtime";
|
|
106
|
+
import type { IRealtimeConnection } from "@nodefony/realtime";
|
|
107
|
+
import { CurrentUser } from "@nodefony/framework";
|
|
108
|
+
import type { IUser } from "@nodefony/user";
|
|
109
|
+
|
|
110
|
+
export class OpsRealtime extends RealtimeController {
|
|
111
|
+
constructor(context: ContextType) {
|
|
112
|
+
super("OpsRealtime", context);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// CANAL (flux serveur → abonnés) : la fabrique rend le nettoyage. Un seul producteur
|
|
116
|
+
// par canal et par serveur, partagé entre connexions : ne capture jamais `this.context`.
|
|
117
|
+
@RealtimeChannel("ops:alerts", { roles: ["ROLE_ADMIN"] })
|
|
118
|
+
alerts(): () => void {
|
|
119
|
+
return () => {};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ENTRÉE (le client pousse, sans réponse) : `params` vient du réseau, valide-le ;
|
|
123
|
+
// l'auteur est `connection.userId`, jamais un champ de la charge.
|
|
124
|
+
@RealtimeInbound("ops:ack", { authenticated: true })
|
|
125
|
+
ack(params: unknown, _reply: unknown, connection: IRealtimeConnection): void {
|
|
126
|
+
if (connection.userId === null || !isAck(params)) return;
|
|
127
|
+
getRealtimeHub().publish("ops:alerts", {
|
|
128
|
+
acked: params.id,
|
|
129
|
+
by: connection.userId,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ACTION (appel avec réponse) : FERMÉE par défaut ; `@CurrentUser()` comme sur une route.
|
|
134
|
+
@RealtimeAction("ops:whoami")
|
|
135
|
+
whoami(@CurrentUser() user: IUser): { identifier: string } {
|
|
136
|
+
return { identifier: user.identifier };
|
|
137
|
+
}
|
|
59
138
|
}
|
|
60
|
-
```
|
|
61
139
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
140
|
+
function isAck(v: unknown): v is { id: string } {
|
|
141
|
+
return (
|
|
142
|
+
typeof v === "object" && v !== null && "id" in v && typeof v.id === "string"
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
```
|
|
68
146
|
|
|
69
|
-
|
|
147
|
+
- **Publier depuis n'importe où** (service, tâche, minuteur, boucle de jeu) :
|
|
148
|
+
`getRealtimeHub().publish(canal, charge)`. La charge est un objet, un tableau, des octets ou
|
|
149
|
+
rien — **un scalaire lève** (`publish("c", 42)` → `TypeError`) : écris `{ value: 42 }`.
|
|
150
|
+
- **Un canal au nom calculé ne se publie pas par la variable `publishToChannel` du contrôleur
|
|
151
|
+
généré** (elle ne sert qu'un nom exact) : `getRealtimeHub().publish(\`room:${id}\`, charge)`.
|
|
152
|
+
- **État ou événement ?** Une position, une jauge : seule la dernière valeur compte — garde-la et
|
|
153
|
+
diffuse au rythme du serveur (`setInterval`, `unref()`), jamais à chaque réception. Un message
|
|
154
|
+
de chat : chacun compte — persiste-le AVANT de le publier.
|
|
155
|
+
- **Une famille de canaux** (`room:*`) : le décorateur ne sert que le nom EXACT. Ajoute
|
|
156
|
+
`override createRealtimeChannel(channel) { return exists(channel) ? () => {} : null; }` ;
|
|
157
|
+
`null` = canal inconnu, le client reçoit `realtime:denied { reason: "unknown" }`. Un canal à
|
|
158
|
+
durée de vie porte l'identifiant de son instance dans son nom (`game:42:a1b2`), et la fabrique
|
|
159
|
+
rend `null` une fois l'instance finie.
|
|
160
|
+
|
|
161
|
+
## 4. Qui a le droit — la politique, et ce qu'elle ne couvre pas
|
|
162
|
+
|
|
163
|
+
Politique = `{ authenticated?, roles?, scopes?, allow?, anonymous? }` — toute autre clé est
|
|
164
|
+
refusée au démarrage. Les clés se cumulent. Un rôle dit **qui est connecté** ; `allow` dit **ce
|
|
165
|
+
compte sur ce canal**.
|
|
70
166
|
|
|
71
167
|
```ts
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
async
|
|
168
|
+
// L'appartenance vit en base (entité générée) ; `allow` la lit UNE fois, au `subscribe` —
|
|
169
|
+
// et à chaque reconnexion, puisque le navigateur rejoue ses abonnements.
|
|
170
|
+
@RealtimeChannel("room:*", {
|
|
171
|
+
allow: async (channel, who) =>
|
|
172
|
+
who.id !== null &&
|
|
173
|
+
(await getRoomMemberService().findOne({ room: channel.slice("room:".length), user: who.id })) !==
|
|
174
|
+
null,
|
|
175
|
+
})
|
|
176
|
+
room(): () => void {
|
|
177
|
+
return () => {};
|
|
76
178
|
}
|
|
77
|
-
```
|
|
78
179
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
180
|
+
// 🔴 Le motif pose la POLITIQUE de toute la famille, pas la FABRIQUE : sans celle-ci,
|
|
181
|
+
// `room:42` est refusé (`realtime:denied { reason: "unknown" }`).
|
|
182
|
+
override createRealtimeChannel(channel: string): (() => void) | null {
|
|
183
|
+
return channel.startsWith("room:") ? () => {} : null;
|
|
184
|
+
}
|
|
185
|
+
```
|
|
84
186
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
187
|
+
- `who` est une donnée plate (`id`, `authenticated`, `roles`, `scopes`, `issuer`, `account`) :
|
|
188
|
+
`allow` ne lit **que ses arguments** — ni contexte de requête, ni session. Seul `=== true`
|
|
189
|
+
admet ; lever ou rejeter = `forbidden` ; trop lent = `unavailable` (le client réessaie seul).
|
|
190
|
+
Dès que la règle lit la base par des services, écris-la en **classe `@injectable`** résolue au
|
|
191
|
+
démarrage (`securite.md`, « forme longue ») : une dépendance absente y refuse le démarrage en
|
|
192
|
+
nommant la classe, au lieu de refuser tous les abonnés sans un mot.
|
|
193
|
+
- **Lire l'appartenance** : `getXxxService()` est l'accesseur que `create entity Xxx` a généré ;
|
|
194
|
+
une colonne de référence se filtre par l'identifiant de la cible
|
|
195
|
+
(`findOne({ room: roomId, user: who.id })`). N'invente ni service, ni injection, ni `as never`.
|
|
196
|
+
- **Exclure** = retirer l'appartenance, PUIS `getRealtimeHub().evictUser(canal, who.id)` (tous ses
|
|
197
|
+
onglets, tous les serveurs ; `realtime:closed { reason: "evicted" }`). L'éjection seule ne
|
|
198
|
+
suffit pas : son navigateur rejoue `subscribe` au rechargement, et `allow` tranche alors. Une
|
|
199
|
+
liste noire à côté d'`allow` diverge en silence : interdit.
|
|
200
|
+
- **Le modérateur est un attribut d'appartenance** (`RoomMember.role`), pas un rôle global de
|
|
201
|
+
l'application. Et l'entité d'appartenance se génère **sans ressource REST** (`--no-controller`) :
|
|
202
|
+
un CRUD ouvert laisserait chacun s'inviter lui-même.
|
|
203
|
+
- **Une entrée ou une action n'hérite PAS de la politique du canal.**
|
|
204
|
+
`@RealtimeInbound("room:send")` porte la sienne, et vérifie l'appartenance avec
|
|
205
|
+
`connection.userId` par le MÊME registre qu'`allow` — ou devient une route `POST` (§1).
|
|
206
|
+
- **Le nom exact l'emporte sur le motif, et le remplace EN ENTIER** : une politique
|
|
207
|
+
`@RealtimeChannel("room:lobby", { authenticated: true })` ouvre `room:lobby` à tout compte
|
|
208
|
+
connecté malgré l'`allow` de `room:*`.
|
|
209
|
+
- **`user:<id>` est fourni** : politique intégrée (compte connecté, son propre id seulement),
|
|
210
|
+
diffusé entre serveurs, rien à déclarer. N'écris jamais de politique sur `user:…`.
|
|
211
|
+
- **Ne ferme pas la zone pour fermer un canal.** La politique se pose sur le canal ; une zone
|
|
212
|
+
`^/api` privée de `"anonymous"` emporte le reste de l'application, démonstrations et tests
|
|
213
|
+
fournis compris. Réserver une route : skill `nodefony-protect-route`.
|
|
214
|
+
- **Condition d'activation** : rôles, portées et `authenticated` ne sont appliqués que si
|
|
215
|
+
`@nodefony/security` est chargé avec une zone qui couvre la socket (`realtime: true`, défaut).
|
|
216
|
+
Sinon le serveur l'écrit en WARNING au premier handshake. `allow` s'applique toujours.
|
|
217
|
+
- La configuration de sécurité `realtimeChannels: [{ pattern: "room:", roles: […] }]` durcit une
|
|
218
|
+
famille sans toucher au code ; elle n'exprime jamais l'appartenance. Ne simule jamais un salon
|
|
219
|
+
privé par un nom de canal difficile à deviner.
|
|
220
|
+
|
|
221
|
+
## 5. Côté client — la façade, jamais le socket nu
|
|
88
222
|
|
|
89
223
|
```ts
|
|
90
|
-
|
|
91
|
-
|
|
224
|
+
import { NodefonySocket } from "nodefony/client";
|
|
225
|
+
|
|
226
|
+
// La socket du contrôleur qui DÉCLARE ces canaux (`create controller Ops` → `/api/ops/realtime`).
|
|
227
|
+
const socket = NodefonySocket.shared({ url: "/api/ops/realtime" }); // UNE socket par URL
|
|
228
|
+
socket.subscribe("ops:alerts"); // DEMANDE le flux (rejoué à chaque reconnexion)
|
|
229
|
+
socket.on("ops:alerts", (alert) => render(alert)); // REÇOIT ; deux gestes distincts
|
|
230
|
+
socket.emit("ops:ack", { id: "a1" }); // entrée client, sans réponse
|
|
231
|
+
const me = await socket.request("ops:whoami"); // action, avec réponse (30 s)
|
|
232
|
+
socket.onDenied(({ channel, reason }) => {}); // forbidden | limit | unknown | unavailable
|
|
233
|
+
socket.onClosed(({ channel, reason }) => {}); // ended | evicted — le client ne rejoue pas
|
|
234
|
+
await socket.whenSubscribed("ops:alerts"); // avant de charger l'état initial
|
|
235
|
+
socket.onUser((note) => {}); // les messages adressés à CE compte
|
|
92
236
|
```
|
|
93
237
|
|
|
94
|
-
|
|
95
|
-
|
|
238
|
+
- **Importe de `nodefony/client`**, jamais de la racine `nodefony` dans un bundle navigateur ;
|
|
239
|
+
`nodefony/realtime` n'existe pas.
|
|
240
|
+
- `shared()` sans `url` lève ; les options ne comptent qu'à la PREMIÈRE création (codec compris).
|
|
241
|
+
- Vues : `nodefony/react` (`<NodefonyProvider url="…">`, `useNodefony()`,
|
|
242
|
+
`useNodefonyChannelData(socket, "canal", initial)`, `useNodefonyChannel(socket, "canal", h)`),
|
|
243
|
+
mêmes noms en `nodefony/vue`. Un composant monte et démonte son abonnement ; la socket
|
|
244
|
+
appartient à la page. Détail : `node_modules/nodefony/docs/client.md`.
|
|
245
|
+
- Le navigateur ne voit un refus de handshake (401/403) que comme une coupure : la socket sonde
|
|
246
|
+
l'adresse en HTTP et s'arrête d'elle-même ; tu n'as rien à écrire.
|
|
247
|
+
|
|
248
|
+
## 6. SSE ou socket ?
|
|
249
|
+
|
|
250
|
+
| Besoin | Choisir |
|
|
251
|
+
| -------------------------------------------------------------------- | ------------------------------------------- |
|
|
252
|
+
| Le serveur pousse sur UNE requête (progression, réponse d'un modèle) | **SSE** — une réponse HTTP qui ne finit pas |
|
|
253
|
+
| Un réseau d'entreprise bloque les WebSocket | **SSE** — c'est une réponse HTTP ordinaire |
|
|
254
|
+
| Deux sens, canaux, plusieurs onglets, identité par connexion | **socket** — le reste de cette page |
|
|
96
255
|
|
|
97
256
|
```ts
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
257
|
+
@route("progress-run", { path: "/run" })
|
|
258
|
+
async run() {
|
|
259
|
+
const sse = await this.renderSse(); // en-têtes, battement, contre-pression
|
|
260
|
+
await sse.send({ step: "calcul" }, { event: "step" });
|
|
261
|
+
return sse.close();
|
|
262
|
+
}
|
|
103
263
|
```
|
|
104
264
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
265
|
+
`this.acceptsSse()` laisse la même route répondre en JSON ou en flux. Client :
|
|
266
|
+
`new NodefonySse(url)` (`nodefony/client`), liaison `useNodefonySse`. **N'écris jamais
|
|
267
|
+
`Content-Type: text/event-stream` à la main** : tu perdrais le battement qui empêche un proxy de
|
|
268
|
+
couper, et la contre-pression (`await sse.send()` freine si le client lit lentement). Un flux SSE
|
|
269
|
+
n'est pas un canal : il n'est pas gardé par une politique de canal, mais par la zone et
|
|
270
|
+
`@IsGranted` de sa route. Détail : `node_modules/@nodefony/http/docs/sse.md`.
|
|
108
271
|
|
|
109
|
-
##
|
|
272
|
+
## 7. Un contrat, déclaré UNE fois
|
|
110
273
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
L'auteur d'un message se prend là, côté SERVEUR — jamais dans la charge, où le client peut mentir.
|
|
274
|
+
Dès que navigateur et serveur échangent plus qu'un canal : sans contrat, chaque côté type ce qu'il
|
|
275
|
+
envoie, jamais ce que l'autre implémente — une méthode oubliée compile puis rend `-32601`.
|
|
114
276
|
|
|
115
277
|
```ts
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
278
|
+
// shared/ops.ts — types SEULS : 0 octet compilé, aucun import de code serveur
|
|
279
|
+
import type { IRealtimeContract } from "nodefony/client";
|
|
280
|
+
|
|
281
|
+
export interface OpsContract extends IRealtimeContract {
|
|
282
|
+
serverToClient: { "ops:alerts": { level: string; at: Date } };
|
|
283
|
+
clientToServer: { "ops:ack": { id: string } };
|
|
284
|
+
serverMethods: {
|
|
285
|
+
"ops:mute": { in: { id: string }; out: { muted: boolean } };
|
|
286
|
+
};
|
|
119
287
|
}
|
|
120
288
|
```
|
|
121
289
|
|
|
122
|
-
## Côté client — la façade, pas le socket
|
|
123
|
-
|
|
124
290
|
```ts
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
291
|
+
// navigateur
|
|
292
|
+
const ops = NodefonySocket.fromContract<OpsContract>({
|
|
293
|
+
url: "/api/ops/realtime", // la socket d'OpsRealtime, qui sert ces canaux
|
|
294
|
+
});
|
|
295
|
+
ops.on("ops:alerts", (a) => a.at); // `string` : ce que JSON fait d'une Date
|
|
129
296
|
```
|
|
130
297
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
Fermer « toute la zone `^/api` » pour protéger un canal emporte le reste de l'application avec
|
|
141
|
-
lui — y compris les démonstrations publiques posées par le scaffold. La politique se pose **sur le
|
|
142
|
-
canal**, pas sur l'espace HTTP qui l'entoure.
|
|
298
|
+
```ts
|
|
299
|
+
// serveur — le contrat s'écrit UNE fois, sur `extends` ; la table sert SEULE les méthodes
|
|
300
|
+
@RealtimePolicy({ "ops:mute": { roles: ["ROLE_OPS"] } })
|
|
301
|
+
class OpsRealtime extends ContractRealtimeController<OpsContract> {
|
|
302
|
+
protected override realtimeActions() {
|
|
303
|
+
return { "ops:mute": (p: { id: string }) => ({ muted: p.id.length > 0 }) }; // une oubliée ne compile pas
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
```
|
|
143
307
|
|
|
144
|
-
|
|
308
|
+
- N'ajoute pas `@RealtimeAction` à une méthode de la table ; la politique va sur la classe par
|
|
309
|
+
`@RealtimePolicy` (un nom hors contrat ne compile pas). Sans `serverMethods`, rien à écrire.
|
|
310
|
+
- Diffuser : `getRealtimeHub<OpsContract>().publish(canal, charge)` — nom et charge vérifiés ; une
|
|
311
|
+
fabrique de canal déclare `publish: ContractRealtimePublish<OpsContract>` ; le flux SSE lit la
|
|
312
|
+
même carte (`this.renderSse<ContractServerEmit<OpsContract>>()`).
|
|
313
|
+
- Routes du pont : clé `serverRoutes` → le client appelle `ops.post("/ops/mute", corps)`.
|
|
314
|
+
- **Le type n'est pas une validation** : la politique reste la seule garde.
|
|
315
|
+
|
|
316
|
+
## 8. Ce qui ne se voit qu'en production — lis-le avant de dire « fait »
|
|
317
|
+
|
|
318
|
+
Résumé ; chaque ligne est détaillée dans [`references/production.md`](references/production.md).
|
|
319
|
+
|
|
320
|
+
- **Plusieurs serveurs** : un canal reste sur son serveur tant que sa classe ne porte pas
|
|
321
|
+
`@RealtimeBroadcast("prefix:")` — sur TOUS les serveurs, pas seulement l'émetteur. L'application
|
|
322
|
+
générée tourne en `backplane.driver: "cluster"` : deux onglets sur un seul processus ne prouvent
|
|
323
|
+
rien. La sonde compte l'oubli (`localOnlyTotal`). `user:` est diffusé par le framework.
|
|
324
|
+
- **Reconnexion** : le client rejoue ses abonnements au `welcome` — pas les messages manqués. Une
|
|
325
|
+
discussion se reprend par une route (`GET …/history?since=`) ; déduplique sur l'identifiant
|
|
326
|
+
persistant.
|
|
327
|
+
- **Aucune garantie de livraison** : au plus une fois, un trou jamais réordonné. Une alarme qui ne
|
|
328
|
+
doit pas se perdre porte son identifiant dans la charge, un accusé, une réémission applicative.
|
|
329
|
+
- **Révocation** : une socket survit à sa session jusqu'à la revalidation (30 s). Rôles changés →
|
|
330
|
+
`4005` (revient aussitôt, lis `onIdentity`) ; session morte → `4001` ; jeton porteur expiré →
|
|
331
|
+
`4006` (définitif : recrée la socket avant `exp`). Compare aux constantes de `nodefony`, jamais
|
|
332
|
+
aux nombres.
|
|
333
|
+
- **Contre-pression** : un client lent est jeté puis fermé (`1013`) par le serveur (`websocket` ET
|
|
334
|
+
`websocketSecure` de `@nodefony/http`) ; `drop` ne trie pas. Côté navigateur, `maxBufferedAmount`
|
|
335
|
+
jette un coup périmé au lieu de l'empiler (jeu, capteur).
|
|
336
|
+
- **`allow` lent** : `unavailable`, réessayé 3 fois par le client ; en rafale au redémarrage
|
|
337
|
+
(N clients × M canaux). Un bus partagé doit être **privé** (un ordre d'éjection capté se rejoue).
|
|
338
|
+
|
|
339
|
+
## 9. Des octets dans un canal
|
|
340
|
+
|
|
341
|
+
Côté serveur, une ligne : `use("@nodefony/realtime", { codec: "cbor" })`. Sans elle, un
|
|
342
|
+
`Uint8Array` part en `{"0":1,…}` **sans erreur**. Côté navigateur, offre le codec à la PREMIÈRE
|
|
343
|
+
création : `NodefonySocket.shared({ url, codec: CBOR_FRAME_CODEC })` (`nodefony/cbor`). Les octets
|
|
344
|
+
passent par `publish` / `on(canal)` sous les mêmes politiques ; `onRealtimeBinary()` est **hors des
|
|
345
|
+
gardes**. Mise en place, messages d'erreur, ce qui rate sans message :
|
|
346
|
+
[`references/binaire.md`](references/binaire.md) — à lire AVANT la première ligne.
|
|
347
|
+
|
|
348
|
+
## 10. Prouver
|
|
145
349
|
|
|
146
350
|
```bash
|
|
147
351
|
npm test
|
|
148
|
-
npx nodefony inspect routes --json
|
|
352
|
+
npx nodefony inspect routes --json
|
|
149
353
|
```
|
|
150
354
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
toujours** (s'il s'est fermé, une politique a débordé).
|
|
355
|
+
Le harnais parle le protocole sans serveur ni navigateur ; chaque cas de `cas-d-usage.md` en
|
|
356
|
+
montre un test complet. Le patron :
|
|
154
357
|
|
|
155
|
-
|
|
358
|
+
```ts
|
|
359
|
+
import { getRealtimeHub } from "@nodefony/realtime";
|
|
360
|
+
import { createRealtimeHarness } from "@nodefony/realtime/testing";
|
|
361
|
+
import {
|
|
362
|
+
createTestFrameAuthorizer,
|
|
363
|
+
createTestIdentity,
|
|
364
|
+
} from "@nodefony/security/testing";
|
|
365
|
+
|
|
366
|
+
const bob = createRealtimeHarness((ctx) => new RoomRealtime(ctx), {
|
|
367
|
+
identity: createTestIdentity({ identifier: "bob" }), // `who.id` = "bob" ; `id:` pour une clé distincte
|
|
368
|
+
frameAuthorizer: createTestFrameAuthorizer(getRealtimeHub()), // le VRAI verrou : sans lui, `roles` n'est pas jugé
|
|
369
|
+
});
|
|
370
|
+
await bob.connect();
|
|
371
|
+
await bob.subscribe("room:7"); // acquitté : `bob.subscriptions()` ; refusé : `bob.denials()`
|
|
372
|
+
await bob.notify("room:send", { room: "room:7", text: "salut" }); // entrée client
|
|
373
|
+
bob.messages("room:7"); // ce qu'il a reçu, dans l'ordre
|
|
374
|
+
bob.received.filter((f) => f.method === "realtime:closed"); // evicted | ended
|
|
375
|
+
bob.dispose();
|
|
376
|
+
```
|
|
156
377
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
378
|
+
- Sans `identity` : connexion **anonyme**. Plusieurs harnais sur le même hub : `resetHub` au
|
|
379
|
+
premier seulement — c'est ainsi qu'on éprouve qu'une publication atteint l'un et pas l'autre.
|
|
380
|
+
- **Vois le test rouge une fois** : retire `allow` (ou `roles`), l'intrus doit entrer ; remets-le.
|
|
381
|
+
- L'exclusion se prouve : exclu → `realtime:closed evicted`, puis un nouveau `subscribe` refusé.
|
|
382
|
+
Un refus opposé à quelqu'un qui n'a jamais été membre ne prouve PAS l'exclusion.
|
|
383
|
+
- **`allow` lit la base ? Le test l'amorce** — c'est la recette du test que `create entity` a
|
|
384
|
+
écrit à côté de chaque entité (`tests/<entité>.test.ts`), avec deux écarts :
|
|
385
|
+
|
|
386
|
+
```ts
|
|
387
|
+
import { DrizzleOrm, seedEntityRow } from "@nodefony/drizzle";
|
|
388
|
+
import { entityRegistry, ormRegistry } from "@nodefony/orm-core";
|
|
389
|
+
|
|
390
|
+
const CONNECTOR = "default"; // celui de l'APPLICATION : `getRoomMemberService()` le lit
|
|
391
|
+
for (const e of [UserEntity, RoomEntity, RoomMemberEntity]) {
|
|
392
|
+
entityRegistry.register({ ...e, connector: CONNECTOR });
|
|
393
|
+
}
|
|
394
|
+
const orm = new DrizzleOrm(CONNECTOR, { filename: ":memory:" });
|
|
395
|
+
await orm.connect();
|
|
396
|
+
// Les PARENTS d'abord : une clé étrangère inventée est refusée par la base.
|
|
397
|
+
const bobId = String(
|
|
398
|
+
await seedEntityRow(orm, { ...UserEntity, connector: CONNECTOR }),
|
|
399
|
+
);
|
|
400
|
+
const room = String(
|
|
401
|
+
await seedEntityRow(orm, { ...RoomEntity, connector: CONNECTOR }),
|
|
402
|
+
);
|
|
403
|
+
await getRoomMemberService().create({ room, user: bobId, role: "member" });
|
|
404
|
+
// `who.id` = l'identifiant EN BASE :
|
|
405
|
+
createTestIdentity({ identifier: "bob", id: bobId });
|
|
406
|
+
// afterAll : orm.disconnect(), entityRegistry.unregister(nom, CONNECTOR), ormRegistry.unregister(CONNECTOR)
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
Écart 1 : le connecteur s'appelle `default`, pas `test-<entité>` — sinon l'accesseur du
|
|
410
|
+
service ne trouve aucune base. Écart 2 : `FOREIGN KEY constraint failed` = un parent non semé,
|
|
411
|
+
jamais une raison de retirer le test. Exemple exécuté complet : `cas-d-usage.md` (salon privé).
|
|
412
|
+
|
|
413
|
+
- **Et le canal public témoin répond toujours** (`live:events` d'une application générée) : s'il
|
|
414
|
+
s'est fermé, une politique ou une zone a débordé.
|
|
415
|
+
- La santé en marche : `GET /nodefony/realtime/api/health` (rôle d'administration) —
|
|
416
|
+
`publishTotal` qui monte sans `fanoutTotal` = tu publies dans le vide (nom mal orthographié).
|
|
417
|
+
|
|
418
|
+
## 11. Voisins
|
|
419
|
+
|
|
420
|
+
| Besoin | Skill |
|
|
421
|
+
| -------------------------------------------------------- | ------------------------ |
|
|
422
|
+
| Réserver une route HTTP, une page | `nodefony-protect-route` |
|
|
423
|
+
| Une ressource complète stockée (l'entité d'appartenance) | `nodefony-add-crud` |
|
|
424
|
+
| Un service métier injectable (la classe d'admission) | `nodefony-add-service` |
|
|
425
|
+
| Voir l'écran, piloter une socket depuis la page | `nodefony-browser` |
|
|
426
|
+
| Un canal qui répondait ne répond plus | `nodefony-troubleshoot` |
|