@nodefony/devkit 10.0.0-beta.5 → 10.0.0-beta.7
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.158.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.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/profileRoute.js +28 -9
- package/dist/nodefony/service/DevkitService.js +6 -6
- package/docs/index.md +1 -1
- package/package.json +13 -13
- package/skills/nodefony-add-crud/SKILL.md +38 -23
- package/skills/nodefony-add-grpc-service/SKILL.md +179 -0
- package/skills/nodefony-add-grpc-service/scripts/call.mjs +218 -0
- package/skills/nodefony-add-realtime-binary/SKILL.md +301 -0
- package/skills/nodefony-add-realtime-binary/references/messages.md +20 -0
- package/skills/nodefony-add-realtime-binary/references/mesures.md +59 -0
- package/skills/nodefony-add-realtime-channel/SKILL.md +452 -105
- package/skills/nodefony-add-realtime-channel/references/contrat.md +22 -0
- package/skills/nodefony-add-realtime-channel/references/production.md +140 -0
- package/skills/nodefony-add-realtime-presence/SKILL.md +328 -0
- package/skills/nodefony-authentication/SKILL.md +2 -2
- package/skills/nodefony-browser/SKILL.md +37 -2
- package/skills/nodefony-browser/references/sondes.md +19 -0
- package/skills/nodefony-browser/scripts/audit.mjs +17 -2
- package/skills/nodefony-browser/scripts/inspect.mjs +108 -87
- package/skills/nodefony-browser/scripts/lib/actions.mjs +164 -0
- package/skills/nodefony-browser/scripts/lib/browser.mjs +67 -11
- package/skills/nodefony-browser/scripts/lib/probes.mjs +108 -3
- package/skills/nodefony-browser/scripts/watch.mjs +27 -11
- package/skills/nodefony-dev/SKILL.md +31 -15
- package/skills/nodefony-dev/references/rapport-de-build.md +19 -0
- package/skills/nodefony-devops/SKILL.md +28 -21
- package/skills/nodefony-devops/references/dimensionnement.md +1 -1
- package/skills/nodefony-devops/references/frontal.md +5 -2
- package/skills/nodefony-devops/references/image.md +23 -15
- package/skills/nodefony-migrate-schema/SKILL.md +110 -20
- package/skills/nodefony-migrate-schema/references/verdicts.md +23 -20
- package/skills/nodefony-protect-route/SKILL.md +27 -32
|
@@ -1,161 +1,508 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: nodefony-add-realtime-channel
|
|
3
|
+
metadata:
|
|
4
|
+
version: 3.1.0
|
|
3
5
|
description: >
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
"
|
|
12
|
-
"réserver un canal à un rôle", "mon canal est public", "
|
|
13
|
-
"
|
|
6
|
+
Branche le temps réel d'une application Nodefony par ses briques — canal, route appelée par la
|
|
7
|
+
socket, entrée client, action, canal personnel, flux SSE, contrat typé — au lieu d'un WebSocket
|
|
8
|
+
ou d'un contrôle d'accès écrits à la main. Part du besoin (chat, salon privé, jeu, sondes,
|
|
9
|
+
notification, tâche longue), mène à la ligne, à l'exemple exécuté et à son test, dit qui a le
|
|
10
|
+
droit de LIRE et d'ÉCRIRE, et porte la production : plusieurs serveurs, reconnexion,
|
|
11
|
+
redéploiement, révocation. À charger AVANT d'écrire un canal, un abonnement ou un flux. Qui est
|
|
12
|
+
en ligne → `nodefony-add-realtime-presence` ; des octets → `nodefony-add-realtime-binary`.
|
|
13
|
+
Déclencheurs : "temps réel", "websocket", "canal", "push", "chat", "salon privé", "exclure un
|
|
14
|
+
membre", "l'exclu revient", "réserver un canal à un rôle", "mon canal est public", "prévenir un
|
|
15
|
+
utilisateur", "progression d'une tâche", "SSE", "tableau de bord en direct", "-32601", "ça marche en
|
|
16
|
+
local, plus en cluster", "le canal ne répond jamais".
|
|
14
17
|
---
|
|
15
18
|
|
|
16
|
-
# add-realtime-channel — le temps réel par sa
|
|
19
|
+
# add-realtime-channel — le temps réel par sa brique
|
|
17
20
|
|
|
18
|
-
> 🧭 **
|
|
19
|
-
>
|
|
20
|
-
>
|
|
21
|
+
> 🧭 **Arrivé ici directement ? Charge aussi `nodefony-dev`** : il porte la conduite commune (par
|
|
22
|
+
> où commencer, comment prouver, les pièges du serveur et du front). Cette page ne couvre QUE le
|
|
23
|
+
> temps réel.
|
|
21
24
|
>
|
|
22
|
-
>
|
|
23
|
-
>
|
|
24
|
-
>
|
|
25
|
-
>
|
|
26
|
-
|
|
27
|
-
>
|
|
28
|
-
>
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
25
|
+
> Les pages de référence sont INSTALLÉES (`rg` ne descend pas dans `node_modules`). Une commande
|
|
26
|
+
> les trouve et rend leur chemin — **exécute-la, puis lis la page** :
|
|
27
|
+
>
|
|
28
|
+
> ```bash
|
|
29
|
+
> node node_modules/@nodefony/devkit/skills/nodefony-dev/scripts/docs.mjs --open cookbook-chat
|
|
30
|
+
> node node_modules/@nodefony/devkit/skills/nodefony-dev/scripts/docs.mjs salon allow evict
|
|
31
|
+
> ```
|
|
32
|
+
|
|
33
|
+
> ⚖️ **La confiance n'exclut pas le contrôle.** Un **canal** sans politique est public par
|
|
34
|
+
> construction ; une **action** sans politique est fermée (connexion authentifiée exigée). Ce qui
|
|
35
|
+
> n'est pas déclaré n'est pas gardé — et ce que le framework porte déjà ne se réécrit pas.
|
|
36
|
+
|
|
37
|
+
> **Maintenance** : vérité courante, jamais un journal. Éditer en place ; historique = `git log`.
|
|
38
|
+
> Chaque bloc de code est un EXTRAIT d'un fichier exécuté par la suite du framework (marqueur
|
|
39
|
+
> `excerpt`) : un test refuse qu'il en diverge. Une page installée en montre le fichier entier.
|
|
40
|
+
>
|
|
41
|
+
> **La démo** (première implémentation complète, chat compris) : dans ton application si elle a été
|
|
42
|
+
> créée avec la démo, sinon ses gabarits sous `node_modules/nodefony/templates/demo/` (fichiers
|
|
43
|
+
> `.tpl`, lisibles tels quels) — le chat : `server/nodefony/controllers/ChatController.ts.tpl`.
|
|
44
|
+
|
|
45
|
+
## 1. Quel besoin → quelle brique
|
|
46
|
+
|
|
47
|
+
| Je veux… | La brique (et rien d'autre) | Où le voir |
|
|
48
|
+
| ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
49
|
+
| un **chat** : des messages gardés, des salons, des droits | lire par un **canal**, écrire par une **route** appelée par la socket (§3) | `cookbook-chat.md`, le chat de la démo |
|
|
50
|
+
| pousser un flux que des écrans lisent | `@RealtimeChannel("nom")` ; un producteur par canal et par serveur, créé au premier abonné | `cas-d-usage.md` (tableau de bord) |
|
|
51
|
+
| le réserver à un rôle | `@RealtimeChannel("nom", { roles: ["ROLE_X"] })` | `securite.md` |
|
|
52
|
+
| une famille de canaux (`room:<id>`, `task:<id>`) | motif `"room:*"` — il sert chaque nom ; `createRealtimeChannel()` seulement pour FILTRER (`null` = refus) | §4 |
|
|
53
|
+
| **ce compte-là et lui seul** (salon privé, invitation) | `allow` sur le motif, écrit en **classe d'admission** qui lit la base (§5) | `cas-d-usage.md` (salon privé : son ADMISSION ; l'écriture suit §3, pas son `room:send`) |
|
|
54
|
+
| 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` |
|
|
55
|
+
| 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) |
|
|
56
|
+
| **qui est en ligne**, combien, « en train d'écrire » | l'option `presence` du canal | skill `nodefony-add-realtime-presence` |
|
|
57
|
+
| 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) |
|
|
58
|
+
| 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) |
|
|
59
|
+
| recevoir un flux FRÉQUENT du client (coups de jeu, mesures) | `@RealtimeInbound("game:steer", politique)` — sans réponse ; seule la dernière valeur compte (§4) | `cas-d-usage.md` (jeu, sondes) |
|
|
60
|
+
| un appel avec réponse qui ne change rien (un devis) | `@RealtimeAction("x:y")` (fermée par défaut) — ou une route `GET` | `actions.md` |
|
|
61
|
+
| le serveur pousse, le client écoute, sur UNE requête | `this.renderSse()` dans une action ordinaire ; `new NodefonySse(url)` côté client (§8) | `http/docs/sse.md` |
|
|
62
|
+
| typer les deux bouts une fois | `interface C extends IRealtimeContract` + `NodefonySocket.fromContract<C>()` + `ContractRealtimeController<C>` (§9) | `actions.md` (contrat) |
|
|
63
|
+
| des **octets** (image, audio, mesures binaires, document) | le codec CBOR et un `Uint8Array` dans la charge | skill `nodefony-add-realtime-binary` |
|
|
64
|
+
| ça marche avec deux onglets, plus en cluster | `@RealtimeBroadcast("prefix:")` sur la classe (défaut : un canal reste sur son serveur) | `references/production.md` |
|
|
65
|
+
|
|
66
|
+
Chemins complets : `node_modules/@nodefony/realtime/docs/<page>.md`,
|
|
67
|
+
`node_modules/@nodefony/http/docs/sse.md`, `node_modules/nodefony/docs/client.md`.
|
|
68
|
+
|
|
69
|
+
## 2. Le geste
|
|
32
70
|
|
|
33
71
|
```bash
|
|
34
|
-
npx nodefony create controller
|
|
72
|
+
npx nodefony create controller Room --kind realtime
|
|
35
73
|
```
|
|
36
74
|
|
|
37
|
-
Produit une sous-classe de `RealtimeController` avec
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
75
|
+
Produit une sous-classe de `RealtimeController` avec un canal de démonstration, une action, une
|
|
76
|
+
entrée client, et **son test** (`createRealtimeHarness`). Nomme le contrôleur par son USAGE
|
|
77
|
+
(`RoomChat`, `Arena`), pas par une entité : `create entity Room` après `create controller Room`
|
|
78
|
+
est refusé (nom déjà référencé). Retires-tu le canal de démonstration ? Adapte son test dans la
|
|
79
|
+
foulée — ne laisse pas un banc rouge que tu finirais par ignorer.
|
|
80
|
+
|
|
81
|
+
🔴 **`/api` est FERMÉ dans une application générée** (zone `main` sans `anonymous`) : sans
|
|
82
|
+
session, l'ouverture de `/api/room/realtime` reçoit 401 — et un WebSocket refusé ne LÈVE rien côté
|
|
83
|
+
client, il attend puis expire. Un canal qui « ne répond jamais » se cherche dans la zone avant le
|
|
84
|
+
réseau. Canal public voulu : une zone au pattern ÉNUMÉRÉ terminé par `(/|$)` (skill
|
|
85
|
+
`nodefony-protect-route`), jamais `anonymous` remis sur `main`.
|
|
41
86
|
|
|
42
|
-
|
|
87
|
+
🔴 **Une socket ne sert QUE les canaux du contrôleur qui porte sa route WebSocket** : ouverte sur
|
|
88
|
+
`/api/arena/realtime`, `room:42` est refusé (`unknown`). Une socket par page : regroupe les canaux
|
|
89
|
+
dans un même contrôleur. Seul `user:<id>` est fourni par toutes.
|
|
43
90
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
| Le serveur pousse, le client écoute (progression, journal) | **SSE** — une réponse HTTP qui ne finit pas |
|
|
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 |
|
|
91
|
+
**N'écris ni `new WebSocket(...)` côté serveur, ni `ws.on(...)`, ni un `WebSocketServer`** : tu
|
|
92
|
+
perdrais le routage par canal, l'identité partagée avec HTTP, la reconnexion et la contre-pression.
|
|
49
93
|
|
|
50
|
-
|
|
51
|
-
|
|
94
|
+
## 3. Le patron d'un chat : LIRE par le canal, ÉCRIRE par une route
|
|
95
|
+
|
|
96
|
+
C'est la forme du chat de la démo, première implémentation complète du temps réel Nodefony. Le
|
|
97
|
+
canal porte ce qui se DIFFUSE ; chaque GESTE qui change un état (envoyer, modifier, inviter) est
|
|
98
|
+
une **route**, que le navigateur appelle **sur la même socket** — `socket.post()` envoie une trame,
|
|
99
|
+
pas une requête HTTP de plus.
|
|
100
|
+
|
|
101
|
+
**1 — ouvrir le pont** : la route WebSocket du contrôleur, et `realtimeApiRequest()` qui rend
|
|
102
|
+
joignables par la socket les routes qui déclarent `WEBSOCKET` :
|
|
103
|
+
|
|
104
|
+
<!-- excerpt: demo/server/nodefony/controllers/ChatController.ts.tpl -->
|
|
52
105
|
|
|
53
106
|
```ts
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
107
|
+
/** Ouvre le pont : les actions ci-dessous deviennent joignables par la socket. */
|
|
108
|
+
protected override realtimeApiRequest(): boolean {
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** La porte WebSocket : `handleRealtime` porte tout le protocole. */
|
|
113
|
+
@route("demo-chat-realtime", {
|
|
114
|
+
path: "/realtime",
|
|
115
|
+
requirements: { methods: ["WEBSOCKET"] },
|
|
116
|
+
})
|
|
117
|
+
async realtime(message: string | Uint8Array | null): Promise<void> {
|
|
118
|
+
this.handleRealtime(message);
|
|
119
|
+
}
|
|
60
120
|
```
|
|
61
121
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
`EventSource` ; les liaisons de front existent comme pour la socket. **N'écris jamais
|
|
65
|
-
`Content-Type: text/event-stream` à la main** : tu perdrais le battement qui empêche un proxy de
|
|
66
|
-
couper la connexion, et la contre-pression (`await sse.send()` freine si le client lit lentement).
|
|
67
|
-
Détail : `node_modules/@nodefony/http/docs/sse.md`.
|
|
122
|
+
**2 — le geste d'écriture est une route** : schéma validé, droit vérifié, rejeu sans doublon,
|
|
123
|
+
réponse à l'expéditeur :
|
|
68
124
|
|
|
69
|
-
|
|
125
|
+
<!-- excerpt: demo/server/nodefony/controllers/ChatController.ts.tpl -->
|
|
70
126
|
|
|
71
127
|
```ts
|
|
72
|
-
@
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
128
|
+
@route("demo-chat-send", {
|
|
129
|
+
path: "/rooms/{room}/send",
|
|
130
|
+
requirements: { methods: ["POST", "WEBSOCKET"] },
|
|
131
|
+
schema: { params: RoomParams, body: SendBody },
|
|
132
|
+
})
|
|
133
|
+
@IsGranted(["ROLE_USER"])
|
|
134
|
+
@Idempotent()
|
|
135
|
+
async send(
|
|
136
|
+
input: RoomInput<{ body: typeof SendBody }>,
|
|
137
|
+
): Promise<{ id: string; sentAt: number }> {
|
|
138
|
+
const me = this.#writer();
|
|
139
|
+
await this.#admitted(input.params.room);
|
|
77
140
|
```
|
|
78
141
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
142
|
+
**3 — le service persiste PUIS publie** sur le canal du salon (seule voie d'écriture : la route,
|
|
143
|
+
l'envoi de pièces jointes et un outil MCP l'appellent) :
|
|
144
|
+
|
|
145
|
+
<!-- excerpt: demo/server/nodefony/service/ChatService.ts.tpl -->
|
|
82
146
|
|
|
83
|
-
|
|
147
|
+
```ts
|
|
148
|
+
async publish(roomId: string, draft: IMessageDraft): Promise<ChatMessage> {
|
|
149
|
+
const message = await this.#messages().archive(roomId, draft);
|
|
150
|
+
publishFrame(roomId, { kind: "message", message });
|
|
151
|
+
```
|
|
84
152
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
153
|
+
**4 — le navigateur écrit par la socket** ; l'auteur, c'est le serveur qui le pose :
|
|
154
|
+
|
|
155
|
+
<!-- excerpt: demo/front/frontend/src/chat/useChat.ts.tpl -->
|
|
88
156
|
|
|
89
157
|
```ts
|
|
90
|
-
|
|
91
|
-
|
|
158
|
+
await socket.post(
|
|
159
|
+
"/demo/chat/rooms/{room}/send",
|
|
160
|
+
replyTo === null ? { text } : { text, replyTo },
|
|
161
|
+
{ params: { room: roomId } },
|
|
162
|
+
);
|
|
92
163
|
```
|
|
93
164
|
|
|
94
|
-
|
|
95
|
-
|
|
165
|
+
Ce que la route donne et qu'une entrée de canal n'a pas : identité **revérifiée** à chaque appel
|
|
166
|
+
(une entrée se fie au verdict retenu sur la connexion), corps validé par schéma (422), rejeu sans
|
|
167
|
+
doublon (`@Idempotent`, la socket pose la clé), une **réponse** (le message enregistré, ou
|
|
168
|
+
403/409/429 — une entrée ne répond rien), et la même route sert en HTTP simple (réseau qui bloque
|
|
169
|
+
les WebSocket, test, agent). La table `serverRoutes` du contrôleur déclare ces routes dans le
|
|
170
|
+
contrat, et le démarrage refuse une route du contrat sans action.
|
|
171
|
+
|
|
172
|
+
**Route ou entrée de canal ?** Le geste a-t-il besoin d'une réponse, d'un schéma, d'une identité
|
|
173
|
+
revérifiée, d'un rejeu sûr ou d'un contrôle métier (appartenance au salon) ? → **route**. Un flux
|
|
174
|
+
fréquent sans réponse, où seule la dernière valeur compte (direction d'un joueur, mesure d'une
|
|
175
|
+
sonde, curseur) ? → **entrée** (§4). La frappe « en train d'écrire » d'un salon PRIVÉ passe par une
|
|
176
|
+
route dans la démo, parce qu'elle doit vérifier l'admission au salon.
|
|
177
|
+
|
|
178
|
+
- **Un seul chemin d'affichage** : la route ne rend que l'identifiant (`{ id, sentAt }`) ; l'écran
|
|
179
|
+
n'ajoute le message qu'à sa DIFFUSION sur le canal, dédoublonnée par cet identifiant. Afficher la
|
|
180
|
+
réponse ET la diffusion ferait voir le message deux fois.
|
|
181
|
+
- **L'historique se lit APRÈS l'abonnement effectif** : `await socket.whenSubscribed(canal)` puis
|
|
182
|
+
la route `GET …/history` (`GET` + `WEBSOCKET`). Lu avant, un message publié pendant qu'`allow`
|
|
183
|
+
lit la base n'arrive par aucun des deux chemins. Même geste à chaque reconnexion : le client
|
|
184
|
+
rejoue ses abonnements, pas les messages manqués.
|
|
185
|
+
- **Rattrape sur l'identifiant croissant de la base, jamais sur un horodatage** : deux serveurs ont
|
|
186
|
+
deux horloges, et un curseur `sentAt > x` perd les messages de la même milliseconde. Une page
|
|
187
|
+
relue qui ne recoupe aucun identifiant connu REMPLACE la liste (trou) au lieu de la fusionner.
|
|
188
|
+
- **Borne le débit d'écriture par compte** : le transport ne le fait pas par défaut, et un compte
|
|
189
|
+
connecté peut sinon boucler l'envoi. `#writer()` ci-dessus compte le geste AVANT toute lecture
|
|
190
|
+
en base ; au-delà, l'action pose `this.response?.setHeader("Retry-After", n)` puis lève un
|
|
191
|
+
`HttpError` 429. Le MÊME code dit l'attente sur les deux transports : l'en-tête en HTTP,
|
|
192
|
+
`error.data.retryAfter` (secondes, RFC 9110 §10.2.3) sur la socket, qui n'a pas d'en-tête — et
|
|
193
|
+
seul cet en-tête-là passe sur une trame. La démo tient une fenêtre fixe par compte
|
|
194
|
+
(`nodefony/kit/rateLimit.ts`, `take(clé)` rend les secondes à attendre), un quota séparé pour la
|
|
195
|
+
frappe (épuiser l'un ne coupe pas l'autre).
|
|
196
|
+
- **Les fichiers ne passent pas par la socket** : la démo envoie ses pièces jointes en `POST`
|
|
197
|
+
multipart (avec `Idempotency-Key`), puis publie le message qui les cite. La route qui SERT une
|
|
198
|
+
pièce d'un salon privé vérifie l'admission au salon comme les autres — un identifiant
|
|
199
|
+
imprévisible n'est pas une autorisation.
|
|
200
|
+
|
|
201
|
+
## 4. Les mots du serveur — canal, fabrique, entrée, action
|
|
202
|
+
|
|
203
|
+
<!-- excerpt: gameMoves.ts -->
|
|
96
204
|
|
|
97
205
|
```ts
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
206
|
+
// Le joueur envoie ce qu'il VEUT faire (une direction), jamais où il EST.
|
|
207
|
+
// Le rôle est contrôlé à la première commande, puis retenu sur la connexion.
|
|
208
|
+
@RealtimeInbound("game:steer", { roles: ["ROLE_PLAYER"] })
|
|
209
|
+
steer(
|
|
210
|
+
params: unknown,
|
|
211
|
+
_reply: unknown,
|
|
212
|
+
connection: IRealtimeConnection,
|
|
213
|
+
): void {
|
|
214
|
+
if (connection.userId === null || !ArenaRealtime.isDirection(params)) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
this.arena.steer(connection.userId, params);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Le moteur démarre au PREMIER joueur abonné et s'arrête au DERNIER : la
|
|
221
|
+
// fonction rendue est l'arrêt. Personne d'autre ne l'appelle.
|
|
222
|
+
@RealtimeChannel("game:state", { roles: ["ROLE_PLAYER"] })
|
|
223
|
+
state(channel: string, publish: RealtimePublish): () => void {
|
|
224
|
+
return this.arena.start((state) => publish(channel, state));
|
|
225
|
+
}
|
|
103
226
|
```
|
|
104
227
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
228
|
+
- **Le contrôleur est instancié PAR CONNEXION** : l'état partagé (le jeu, les salons) vit dans un
|
|
229
|
+
service `@injectable`, injecté par son nom (`@Inject("ArenaService")`). La fabrique d'un canal
|
|
230
|
+
est partagée entre connexions : elle ne capture jamais `this.context`.
|
|
231
|
+
- **`params` vient du réseau** : valide-le. L'auteur est `connection.userId`, jamais un champ de
|
|
232
|
+
la charge.
|
|
233
|
+
- **Publier depuis n'importe où** (service, tâche, minuteur) : `getRealtimeHub().publish(canal,
|
|
234
|
+
charge)`. La charge est un objet, un tableau, des octets ou rien — **un scalaire lève**
|
|
235
|
+
(`publish("c", 42)` → `TypeError`) : écris `{ value: 42 }`. Un canal au nom calculé se publie par
|
|
236
|
+
son nom (``getRealtimeHub().publish(`room:${id}`, charge)``).
|
|
237
|
+
- **État ou événement ?** Une position, une jauge : seule la dernière valeur compte — garde-la et
|
|
238
|
+
diffuse au rythme du serveur (`setInterval`, `unref()`) — un relais direct à chaque réception ne se
|
|
239
|
+
tolère qu'à faible cadence (quelques trames par seconde). Un message de
|
|
240
|
+
chat : chacun compte — persiste-le AVANT de le publier (§3).
|
|
241
|
+
- **Une famille de canaux** (`room:*`) : le motif pose la POLITIQUE de toute la famille ET sert
|
|
242
|
+
ses noms — `room:42` est servi, la méthode reçoit le nom réel. Une surcharge de
|
|
243
|
+
`createRealtimeChannel` ne sert qu'à FILTRER : dès qu'elle existe, elle seule décide, et son
|
|
244
|
+
`null` est un refus `unknown` que le motif ne contourne pas :
|
|
245
|
+
|
|
246
|
+
<!-- excerpt: privateRoom.ts -->
|
|
108
247
|
|
|
109
|
-
|
|
248
|
+
```ts
|
|
249
|
+
// « CETTE personne a-t-elle droit à CE salon ? » : la classe d'admission tranche.
|
|
250
|
+
@RealtimeChannel("room:*", { allow: RoomAdmission })
|
|
251
|
+
room(): () => void {
|
|
252
|
+
return () => {};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// La surcharge FILTRE la famille : un nom qui n'est pas un salon est refusé
|
|
256
|
+
// (`null`). Sans elle, le motif servirait tout `room:*`, sous `allow`.
|
|
257
|
+
override createRealtimeChannel(channel: string): (() => void) | null {
|
|
258
|
+
return RoomService.roomOf(channel) === null ? null : () => {};
|
|
259
|
+
}
|
|
260
|
+
```
|
|
110
261
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
262
|
+
Un canal à durée de vie porte l'identifiant de son instance dans son nom (`game:42:a1b2`) ; la
|
|
263
|
+
fabrique rend `null` une fois l'instance finie.
|
|
264
|
+
|
|
265
|
+
## 5. Qui a le droit — LIRE et ÉCRIRE sont deux politiques
|
|
266
|
+
|
|
267
|
+
Une politique = `{ authenticated?, roles?, scopes?, allow?, anonymous?, presence? }` — toute autre
|
|
268
|
+
clé refuse le démarrage en nommant la classe et le canal. Les axes se cumulent (ET). Un rôle dit
|
|
269
|
+
**qui est connecté** ; `allow` dit **ce compte sur ce canal**.
|
|
270
|
+
|
|
271
|
+
| Déclaration | Elle garde | Jugée |
|
|
272
|
+
| ---------------------------------------------- | ------------------------------------------------------------------------------------ | --------------------------------------------- |
|
|
273
|
+
| `@RealtimeChannel(nom, politique)` | la **LECTURE** du nom | au `subscribe` |
|
|
274
|
+
| `@RealtimeInbound(nom, politique)` | l'**ÉCRITURE** du nom | à chaque trame entrante : lecture ET écriture |
|
|
275
|
+
| `@RealtimeAction(nom, politique)` | l'écriture du nom ; sans politique : la lecture du nom si déclarée, sinon **fermée** | à chaque appel |
|
|
276
|
+
| `@RealtimePolicy({ nom: politique })` (classe) | lecture ET écriture, pour un contrôleur à contrat | idem |
|
|
277
|
+
| route + `@IsGranted` | le geste, comme toute route HTTP | à chaque appel, identité revérifiée |
|
|
278
|
+
|
|
279
|
+
- **Ce qui n'est pas déclaré s'hérite** : une lecture absente reprend l'écriture du même nom ; un
|
|
280
|
+
canal que tout le monde lit et que seuls certains écrivent se déclare donc deux fois, lecture
|
|
281
|
+
ouverte et écriture gardée. Deux déclarations contradictoires d'une même sorte sur un nom
|
|
282
|
+
refusent le démarrage.
|
|
283
|
+
- **Une entrée sous un AUTRE nom n'hérite PAS de la politique du canal qu'elle vise** :
|
|
284
|
+
`@RealtimeInbound("room:send")` ne reçoit pas l'`allow` de `room:*` — elle vérifie l'appartenance
|
|
285
|
+
elle-même, par le MÊME service que l'admission (ou devient une route, §3).
|
|
286
|
+
- **`allow` est une règle de LECTURE** : posé sur une écriture distincte, il refuse le démarrage.
|
|
287
|
+
Il est demandé UNE fois au `subscribe` (et à chaque reconnexion) ; seul `=== true` admet ; lever
|
|
288
|
+
= `forbidden` ; trop lent = `unavailable` (le client réessaie seul).
|
|
289
|
+
- **Dès que la règle lit la base, écris-la en classe d'admission** — une dépendance absente refuse
|
|
290
|
+
alors le démarrage en nommant la classe, au lieu de refuser tous les abonnés sans un mot :
|
|
291
|
+
|
|
292
|
+
<!-- excerpt: privateRoom.ts -->
|
|
114
293
|
|
|
115
294
|
```ts
|
|
116
|
-
@
|
|
117
|
-
|
|
118
|
-
|
|
295
|
+
@injectable()
|
|
296
|
+
export class RoomAdmission extends Service implements IChannelAdmission {
|
|
297
|
+
constructor(@Inject("RoomService") private readonly rooms: RoomService) {
|
|
298
|
+
super("roomAdmission");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async allow(channel: string, who: ISubscriberIdentity): Promise<boolean> {
|
|
302
|
+
return who.id !== null && (await this.rooms.isMember(channel, who.id));
|
|
303
|
+
}
|
|
119
304
|
}
|
|
120
305
|
```
|
|
121
306
|
|
|
122
|
-
|
|
307
|
+
- **Déclarée dans `@services([...])` du module** : `nodefony create service RoomAdmission` le fait ;
|
|
308
|
+
écrite à la main et oubliée, `nodefony doctor` la signale orpheline (`wiring/orphan-service`).
|
|
309
|
+
- `who` est plat (`id`, `authenticated`, `roles`, `scopes`, `issuer`, `account`) : la règle ne lit
|
|
310
|
+
que ses arguments — ni contexte de requête, ni session. Un salon PUBLIC lisible sans compte :
|
|
311
|
+
`anonymous: true` à côté d'`allow` (la démo), la règle refusant les salons privés.
|
|
312
|
+
- **Le verdict d'`allow` est RETENU sur la connexion** : une admission qui expire (invitation à un
|
|
313
|
+
événement terminé, salon verrouillé) ne se retire pas seule — éjecte (`evictUser`) au moment où
|
|
314
|
+
la condition tombe, sinon le canal continue de livrer ce que la route refuse déjà. Une même
|
|
315
|
+
règle pure, appelée par la classe d'admission ET par les routes, évite qu'elles divergent.
|
|
316
|
+
- **Exclure** = retirer l'appartenance, PUIS `getRealtimeHub().evictUser(canal, id)` (tous ses
|
|
317
|
+
onglets, tous les serveurs ; `realtime:closed { reason: "evicted" }`). L'éjection seule ne suffit
|
|
318
|
+
pas : son navigateur rejoue `subscribe` au rechargement, et `allow` tranche alors. Une liste
|
|
319
|
+
noire à côté d'`allow` diverge en silence : interdit. **Exclusion DÉFINITIVE** (pas de
|
|
320
|
+
réinvitation) = un ÉTAT sur la ligne d'appartenance (`banned`), lu par la MÊME règle qu'`allow` et
|
|
321
|
+
par la route d'invitation — jamais une seconde table. Le modérateur est un attribut
|
|
322
|
+
d'appartenance, pas un rôle global ; l'entité d'appartenance se génère **sans ressource REST**
|
|
323
|
+
(`--no-controller`) — un CRUD ouvert laisserait chacun s'inviter.
|
|
324
|
+
- **Inviter** = une route (`@IsGranted` + contrôle « est modérateur ») qui écrit l'appartenance PUIS
|
|
325
|
+
prévient l'invité : `getRealtimeHub().publishToUser(invité, { kind: "invited", room })`. Son
|
|
326
|
+
écran s'abonne au salon, `allow` dit oui.
|
|
327
|
+
- **Nom exact contre motif** : le nom exact l'emporte et REMPLACE le motif en entier ; entre
|
|
328
|
+
motifs, le littéral le plus long gagne. Un canal servi par la fabrique sans aucune politique qui
|
|
329
|
+
le couvre est signalé une fois au journal (`… n'a aucune règle d'accès …`) : pose le motif.
|
|
330
|
+
- **Noms réservés** : `nodefony:` (plateforme) et les suffixes `:health` / `:stats` dans TOUT
|
|
331
|
+
espace de noms (refusés sans `ROLE_NODEFONY_ADMIN`). `user:<id>` est fourni (son propre id
|
|
332
|
+
seulement) : n'écris jamais de politique sur `user:…` sans le vouloir — elle remplacerait la
|
|
333
|
+
garantie « toi seul ».
|
|
334
|
+
- **Condition d'activation** : rôles, portées et `authenticated` ne sont jugés que si
|
|
335
|
+
`@nodefony/security` est chargé avec une zone qui couvre la socket ; sinon un WARNING au premier
|
|
336
|
+
handshake. `allow` s'applique toujours. **Ne ferme pas la zone pour fermer un canal** : la
|
|
337
|
+
politique se pose sur le canal.
|
|
338
|
+
|
|
339
|
+
## 6. Côté client — la façade, jamais le socket nu
|
|
340
|
+
|
|
341
|
+
<!-- excerpt: privateRoom.client.ts -->
|
|
123
342
|
|
|
124
343
|
```ts
|
|
125
344
|
import { NodefonySocket } from "nodefony/client";
|
|
126
345
|
|
|
127
|
-
const
|
|
128
|
-
|
|
346
|
+
const socket = NodefonySocket.shared({ url: "/api/room/realtime" });
|
|
347
|
+
|
|
348
|
+
/** Entrer dans un salon : une DEMANDE, que `allow` tranche côté serveur. */
|
|
349
|
+
export function joinRoom(
|
|
350
|
+
room: string,
|
|
351
|
+
onMessage: (message: unknown) => void,
|
|
352
|
+
): () => void {
|
|
353
|
+
const off = socket.on(room, onMessage);
|
|
354
|
+
socket.subscribe(room);
|
|
355
|
+
return () => {
|
|
356
|
+
off();
|
|
357
|
+
socket.unsubscribe(room);
|
|
358
|
+
};
|
|
359
|
+
}
|
|
129
360
|
```
|
|
130
361
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
362
|
+
| Méthode | Rôle |
|
|
363
|
+
| ---------------------------------------------------- | -------------------------------------------------------------------------- |
|
|
364
|
+
| `NodefonySocket.shared({ url })` | UNE socket par adresse ; les options ne comptent qu'à la première création |
|
|
365
|
+
| `subscribe(canal)` / `on(canal, h)` | DEMANDER le flux (rejoué à chaque reconnexion) / le RECEVOIR — deux gestes |
|
|
366
|
+
| `whenSubscribed(canal)` | attendre l'acquittement avant de charger l'état initial |
|
|
367
|
+
| `get` / `post` / `put` / `patch` / `delete(chemin…)` | appeler une route par la socket (§3) |
|
|
368
|
+
| `emit(nom, charge)` | pousser vers une entrée (`@RealtimeInbound`), sans réponse |
|
|
369
|
+
| `request(nom, params)` | appeler une action, avec réponse |
|
|
370
|
+
| `onDenied(h)` | refus : `forbidden`, `limit`, `unknown`, `unavailable` |
|
|
371
|
+
| `onClosed(h)` | fin imposée : `ended`, `evicted` — le client ne rejoue pas |
|
|
372
|
+
| `onUser(h)` | les messages adressés à CE compte |
|
|
373
|
+
| `onState(h)` / `onIdentity(h)` | connexion (`connected`…) / compte de la connexion |
|
|
374
|
+
|
|
375
|
+
- **Importe de `nodefony/client`**, jamais de la racine `nodefony` dans un bundle navigateur ;
|
|
376
|
+
`nodefony/realtime` n'existe pas.
|
|
377
|
+
- Vues : `nodefony/react` (`<NodefonyProvider url="…">`, `useNodefony()`,
|
|
378
|
+
`useNodefonyChannelData`, `useNodefonyChannel`), mêmes noms en `nodefony/vue`. Un composant monte
|
|
379
|
+
et démonte son abonnement ; la socket appartient à la page.
|
|
380
|
+
- Un refus de handshake (401/403) n'est vu par le navigateur que comme une coupure : la socket
|
|
381
|
+
sonde l'adresse en HTTP et s'arrête d'elle-même. Rien à écrire.
|
|
382
|
+
|
|
383
|
+
## 7. Plusieurs serveurs, redéploiement, révocation — résumé
|
|
384
|
+
|
|
385
|
+
Lis [`references/production.md`](references/production.md) avant de dire « fait ».
|
|
386
|
+
|
|
387
|
+
- Un canal reste sur son serveur tant que sa classe ne porte pas `@RealtimeBroadcast("prefix:")`
|
|
388
|
+
— sur TOUS les serveurs. L'application générée tourne en `backplane.driver: "cluster"` : deux
|
|
389
|
+
onglets sur un processus ne prouvent rien. L'oubli se compte (`localOnlyTotal`).
|
|
390
|
+
- **Le temps réel traverse les serveurs, l'ÉTAT non** : plusieurs serveurs exigent une base
|
|
391
|
+
partagée (SQLite local = des salons et des appartenances différents par serveur, donc des `allow`
|
|
392
|
+
différents) et un stockage de fichiers partagé.
|
|
393
|
+
- Noms de canaux = espace PLAT entre modules : préfixe-les par domaine. Livraison : au plus une
|
|
394
|
+
fois — la reconnexion rejoue les abonnements, pas les messages.
|
|
395
|
+
- Révocation : `4001` session morte, `4005` droits changés (revient aussitôt), `4006` jeton porteur
|
|
396
|
+
expiré ; `1013` client qui ne lit plus ; `1001` redéploiement (reconnexion silencieuse). Compare
|
|
397
|
+
aux constantes de `nodefony`, jamais aux nombres.
|
|
398
|
+
- Socket sur une autre origine que la page : `publicOrigin` (CSP `connect-src`).
|
|
399
|
+
|
|
400
|
+
## 8. SSE ou socket ?
|
|
401
|
+
|
|
402
|
+
| Besoin | Choisir |
|
|
403
|
+
| -------------------------------------------------------------------- | ------------------------------------------- |
|
|
404
|
+
| Le serveur pousse sur UNE requête (progression, réponse d'un modèle) | **SSE** — une réponse HTTP qui ne finit pas |
|
|
405
|
+
| Un réseau d'entreprise bloque les WebSocket | **SSE** — c'est une réponse HTTP ordinaire |
|
|
406
|
+
| Deux sens, canaux, plusieurs onglets, identité par connexion | **socket** — le reste de cette page |
|
|
407
|
+
|
|
408
|
+
`const sse = await this.renderSse()` dans une action ordinaire, puis `await sse.send(données, {
|
|
409
|
+
event })` et `sse.close()` ; `this.acceptsSse()` laisse la même route répondre en JSON ou en flux.
|
|
410
|
+
Client : `new NodefonySse(url)` (`nodefony/client`), liaison `useNodefonySse`. **N'écris jamais
|
|
411
|
+
`Content-Type: text/event-stream` à la main** : tu perdrais le battement qui empêche un proxy de
|
|
412
|
+
couper, et la contre-pression (`await sse.send()` freine si le client lit lentement). Un flux SSE
|
|
413
|
+
est gardé par la zone et `@IsGranted` de sa route, pas par une politique de canal. Exemple complet :
|
|
414
|
+
`node_modules/@nodefony/http/docs/sse.md`.
|
|
137
415
|
|
|
138
|
-
##
|
|
416
|
+
## 9. Un contrat, déclaré UNE fois
|
|
139
417
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
418
|
+
Dès que navigateur et serveur échangent plus qu'un canal : sans contrat, une méthode oubliée
|
|
419
|
+
compile puis rend `-32601`. Une interface de types seuls (`extends IRealtimeContract`), partagée
|
|
420
|
+
par les deux côtés : `NodefonySocket.fromContract<C>()` au navigateur, `ContractRealtimeController<C>`
|
|
421
|
+
au serveur, `@RealtimePolicy` sur la classe. Les quatre clés, leurs pièges et le contrat du chat :
|
|
422
|
+
[`references/contrat.md`](references/contrat.md). **Le type n'est pas une validation.**
|
|
143
423
|
|
|
144
|
-
## Prouver
|
|
424
|
+
## 10. Prouver
|
|
145
425
|
|
|
146
426
|
```bash
|
|
147
427
|
npm test
|
|
148
|
-
npx nodefony inspect routes --json
|
|
428
|
+
npx nodefony inspect routes --json
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
Le harnais `createRealtimeHarness` (`@nodefony/realtime/testing`) parle le protocole sans serveur
|
|
432
|
+
ni navigateur ; l'identité et le VRAI verrou de trames viennent de `@nodefony/security/testing`.
|
|
433
|
+
Le patron (salon privé, cas exécuté complet dans `cas-d-usage.md`) :
|
|
434
|
+
|
|
435
|
+
<!-- excerpt: privateRoom.test.ts -->
|
|
436
|
+
|
|
437
|
+
```ts
|
|
438
|
+
/** Une connexion par compte, identifiée par son identifiant EN BASE. */
|
|
439
|
+
async function connect(name: string): Promise<IRealtimeHarness<RoomRealtime>> {
|
|
440
|
+
// Le service et l'admission passés à la main, comme l'injecteur en production.
|
|
441
|
+
const h = createRealtimeHarness((ctx) => new RoomRealtime(ctx, rooms), {
|
|
442
|
+
identity: createTestIdentity({ identifier: name, id: idOf(name) }),
|
|
443
|
+
admissions: [new RoomAdmission(rooms)],
|
|
444
|
+
resetHub: open.length === 0,
|
|
445
|
+
});
|
|
446
|
+
await h.connect();
|
|
447
|
+
open.push(h);
|
|
448
|
+
return h;
|
|
449
|
+
}
|
|
149
450
|
```
|
|
150
451
|
|
|
151
|
-
|
|
152
|
-
compte sans le rôle non plus, l'administrateur oui — **et le canal public du scaffold répond
|
|
153
|
-
toujours** (s'il s'est fermé, une politique a débordé).
|
|
452
|
+
<!-- excerpt: privateRoom.test.ts -->
|
|
154
453
|
|
|
155
|
-
|
|
454
|
+
```ts
|
|
455
|
+
it("exclu : `evicted` tout de suite, puis refusé à la page rechargée", async () => {
|
|
456
|
+
const carol = await connect("carol");
|
|
457
|
+
await carol.subscribe(channel);
|
|
458
|
+
await flush();
|
|
459
|
+
await rooms.exclude(channel, idOf("carol"));
|
|
460
|
+
expect(closedOf(carol)).toEqual([{ channel, reason: "evicted" }]);
|
|
461
|
+
const reloaded = await connect("carol"); // une page rechargée refait `subscribe`
|
|
462
|
+
await reloaded.subscribe(channel);
|
|
463
|
+
await flush();
|
|
464
|
+
expect(reloaded.denials()).toHaveLength(1);
|
|
465
|
+
});
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
- Sans `identity` : **anonyme**. `resetHub` au premier harnais seulement ; `subscriptions()`,
|
|
469
|
+
`denials()`, `messages(canal)` : acquittés, refus, reçus.
|
|
470
|
+
- **`roles` / `authenticated` ne sont jugés qu'avec un verrou** : passe
|
|
471
|
+
`frameAuthorizer: createTestFrameAuthorizer(getRealtimeHub())` au harnais, sinon la politique
|
|
472
|
+
n'est appliquée par personne — et le test est vert pour la mauvaise raison.
|
|
473
|
+
- 🔴 **`allow` lit la base ? Le test MONTE la base**, sur le connecteur que lit le service
|
|
474
|
+
(`const CONNECTOR = "default"`) — sinon `aucun connecteur « default »`, et le harnais rend
|
|
475
|
+
`socket fermée (code 1011)` : ni l'Origin ni l'authentification, le service est sans base.
|
|
476
|
+
Dans le `beforeAll` :
|
|
477
|
+
|
|
478
|
+
<!-- excerpt: privateRoom.test.ts -->
|
|
479
|
+
|
|
480
|
+
```ts
|
|
481
|
+
for (const entity of ENTITIES) {
|
|
482
|
+
entityRegistry.register({ ...entity, connector: CONNECTOR });
|
|
483
|
+
}
|
|
484
|
+
orm = new DrizzleOrm(CONNECTOR, { filename: ":memory:" }); // en mémoire, rien à installer
|
|
485
|
+
await orm.connect();
|
|
486
|
+
```
|
|
156
487
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
488
|
+
Puis sème les PARENTS (`seedEntityRow`) : `FOREIGN KEY constraint failed` = un parent non
|
|
489
|
+
semé, jamais une raison de retirer le test. Fichier complet : salon privé de `cas-d-usage.md`.
|
|
490
|
+
|
|
491
|
+
- **Vois le test rouge une fois** : retire `allow` (ou `roles`), l'intrus doit entrer ; remets-le.
|
|
492
|
+
L'exclusion se prouve par `evicted` PUIS un `subscribe` refusé — un refus opposé à qui n'a jamais
|
|
493
|
+
été membre ne prouve rien.
|
|
494
|
+
- Une route du pont se prouve aussi en HTTP (même route, `POST`).
|
|
495
|
+
- La santé en marche : `GET /nodefony/realtime/api/health` (rôle d'administration) —
|
|
496
|
+
`publishTotal` qui monte sans `fanoutTotal` = tu publies dans le vide (nom mal orthographié).
|
|
497
|
+
|
|
498
|
+
## 11. Voisins
|
|
499
|
+
|
|
500
|
+
| Besoin | Skill |
|
|
501
|
+
| --------------------------------------------------------- | -------------------------------- |
|
|
502
|
+
| Qui est en ligne, combien, « en train d'écrire » | `nodefony-add-realtime-presence` |
|
|
503
|
+
| Des octets : image, audio, mesures binaires, CBOR | `nodefony-add-realtime-binary` |
|
|
504
|
+
| Réserver une route HTTP, une page | `nodefony-protect-route` |
|
|
505
|
+
| Une ressource stockée (l'entité d'appartenance, messages) | `nodefony-add-crud` |
|
|
506
|
+
| Un service métier injectable (la classe d'admission) | `nodefony-add-service` |
|
|
507
|
+
| Voir l'écran, piloter une socket depuis la page | `nodefony-browser` |
|
|
508
|
+
| Un canal qui répondait ne répond plus | `nodefony-troubleshoot` |
|