@nodefony/realtime 10.0.0-beta.2 → 10.0.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/index.js +18 -13
- package/dist/nodefony/config/config.js +7 -5
- package/dist/nodefony/config/configSchema.js +18 -0
- package/dist/nodefony/config/defineModuleConfig.js +43 -17
- package/dist/nodefony/decorators/realtimeDecorators.js +69 -19
- package/dist/nodefony/interfaces/IRealtimeConnectionState.js +1 -0
- package/dist/nodefony/src/backplane/ClusterBackplane.js +1 -2
- package/dist/nodefony/src/backplane/LoopbackBackplane.js +1 -2
- package/dist/nodefony/src/backplane/RedisBackplane.js +22 -6
- package/dist/nodefony/src/backplane/backplaneRegistry.js +1 -2
- package/dist/nodefony/src/backplane/bootNotices.js +42 -0
- package/dist/nodefony/src/backplane/envelope.js +1 -2
- package/dist/nodefony/src/backplane/originId.js +1 -2
- package/dist/nodefony/src/backplane/publishQueue.js +1 -5
- package/dist/nodefony/src/cluster/ClusterProbeClient.js +16 -6
- package/dist/nodefony/src/guards.js +40 -0
- package/dist/nodefony/src/server/AnonymousRealtimeToken.js +2 -6
- package/dist/nodefony/src/server/RealtimeAdminApi.js +1 -2
- package/dist/nodefony/src/server/RealtimeController.js +226 -82
- package/dist/nodefony/src/server/RealtimeHub.js +32 -54
- package/dist/nodefony/src/server/ServerRealtimeSocket.js +30 -13
- package/dist/nodefony/src/server/deniedDetail.js +1 -2
- package/dist/nodefony/src/server/subscribedAck.js +29 -0
- package/dist/nodefony/src/server/syslogUplink.js +5 -4
- package/dist/nodefony/src/server/welcomeEnv.js +1 -2
- package/dist/nodefony/src/service/RealtimeService.js +4 -4
- package/dist/nodefony/src/transport/WsConnectionTransport.js +42 -2
- package/dist/nodefony/testing/index.js +118 -5
- package/dist/types/index.d.ts +1 -0
- package/dist/types/nodefony/config/configSchema.d.ts +12 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +29 -6
- package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +19 -2
- package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +40 -0
- package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +1 -1
- package/dist/types/nodefony/interfaces/IRealtimeHandshake.d.ts +1 -1
- package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +13 -0
- package/dist/types/nodefony/interfaces/index.d.ts +1 -0
- package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +7 -0
- package/dist/types/nodefony/src/backplane/bootNotices.d.ts +25 -0
- package/dist/types/nodefony/src/guards.d.ts +32 -0
- package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +13 -0
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +82 -4
- package/dist/types/nodefony/src/server/RealtimeHub.d.ts +22 -16
- package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +18 -8
- package/dist/types/nodefony/src/server/subscribedAck.d.ts +23 -0
- package/dist/types/nodefony/src/service/RealtimeService.d.ts +1 -1
- package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +26 -1
- package/dist/types/nodefony/testing/index.d.ts +8 -3
- package/docs/actions.md +129 -45
- package/docs/architecture.md +143 -135
- package/docs/cadence-et-contre-pression.md +643 -0
- package/docs/configuration.md +126 -97
- package/docs/cookbook-chat.md +50 -43
- package/docs/index.md +63 -38
- package/docs/observabilite.md +58 -56
- package/docs/protocole.md +110 -76
- package/docs/securite.md +128 -117
- package/docs/vocabulaire.md +133 -129
- package/package.json +17 -13
- package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
- package/docs/couverture-bancs.md +0 -139
package/docs/actions.md
CHANGED
|
@@ -117,7 +117,7 @@ attendre trop longtemps et repartir.
|
|
|
117
117
|
|
|
118
118
|
Techniquement, ce numéro est le champ `id` de la frame JSON-RPC 2.0. Le pair l'attribue, garde
|
|
119
119
|
l'appel en attente dans une table, arme une minuterie, et résout la `Promise` quand la réponse
|
|
120
|
-
portant cet `id` revient (`JsonRpcPeer.handleResponse()`, `JsonRpcPeer.ts
|
|
120
|
+
portant cet `id` revient (`JsonRpcPeer.handleResponse()`, `JsonRpcPeer.ts#JsonRpcPeer.handleResponse`). Une frame sans
|
|
121
121
|
`id` ne crée aucune de ces trois choses — c'est pourquoi une publication ne coûte rien au repos.
|
|
122
122
|
|
|
123
123
|
## La vision Nodefony — un nom, un handler, une découverte
|
|
@@ -126,18 +126,18 @@ Trois partis pris distinguent les actions Nodefony d'une couche RPC classique.
|
|
|
126
126
|
|
|
127
127
|
**Il n'y a pas de couche RPC séparée.** Une action est une **méthode de contrôleur**, dans la même
|
|
128
128
|
classe que tes canaux et — si tu le veux — que tes routes HTTP. Pas de service dédié, pas de
|
|
129
|
-
schéma à compiler : `@RealtimeAction("orders:quote")` (`realtimeDecorators.ts
|
|
129
|
+
schéma à compiler : `@RealtimeAction("orders:quote")` (`realtimeDecorators.ts#RealtimeAction`) suffit, et le
|
|
130
130
|
retour de la méthode devient le `result` de la réponse.
|
|
131
131
|
|
|
132
132
|
**Le contrat du handler est minuscule, volontairement.** Une action reçoit **un seul argument**,
|
|
133
133
|
les paramètres bruts du client, et rend une valeur — `RpcActionHandler`
|
|
134
|
-
(`JsonRpcPeer.ts
|
|
134
|
+
(`JsonRpcPeer.ts#RpcActionHandler`). Pas de contexte injecté dans la signature : le `this` est lié à
|
|
135
135
|
l'instance du contrôleur au handshake, ce qui donne accès au noyau, aux services et à la
|
|
136
136
|
connexion sans élargir le contrat.
|
|
137
137
|
|
|
138
138
|
**L'endpoint s'annonce lui-même.** La liste des actions exposées voyage dans la frame d'accueil —
|
|
139
|
-
`IRealtimeWelcome` (`RealtimeController.ts
|
|
140
|
-
`
|
|
139
|
+
`IRealtimeWelcome` (`RealtimeController.ts`) — et se lit côté client par
|
|
140
|
+
`NodefonySocket.serverMethods` (`NodefonySocket.ts#NodefonySocket.serverMethods`). Une interface n'écrit donc jamais un nom d'action en dur : elle
|
|
141
141
|
n'active un bouton que si le serveur a déclaré savoir le servir.
|
|
142
142
|
|
|
143
143
|
**Le compromis, dit franchement** : une action est **un aller-retour**, point. Elle ne diffuse pas,
|
|
@@ -199,7 +199,7 @@ export default OrdersRealtimeController;
|
|
|
199
199
|
|
|
200
200
|
```ts
|
|
201
201
|
// frontend/src/orders.ts
|
|
202
|
-
import {
|
|
202
|
+
import { NodefonySocket } from "nodefony/client";
|
|
203
203
|
|
|
204
204
|
interface IQuote {
|
|
205
205
|
orderId: string;
|
|
@@ -207,7 +207,7 @@ interface IQuote {
|
|
|
207
207
|
currency: string;
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
-
const socket =
|
|
210
|
+
const socket = NodefonySocket.shared({
|
|
211
211
|
url: "wss://127.0.0.1:5152/orders/realtime",
|
|
212
212
|
});
|
|
213
213
|
|
|
@@ -274,20 +274,20 @@ Le trajet complet d'une requête, des étapes qu'elle traverse aux branches par
|
|
|
274
274
|
|
|
275
275
|
### Deux voies, un même registre
|
|
276
276
|
|
|
277
|
-
| Voie
|
|
278
|
-
|
|
|
279
|
-
| `@RealtimeAction("nom")` (`realtimeDecorators.ts
|
|
280
|
-
| `realtimeActions()` (`RealtimeController.ts
|
|
277
|
+
| Voie | Quand la choisir |
|
|
278
|
+
| -------------------------------------------------------------------------------- | ----------------------------------------------------------- |
|
|
279
|
+
| `@RealtimeAction("nom")` (`realtimeDecorators.ts#RealtimeAction`) | le cas courant — un nom fixe, une méthode, déclaratif |
|
|
280
|
+
| `realtimeActions()` (`RealtimeController.ts#RealtimeController.realtimeActions`) | la table est **calculée** (noms dynamiques, boucle, config) |
|
|
281
281
|
|
|
282
282
|
Les deux sont fusionnées au handshake, et **l'override gagne** en cas de conflit de nom : une
|
|
283
283
|
classe peut ainsi remplacer une action héritée sans toucher au parent
|
|
284
|
-
(`RealtimeController.ts
|
|
284
|
+
(`RealtimeController.ts#RealtimeController.onHandshake`). Un endpoint sans aucune action ne paie rien — la table du pair est
|
|
285
285
|
allouée au premier enregistrement seulement.
|
|
286
286
|
|
|
287
287
|
### Ce que ton `return` et ton `throw` deviennent sur le fil
|
|
288
288
|
|
|
289
289
|
Le retour du handler est envoyé tel quel en `result`. Les erreurs, elles, suivent une règle Zero
|
|
290
|
-
Trust stricte, appliquée dans `JsonRpcPeer.handleRequest()` (`JsonRpcPeer.ts
|
|
290
|
+
Trust stricte, appliquée dans `JsonRpcPeer.handleRequest()` (`JsonRpcPeer.ts#JsonRpcPeer.handleRequest`) :
|
|
291
291
|
|
|
292
292
|
| Côté serveur | Ce que reçoit le client | Pourquoi |
|
|
293
293
|
| ------------------------------- | ---------------------------------------- | ----------------------------------------------- |
|
|
@@ -299,7 +299,7 @@ Trust stricte, appliquée dans `JsonRpcPeer.handleRequest()` (`JsonRpcPeer.ts:49
|
|
|
299
299
|
|
|
300
300
|
> [!WARNING]
|
|
301
301
|
> **Le temps réel n'a qu'UNE erreur, et elle vient du cœur.** Pour choisir ce que voit le client,
|
|
302
|
-
> c'est `RpcError` (`JsonRpcPeer.ts
|
|
302
|
+
> c'est `RpcError` (`JsonRpcPeer.ts#RpcError`), importée depuis `nodefony` — son `code` est un entier
|
|
303
303
|
> JSON-RPC et son `data` traverse le fil. Toute autre exception donne un `-32603` opaque. Ne pas
|
|
304
304
|
> chercher d'erreur maison dans `@nodefony/realtime` : il n'y en a pas, et
|
|
305
305
|
> `tests/unit/errorSurface.test.ts` refuse d'en exporter une que personne ne lève.
|
|
@@ -313,8 +313,8 @@ Trust stricte, appliquée dans `JsonRpcPeer.handleRequest()` (`JsonRpcPeer.ts:49
|
|
|
313
313
|
|
|
314
314
|
Cette défense est le **verrou de frame**, posé par `@nodefony/security`. Il examine chaque frame
|
|
315
315
|
entrante et, pour une action, résout une politique **par son nom** — exactement le mécanisme des
|
|
316
|
-
canaux (`buildFrameAuthorizer()`, `frameAuthorizer.ts
|
|
317
|
-
`frameAuthorizer.ts
|
|
316
|
+
canaux (`buildFrameAuthorizer()`, `frameAuthorizer.ts#buildFrameAuthorizer` ; branche des méthodes,
|
|
317
|
+
`frameAuthorizer.ts#buildFrameAuthorizer`). Trois situations, à connaître dans cet ordre :
|
|
318
318
|
|
|
319
319
|
### Situation 1 — une action est FERMÉE par défaut
|
|
320
320
|
|
|
@@ -345,42 +345,76 @@ homonyme), elle est conservée : le défaut ne rétrograde jamais une règle plu
|
|
|
345
345
|
### Situation 2 — les namespaces réservés portent un plancher
|
|
346
346
|
|
|
347
347
|
Un nom qui commence par `nodefony:` hérite d'un plancher non contournable : authentifié **et**
|
|
348
|
-
`ROLE_NODEFONY_ADMIN` (`SYSTEM_CHANNEL_POLICY`, `frameAuthorizer.ts
|
|
348
|
+
`ROLE_NODEFONY_ADMIN` (`SYSTEM_CHANNEL_POLICY`, `frameAuthorizer.ts#SYSTEM_CHANNEL_POLICY`) — un rôle de la plateforme,
|
|
349
349
|
pas le `ROLE_ADMIN` d'une organisation. Un seul préfixe couvre donc tout
|
|
350
|
-
ce que la plateforme expose (`DEFAULT_SYSTEM_PREFIXES`, `frameAuthorizer.ts
|
|
350
|
+
ce que la plateforme expose (`DEFAULT_SYSTEM_PREFIXES`, `frameAuthorizer.ts#DEFAULT_SYSTEM_PREFIXES`). C'est ce qui
|
|
351
351
|
protège `nodefony:kernel:gc` : le nom **est** la garde, et la comparaison est insensible à la casse
|
|
352
352
|
pour qu'un `NODEFONY:kernel:gc` ne passe pas à côté.
|
|
353
353
|
|
|
354
354
|
Ce plancher **prime sur ta déclaration** — y compris sur `{ authenticated: false }`. Et il ne
|
|
355
355
|
s'arrête pas aux préfixes : tout nom **contenant** `:health` ou `:stats` est traité comme un canal
|
|
356
|
-
d'observabilité (`matchSystemPolicy`, `frameAuthorizer.ts
|
|
356
|
+
d'observabilité (`matchSystemPolicy`, `frameAuthorizer.ts#matchSystemPolicy`). Une action `public:health` reste
|
|
357
357
|
donc réservée, quoi qu'en dise son décorateur. À savoir avant de nommer une action « ouverte ».
|
|
358
358
|
|
|
359
359
|
### Situation 3 — couvrir SES actions par la configuration
|
|
360
360
|
|
|
361
361
|
Pour exiger un rôle sur tes propres actions, on déclare une règle de préfixe dans la
|
|
362
362
|
configuration de sécurité — la même liste que pour les canaux
|
|
363
|
-
(`realtimeChannels`, `security/nodefony/config/config.ts
|
|
363
|
+
(`realtimeChannels`, `security/nodefony/config/config.ts#securityConfigSchema`) :
|
|
364
364
|
|
|
365
365
|
```ts ignore
|
|
366
366
|
use("@nodefony/security", {
|
|
367
367
|
realtimeChannels: [
|
|
368
368
|
// Couvre AUSSI bien le canal `orders:feed` que l'action `orders:quote` :
|
|
369
369
|
// le verrou résout une politique par NOM, sans savoir ce qu'il garde.
|
|
370
|
-
{
|
|
371
|
-
prefix: "orders:",
|
|
372
|
-
policy: { authenticated: true, roles: ["ROLE_USER"] },
|
|
373
|
-
},
|
|
370
|
+
{ pattern: "orders:", authenticated: true, roles: ["ROLE_USER"] },
|
|
374
371
|
],
|
|
375
372
|
});
|
|
376
373
|
```
|
|
377
374
|
|
|
375
|
+
### Savoir QUI appelle — `@CurrentUser` et les paramètres décorés
|
|
376
|
+
|
|
377
|
+
Une action authentifiée sait que l'appelant est identifié ; elle peut aussi savoir **qui** il est.
|
|
378
|
+
Les décorateurs de paramètres d'une route y prennent le même sens
|
|
379
|
+
(`actionParamsWrapper`, `RealtimeController.ts#RealtimeController.actionParamsWrapper`) :
|
|
380
|
+
|
|
381
|
+
```ts ignore
|
|
382
|
+
import { Body, CurrentUser } from "@nodefony/framework";
|
|
383
|
+
import type { IUser } from "@nodefony/user";
|
|
384
|
+
|
|
385
|
+
// Le client : socket.request("chat:send", { text: "salut" })
|
|
386
|
+
@RealtimeAction("chat:send")
|
|
387
|
+
send(@Body("text") text: string, @CurrentUser() user: IUser) {
|
|
388
|
+
// L'auteur vient du SERVEUR : le client ne peut pas mentir dessus.
|
|
389
|
+
return { author: user.identifier, text };
|
|
390
|
+
}
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
| Décorateur | Ce qu'il reçoit dans une action |
|
|
394
|
+
| ----------------------------------------- | ----------------------------------------------------------- |
|
|
395
|
+
| `@Body()` / `@Body("clé")` | la charge de l'appel (`params`), ou l'une de ses clés |
|
|
396
|
+
| `@CurrentUser()` | l'utilisateur de la connexion, `undefined` pour un anonyme |
|
|
397
|
+
| `@Query()` / `@Param()` | un objet vide — une action n'a ni URL ni variables de route |
|
|
398
|
+
| `@Headers()` / `@Cookie()` / `@Session()` | ceux de la requête d'ouverture de la socket |
|
|
399
|
+
|
|
400
|
+
Une action **sans** paramètre décoré reçoit la charge brute en premier argument, comme avant —
|
|
401
|
+
rien ne change pour elle, ni comportement ni coût.
|
|
402
|
+
|
|
403
|
+
> [!IMPORTANT]
|
|
404
|
+
> **L'identité est celle de l'ouverture de la socket.** Elle n'est pas relue à chaque appel : le
|
|
405
|
+
> hub la revalide toutes les 30 secondes (`REVOCATION_REVALIDATE_MS`, `RealtimeHub.ts#channelFamily`) et
|
|
406
|
+
> ferme la socket en `4001` si la session est morte (`RealtimeHub.ts#RealtimeHub.probe`). Après une
|
|
407
|
+
> déconnexion, une action peut donc encore voir l'ancien utilisateur pendant cette fenêtre. Pour
|
|
408
|
+
> une écriture sensible — paiement, droits, suppression — passe par une route
|
|
409
|
+
> `POST + WEBSOCKET` appelée par `socket.mutate()` : ce pont revalide l'identité à **chaque**
|
|
410
|
+
> appel et porte la clé d'idempotence.
|
|
411
|
+
|
|
378
412
|
### La règle DEV-only
|
|
379
413
|
|
|
380
414
|
Une action réservée au développement se contrôle **dans le handler, côté serveur** — jamais par un
|
|
381
415
|
drapeau d'interface. Cacher un bouton n'empêche personne de forger la frame. Le modèle est
|
|
382
416
|
`nodefony:scaffold:run` dans Studio, qui refuse net hors développement avant même de lire ses paramètres
|
|
383
|
-
(`StudioRealtimeController.ts
|
|
417
|
+
(`StudioRealtimeController.ts#StudioRealtimeController.actionScaffoldRun`).
|
|
384
418
|
|
|
385
419
|
> [!CAUTION]
|
|
386
420
|
> Le défaut fermé protège de l'oubli, pas de la sur-ouverture. Une action mutable reste la surface
|
|
@@ -394,21 +428,21 @@ drapeau d'interface. Cacher un bouton n'empêche personne de forger la frame. Le
|
|
|
394
428
|
### Le délai d'expiration est la seule libération automatique
|
|
395
429
|
|
|
396
430
|
`request()` prend le délai en **troisième argument positionnel**, en millisecondes — il n'y a pas
|
|
397
|
-
d'objet d'options (`
|
|
431
|
+
d'objet d'options (`NodefonySocket.request()`, `NodefonySocket.ts#NodefonySocket.request`) :
|
|
398
432
|
|
|
399
433
|
```ts ignore
|
|
400
434
|
await socket.request("orders:quote", { orderId }); // 30 000 ms par défaut
|
|
401
435
|
await socket.request("orders:export", { scope }, 120_000); // action longue
|
|
402
436
|
```
|
|
403
437
|
|
|
404
|
-
| Défaut | Valeur | Ancrage
|
|
405
|
-
| --------- | --------- |
|
|
406
|
-
| `request` | 30 000 ms | `JsonRpcPeer.ts
|
|
438
|
+
| Défaut | Valeur | Ancrage |
|
|
439
|
+
| --------- | --------- | ------------------------------------ |
|
|
440
|
+
| `request` | 30 000 ms | `JsonRpcPeer.ts#JsonRpcPeer.request` |
|
|
407
441
|
|
|
408
442
|
À l'expiration, l'entrée en attente est **retirée** et la `Promise` rejetée avec
|
|
409
|
-
`RPC timeout: <méthode>` (`JsonRpcPeer.ts
|
|
443
|
+
`RPC timeout: <méthode>` (`JsonRpcPeer.ts#JsonRpcPeer.startCall`). Conséquence à connaître : une réponse qui
|
|
410
444
|
arriverait après coup ne trouve plus personne et est ignorée en silence
|
|
411
|
-
(`JsonRpcPeer.ts
|
|
445
|
+
(`JsonRpcPeer.ts#JsonRpcPeer.handleResponse`) — pas de résolution tardive, pas de fuite.
|
|
412
446
|
|
|
413
447
|
### Abandonner : ce qui existe vraiment
|
|
414
448
|
|
|
@@ -422,11 +456,11 @@ Trois leviers existent, et ils ne font pas la même chose :
|
|
|
422
456
|
|
|
423
457
|
1. **Le délai d'expiration** — libère le client, laisse le serveur travailler.
|
|
424
458
|
2. **La fermeture de la connexion** — `dispose()` rejette **tous** les appels en attente d'un
|
|
425
|
-
coup (`JsonRpcPeer.ts
|
|
426
|
-
(`RealtimeController.ts
|
|
459
|
+
coup (`JsonRpcPeer.ts#JsonRpcPeer.dispose`), appelé au nettoyage de la socket
|
|
460
|
+
(`RealtimeController.ts#RealtimeController.onHandshake`). Là encore : côté client seulement.
|
|
427
461
|
3. **Une action compagnon** — la seule vraie annulation. On expose une seconde action qui prend
|
|
428
462
|
l'identifiant du travail et l'interrompt côté serveur. Le modèle du dépôt est
|
|
429
|
-
`nodefony:scaffold:cancel` (`StudioRealtimeController.ts
|
|
463
|
+
`nodefony:scaffold:cancel` (`StudioRealtimeController.ts#StudioRealtimeController.actionScaffoldRun`), pendant de `nodefony:scaffold:run`.
|
|
430
464
|
|
|
431
465
|
### Rejouer : idempotence par action
|
|
432
466
|
|
|
@@ -444,8 +478,8 @@ fois ? »**.
|
|
|
444
478
|
| une mutation (`socket.mutate`) | **non** par nature | oui, **avec une clé d'idempotence** |
|
|
445
479
|
|
|
446
480
|
Pour les mutations passant par le pont API, la clé n'est pas une convention : elle est **exigée
|
|
447
|
-
par la signature** de `mutate()` (`
|
|
448
|
-
(`routerDecorators.ts
|
|
481
|
+
par la signature** de `mutate()` (`NodefonySocket.ts#NodefonySocket.mutate`), et c'est la garde `@Idempotent`
|
|
482
|
+
(`routerDecorators.ts#Idempotent`) qui, côté serveur, reconnaît le rejeu et rend la réponse déjà calculée
|
|
449
483
|
au lieu de refaire l'effet.
|
|
450
484
|
|
|
451
485
|
```ts ignore
|
|
@@ -471,11 +505,11 @@ contraire des résultats **au fil de l'eau**, pendant des minutes.
|
|
|
471
505
|
Le remède tient en trois gestes, et il est déjà en production dans Studio :
|
|
472
506
|
|
|
473
507
|
1. **L'action lance et rend un identifiant**, sans attendre — `nodefony:scaffold:run`
|
|
474
|
-
(`StudioRealtimeController.ts
|
|
508
|
+
(`StudioRealtimeController.ts#StudioRealtimeController.actionScaffoldRun`) démarre le travail et rend son état, dont son `id`.
|
|
475
509
|
2. **Le flux se diffuse sur un canal dédié**, nommé d'après ce travail — `nodefony:scaffold:job@<id>`,
|
|
476
|
-
servi par le contrôleur (`StudioRealtimeController.ts
|
|
510
|
+
servi par le contrôleur (`StudioRealtimeController.ts#StudioRealtimeController.actionScaffoldCancel`).
|
|
477
511
|
3. **Le client s'abonne à ce canal** et regarde le travail se faire, ligne après ligne
|
|
478
|
-
(`Create.tsx
|
|
512
|
+
(`Create.tsx#Create`).
|
|
479
513
|
|
|
480
514
|
### La course, et comment le dépôt la neutralise
|
|
481
515
|
|
|
@@ -485,7 +519,7 @@ prudente est donc de **s'abonner avant** de lancer l'appel.
|
|
|
485
519
|
|
|
486
520
|
Nodefony a choisi la garantie côté serveur plutôt que la discipline côté client : le producteur du
|
|
487
521
|
canal **rejoue son historique** au nouvel abonné, de sorte qu'un arrivant tardif voit tout depuis
|
|
488
|
-
le début (`ScaffoldService.subscribe()`, `ScaffoldService.ts
|
|
522
|
+
le début (`ScaffoldService.subscribe()`, `ScaffoldService.ts#ScaffoldService.subscribe`). C'est ce qui autorise le front
|
|
489
523
|
à faire l'appel d'abord et à s'abonner ensuite, sans rien perdre.
|
|
490
524
|
|
|
491
525
|
> [!TIP]
|
|
@@ -495,7 +529,7 @@ le début (`ScaffoldService.subscribe()`, `ScaffoldService.ts:456`). C'est ce qu
|
|
|
495
529
|
|
|
496
530
|
### Et le streaming du protocole ?
|
|
497
531
|
|
|
498
|
-
**Il n'y en a pas.** Une action rend **une** valeur (`RpcActionHandler`, `JsonRpcPeer.ts
|
|
532
|
+
**Il n'y en a pas.** Une action rend **une** valeur (`RpcActionHandler`, `JsonRpcPeer.ts#RpcActionHandler`), que
|
|
499
533
|
le pair emballe en une réponse unique. Pour tout ce qui progresse — une réponse mot à mot d'un
|
|
500
534
|
modèle de langage, un export qui avance, un traitement long — la voie est le motif
|
|
501
535
|
**« travail + canal »** : l'action accuse réception, et la progression arrive sur un canal.
|
|
@@ -510,15 +544,15 @@ il sera conçu avec son premier consommateur réel.
|
|
|
510
544
|
Un cas particulier mérite d'être connu avant d'écrire une action : **elle existe peut-être déjà en
|
|
511
545
|
HTTP**. Le pont API expose la méthode `api.request`, qui rejoue une route de contrôleur sur la
|
|
512
546
|
socket, avec la même garde et le même résultat qu'en REST — `invokeApiRequest()`
|
|
513
|
-
(`RealtimeController.ts
|
|
514
|
-
`realtimeApiRequest()` (`RealtimeController.ts
|
|
547
|
+
(`RealtimeController.ts#RealtimeController.invokeApiRequest`). Il est **désactivé par défaut** et s'active en surchargeant
|
|
548
|
+
`realtimeApiRequest()` (`RealtimeController.ts#RealtimeController.realtimeApiRequest`).
|
|
515
549
|
|
|
516
550
|
```ts ignore
|
|
517
551
|
const modules = await socket.request("/nodefony/kernel/api/modules");
|
|
518
552
|
```
|
|
519
553
|
|
|
520
554
|
La forme se discrimine toute seule : un chemin commence par `/`, jamais un nom d'action —
|
|
521
|
-
`
|
|
555
|
+
`NodefonySocket.request()` (`NodefonySocket.ts#NodefonySocket.request`). Écris une action RPC pour ce qui n'a de sens **que** sur la socket ;
|
|
522
556
|
passe par le pont pour tout ce qui est déjà une route. Le détail du pont vit dans le
|
|
523
557
|
[vocabulaire](./vocabulaire.md) et l'[architecture](./architecture.md).
|
|
524
558
|
|
|
@@ -530,6 +564,7 @@ passe par le pont pour tout ce qui est déjà une route. Le détail du pont vit
|
|
|
530
564
|
| Le client attend indéfiniment, aucune erreur | le handler ne rend jamais (il publie au lieu de retourner) — pas de frame `result` | toujours `return` une valeur ; publier **en plus**, jamais **à la place** |
|
|
531
565
|
| `-32601 method not found` | nom mal orthographié, ou action déclarée sur un **autre** endpoint | vérifier `socket.serverMethods` — c'est la liste réelle de CETTE connexion |
|
|
532
566
|
| `-32603 internal error` sans détail | un `throw` ordinaire est rendu opaque au client (Zero Trust) | lever une `RpcError` avec un code et un message publiables |
|
|
567
|
+
| `api.request` rejeté avec `data.status: 422` | la route invoquée valide son entrée (`schema`, ou une `ZodError` de l'action) | lire `data.fields` : chaque champ fautif, sa partie (`in`) et son message |
|
|
533
568
|
| `-32001 unauthorized` sur une action légitime | le nom commence par `nodefony:`, le namespace réservé à la plateforme | renommer hors de `nodefony:`, ou obtenir `ROLE_NODEFONY_ADMIN` |
|
|
534
569
|
| Une action est refusée à un anonyme alors qu'on la croyait publique | une action est fermée par défaut (`{ authenticated: true }`) | déclarer `{ authenticated: false }` pour l'ouvrir ; un rôle se pose par une règle de préfixe |
|
|
535
570
|
| Un travail relancé crée deux jobs | action non idempotente rejouée après une reconnexion | action compagnon d'annulation, ou identifiant fourni par l'appelant + mémorisation |
|
|
@@ -548,7 +583,7 @@ vivent dans la carte de l'aperçu, régénérée depuis les résultats réels, j
|
|
|
548
583
|
- **Unitaires, déclaration** (`realtimeDecorators.test.ts`) : `@RealtimeAction` enregistre bien le
|
|
549
584
|
nom et lie le `this` ; (`RealtimeController.test.ts`) : fusion décorateurs + override et
|
|
550
585
|
annonce dans l'accueil.
|
|
551
|
-
- **Unitaires, client** (`
|
|
586
|
+
- **Unitaires, client** (`NodefonySocketCoverage.test.ts`) : formes de `request`, contrat de
|
|
552
587
|
streaming, rejet des appels en attente au `dispose`.
|
|
553
588
|
- **Attaque, autorisation** (`realtimeFrameLock.test.ts`) : `nodefony:kernel:ping` et `nodefony:kernel:gc` refusés à
|
|
554
589
|
l'anonyme **et** à l'utilisateur authentifié, acceptés à l'administrateur — la preuve que le
|
|
@@ -559,6 +594,55 @@ vivent dans la carte de l'aperçu, régénérée depuis les résultats réels, j
|
|
|
559
594
|
fan-out, pas celui des appels corrélés). Pour en monter un, voir le skill
|
|
560
595
|
`nodefony-load-test` ; pour la mémoire, `nodefony-check-memory-health`.
|
|
561
596
|
|
|
597
|
+
### Éprouver SON action — le harnais, identité comprise
|
|
598
|
+
|
|
599
|
+
Une application teste ses actions sans serveur ni navigateur avec `createRealtimeHarness`
|
|
600
|
+
(`@nodefony/realtime/testing`, `nodefony/testing/index.ts#createRealtimeHarness`) : il monte le contrôleur sur une
|
|
601
|
+
fausse connexion, envoie les frames qu'un client enverrait et rend celles qui sortent. Sans option,
|
|
602
|
+
la connexion est **anonyme** — le repli Zero Trust. Une action qui lit `@CurrentUser()` s'éprouve
|
|
603
|
+
donc avec l'identité qu'aurait posée le pare-feu au handshake, passée en `identity`
|
|
604
|
+
(`nodefony/testing/index.ts#IRealtimeHarnessOptions.identity`) :
|
|
605
|
+
|
|
606
|
+
```ts ignore
|
|
607
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
608
|
+
import { getRealtimeHub } from "@nodefony/realtime";
|
|
609
|
+
import type { IRealtimeToken } from "@nodefony/realtime";
|
|
610
|
+
import { createRealtimeHarness } from "@nodefony/realtime/testing";
|
|
611
|
+
import ChatController from "../nodefony/controllers/ChatController";
|
|
612
|
+
|
|
613
|
+
describe("ChatController — actions", () => {
|
|
614
|
+
afterEach(() => getRealtimeHub().clear());
|
|
615
|
+
|
|
616
|
+
it("l'action reçoit son appelant par @CurrentUser", async () => {
|
|
617
|
+
const alice = { identifier: "alice" };
|
|
618
|
+
const attributes = new Map<string, unknown>([["user", alice]]);
|
|
619
|
+
const identity: IRealtimeToken = {
|
|
620
|
+
type: "session",
|
|
621
|
+
getUserIdentifier: () => alice.identifier,
|
|
622
|
+
isAuthenticated: () => true,
|
|
623
|
+
getRoles: () => ["ROLE_USER"],
|
|
624
|
+
getScopes: () => [],
|
|
625
|
+
getAttribute: <T>(key: string) => attributes.get(key) as T | undefined,
|
|
626
|
+
};
|
|
627
|
+
const h = createRealtimeHarness((ctx) => new ChatController(ctx), {
|
|
628
|
+
identity,
|
|
629
|
+
});
|
|
630
|
+
await h.connect();
|
|
631
|
+
const me = await h.call<{ identifier: string }>("chat:whoami");
|
|
632
|
+
expect(me.identifier).toBe("alice");
|
|
633
|
+
h.dispose();
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
C'est l'attribut `user` du jeton que `@CurrentUser()` rend. Pour éprouver un **refus** (`@IsGranted`,
|
|
639
|
+
`policy` de canal), il faut en plus le verrou de frame de `@nodefony/security`, passé en
|
|
640
|
+
`frameAuthorizer` : `@nodefony/realtime` ne connaît pas la sécurité, et sans ce verrou une règle
|
|
641
|
+
déclarée n'est appliquée par personne — en test comme en production. La table des membres du
|
|
642
|
+
harnais et l'exemple de refus sont dans le `README.md` du paquet, section « Tester un controller ».
|
|
643
|
+
`nodefony create controller --kind realtime` génère ce test, identité comprise, quand l'application
|
|
644
|
+
porte la sécurité.
|
|
645
|
+
|
|
562
646
|
Couverture : `npm run coverage` dans `@nodefony/realtime`.
|
|
563
647
|
|
|
564
648
|
## 🔗 Pour aller plus loin
|