@nodefony/realtime 10.0.0-beta.2 → 10.0.0-beta.3

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.
Files changed (45) hide show
  1. package/README.md +2 -2
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  4. package/dist/index.js +17 -10
  5. package/dist/nodefony/config/config.js +6 -3
  6. package/dist/nodefony/config/configSchema.js +19 -0
  7. package/dist/nodefony/config/defineModuleConfig.js +42 -15
  8. package/dist/nodefony/decorators/realtimeDecorators.js +68 -17
  9. package/dist/nodefony/src/backplane/RedisBackplane.js +21 -4
  10. package/dist/nodefony/src/backplane/bootNotices.js +43 -0
  11. package/dist/nodefony/src/backplane/publishQueue.js +1 -4
  12. package/dist/nodefony/src/cluster/ClusterProbeClient.js +15 -4
  13. package/dist/nodefony/src/guards.js +41 -0
  14. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +1 -4
  15. package/dist/nodefony/src/server/RealtimeController.js +120 -50
  16. package/dist/nodefony/src/server/RealtimeHub.js +29 -51
  17. package/dist/nodefony/src/server/ServerRealtimeSocket.js +13 -5
  18. package/dist/nodefony/src/server/syslogUplink.js +4 -2
  19. package/dist/nodefony/src/service/RealtimeService.js +3 -2
  20. package/dist/nodefony/testing/index.js +41 -1
  21. package/dist/types/nodefony/config/configSchema.d.ts +12 -0
  22. package/dist/types/nodefony/config/defineModuleConfig.d.ts +29 -6
  23. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +19 -2
  24. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +1 -1
  25. package/dist/types/nodefony/interfaces/IRealtimeHandshake.d.ts +1 -1
  26. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +7 -0
  27. package/dist/types/nodefony/src/backplane/bootNotices.d.ts +25 -0
  28. package/dist/types/nodefony/src/guards.d.ts +32 -0
  29. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +13 -0
  30. package/dist/types/nodefony/src/server/RealtimeController.d.ts +24 -4
  31. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +22 -16
  32. package/dist/types/nodefony/src/service/RealtimeService.d.ts +1 -1
  33. package/dist/types/nodefony/testing/index.d.ts +2 -2
  34. package/docs/actions.md +111 -28
  35. package/docs/architecture.md +77 -75
  36. package/docs/cadence-et-contre-pression.md +634 -0
  37. package/docs/configuration.md +39 -34
  38. package/docs/cookbook-chat.md +31 -27
  39. package/docs/index.md +35 -21
  40. package/docs/observabilite.md +17 -15
  41. package/docs/protocole.md +74 -63
  42. package/docs/securite.md +49 -49
  43. package/docs/vocabulaire.md +76 -75
  44. package/package.json +16 -12
  45. package/docs/couverture-bancs.md +0 -139
@@ -140,8 +140,25 @@ export declare function RealtimeChannel(channel: string, policy?: IChannelPolicy
140
140
  * }
141
141
  */
142
142
  export declare function RealtimeInbound(method: string, policy?: IChannelPolicy): MethodDecorator;
143
- /** Lecture (lazy, au handshake) du registre des actions décorées. */
144
- export declare function getRealtimeActions(instance: object): Record<string, RpcActionHandler> | null;
143
+ /**
144
+ * Méthode d'action liée à son instance, appelable avec ses arguments réels —
145
+ * plusieurs quand ses paramètres sont décorés (`@Body`, `@CurrentUser`…).
146
+ */
147
+ export type RealtimeActionMethod = (...args: unknown[]) => unknown;
148
+ /**
149
+ * Enveloppe posée sur une action décorée au handshake : reçoit la méthode liée
150
+ * et son nom de propriété, rend le handler JSON-RPC à enregistrer.
151
+ */
152
+ export type RealtimeActionWrapper = (method: RealtimeActionMethod, propertyKey: string | symbol) => RpcActionHandler;
153
+ /**
154
+ * Lecture (lazy, au handshake) du registre des actions décorées.
155
+ *
156
+ * @param instance - le contrôleur de la connexion
157
+ * @param wrap - enveloppe optionnelle de chaque action (résolution des
158
+ * paramètres décorés) ; sans elle, l'action reçoit la charge JSON-RPC brute
159
+ * @returns les handlers par nom d'action, ou `null` si aucune n'est décorée
160
+ */
161
+ export declare function getRealtimeActions(instance: object, wrap?: RealtimeActionWrapper): Record<string, RpcActionHandler> | null;
145
162
  /** Lecture (lazy, au handshake) du registre des canaux décorés. */
146
163
  export declare function getRealtimeChannels(instance: object): Record<string, RealtimeChannelFactory> | null;
147
164
  /** Lecture (lazy, au handshake) du registre des canaux inbound décorés. */
@@ -22,7 +22,7 @@ export type RealtimeInboundHandler = (params: unknown, reply: (payload: unknown)
22
22
  * (Les hooks `realtimeActions()` / `realtimeChannels()` sont `protected` sur la base
23
23
  * — détail d'implémentation surchargeable, hors contrat public.)
24
24
  *
25
- * Côté client, le pendant n'est PAS un controller mais `RealtimeClient` — tous deux
25
+ * Côté client, le pendant n'est PAS un controller mais `NodefonySocket` — tous deux
26
26
  * composent le même peer ; cf vision « la socket Nodefony ».
27
27
  */
28
28
  export interface IRealtimeController {
@@ -12,7 +12,7 @@
12
12
  * @see IRealtimeAuthenticator
13
13
  */
14
14
  export interface IRealtimeHandshake {
15
- /** En-têtes de la requête upgrade (clés en lowercase RFC 7230 §3.2). */
15
+ /** En-têtes de la requête upgrade (clés en lowercase RFC 9110 §5.1). */
16
16
  readonly headers: Readonly<Record<string, string | string[] | undefined>>;
17
17
  /** Cookies parsés depuis `Cookie:` (vide si en-tête absent ou mal formé). */
18
18
  readonly cookies: ReadonlyMap<string, string>;
@@ -83,6 +83,13 @@ export interface IRedisSubscriber {
83
83
  * Le listener Redis est conservé pour un `unsubscribe(channel, listener)` ciblé
84
84
  * (n'arrache pas d'éventuels autres abonnés du même client).
85
85
  */
86
+ /**
87
+ * Vrai si la valeur expose la surface d'un client Redis publisher — vérifié au
88
+ * démarrage, sur le client que rend le service `redis`.
89
+ */
90
+ export declare function isRedisPublisher(value: unknown): value is IRedisPublisher;
91
+ /** Vrai si la valeur expose la surface d'un client Redis subscriber dédié. */
92
+ export declare function isRedisSubscriber(value: unknown): value is IRedisSubscriber;
86
93
  export declare function createRedisServiceTransport(publisher: IRedisPublisher, subscriber: IRedisSubscriber): IRedisBackplaneTransport;
87
94
  /**
88
95
  * Réglages optionnels du driver — regroupés en objet pour ne pas allonger une
@@ -0,0 +1,25 @@
1
+ import type { IBootNotice } from "nodefony";
2
+ /**
3
+ * Le temps réel ne dépasse pas ce processus : aucun backplane actif (driver
4
+ * `loopback`, `cluster` hors d'un worker, ou fabrique inactive ici).
5
+ *
6
+ * Simple information en développement mono-processus — c'est le cas normal —
7
+ * mais à savoir avant de déployer plusieurs exemplaires : un message publié
8
+ * sur l'un n'atteindrait pas les clients connectés aux autres.
9
+ *
10
+ * @param driver - le driver déclaré.
11
+ * @returns le point à déclarer.
12
+ */
13
+ export declare function realtimeLocalOnlyNotice(driver: string): IBootNotice;
14
+ /**
15
+ * Le backplane déclaré n'a pas pu démarrer — le hub est retombé en local.
16
+ *
17
+ * Distinct de {@link realtimeLocalOnlyNotice} : ici, l'application a DEMANDÉ
18
+ * un fan-out entre exemplaires et ne l'a pas. Deux pods servent alors deux
19
+ * mondes temps réel disjoints, sans erreur visible.
20
+ *
21
+ * @param driver - le driver déclaré.
22
+ * @param reason - la cause (message de l'échec).
23
+ * @returns le point à déclarer.
24
+ */
25
+ export declare function realtimeBackplaneDownNotice(driver: string, reason: string): IBootNotice;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Gardes de type internes au paquet — elles remplacent une conversion (`as`)
3
+ * par une vérification faite à l'exécution, pour qu'une valeur d'une autre
4
+ * forme soit écartée au lieu de traverser le code sous un type qui ment.
5
+ *
6
+ * Non exportées par le paquet : ce sont des détails d'implémentation.
7
+ */
8
+ /**
9
+ * Vrai si la valeur est un objet non nul : ses propriétés se lisent alors,
10
+ * typées `unknown`, et chacune se vérifie avant usage.
11
+ *
12
+ * @param value - valeur venue du réseau, d'une métadonnée ou du conteneur
13
+ * @returns `true` pour tout objet (tableau compris), `false` pour `null` et les primitives
14
+ */
15
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
16
+ /**
17
+ * Vrai si la valeur est une fonction. Toute fonction JavaScript s'appelle avec
18
+ * n'importe quels arguments et rend une valeur inconnue : la signature annoncée
19
+ * ne promet rien de plus que ce que `typeof` a vérifié.
20
+ *
21
+ * @param value - valeur lue par son nom (métadonnée, propriété dynamique)
22
+ * @returns `true` si la valeur est appelable
23
+ */
24
+ export declare function isCallable(value: unknown): value is (...args: unknown[]) => unknown;
25
+ /**
26
+ * Vrai si la valeur se comporte comme une promesse (transport asynchrone) —
27
+ * un client Redis rend une promesse, un faux transport de test rend `void`.
28
+ *
29
+ * @param value - retour d'un transport
30
+ * @returns `true` si la valeur porte une méthode `then`
31
+ */
32
+ export declare function isThenable(value: unknown): value is Promise<unknown>;
@@ -1,3 +1,16 @@
1
1
  import type { IRealtimeToken } from "../../interfaces/IRealtimeToken.js";
2
+ /**
3
+ * Token realtime ANONYME — fallback Zero Trust quand aucun matcher
4
+ * `IRealtimeAuthenticatorMatcher` ne capture le handshake.
5
+ *
6
+ * Singleton gelé (`Object.freeze`) — 0 alloc par connexion non authentifiée.
7
+ * Aligné sur le pattern `AnonymousToken` de `@nodefony/security` S1 (même
8
+ * surface logique, type `"anonymous"`, `roles = ["ROLE_ANONYMOUS"]`).
9
+ *
10
+ * Stocké par le hub sur la map `peer → token` au handshake si aucun
11
+ * authenticator ne capture la connexion. Les voters P6 (futur) verront un
12
+ * token avec `isAuthenticated() === false` et appliqueront leur politique
13
+ * (Zero Trust : zone protégée → frame refusée par `beforeDispatch`).
14
+ */
2
15
  export declare const ANONYMOUS_REALTIME_TOKEN: IRealtimeToken;
3
16
  export default ANONYMOUS_REALTIME_TOKEN;
@@ -11,7 +11,7 @@ import type { IRealtimeController, RealtimePublish, RealtimeInboundHandler } fro
11
11
  * — écrit UNE fois, partagé par tous les modules. Un contrôleur concret ne déclare que
12
12
  * son métier : {@link createRealtimeChannel} (providers de canaux) + {@link realtimeActions}.
13
13
  *
14
- * Chaque connexion compose un `JsonRpcPeer` (le MÊME que `RealtimeClient` côté navigateur
14
+ * Chaque connexion compose un `JsonRpcPeer` (le MÊME que `NodefonySocket` côté navigateur
15
15
  * — symétrie isomorphe) branché sur un {@link WsConnectionTransport}.
16
16
  *
17
17
  * Usage : le sous-classe garde sa route WS et délègue —
@@ -111,13 +111,13 @@ export declare abstract class RealtimeController<Emit extends EventsMap = Defaul
111
111
  * est structurel, pas un choix : c'est au client d'attendre `realtime:welcome`
112
112
  * avant de pousser.
113
113
  *
114
- * ⚠️ Cette phrase a longtemps ajouté « ce que `RealtimeClient` fait nativement ».
114
+ * ⚠️ Cette phrase a longtemps ajouté « ce que le client fait nativement ».
115
115
  * C'était FAUX : le client rejouait ses abonnements dès l'ouverture de la socket,
116
116
  * si bien qu'un `subscribe` posé avant le démarrage et TOUS ceux d'après une
117
117
  * reconnexion étaient perdus ici même, sans un mot des deux côtés. Un contrat écrit
118
118
  * d'un seul côté du fil n'est pas tenu ; il l'est désormais par
119
- * `RealtimeClient.replaySubscriptions` et les deux cas « la fenêtre où le serveur
120
- * écoute (welcome) » de `RealtimeClientCoverage.test.ts`.
119
+ * `NodefonySocket.replaySubscriptions` et les deux cas « la fenêtre où le serveur
120
+ * écoute (welcome) » de `NodefonySocketCoverage.test.ts`.
121
121
  */
122
122
  protected handleRealtime(message: string | Buffer | null): void;
123
123
  /**
@@ -166,6 +166,26 @@ export declare abstract class RealtimeController<Emit extends EventsMap = Defaul
166
166
  private onRealtimeNotification;
167
167
  /** Abonne la connexion à un canal via le hub partagé (idempotent par connexion). */
168
168
  private startChannel;
169
+ /**
170
+ * Enveloppe des actions `@RealtimeAction` dont un paramètre est décoré
171
+ * (`@Body`, `@CurrentUser`…) : l'action reçoit ses arguments comme une route,
172
+ * résolus par la fonction du framework et non par une copie.
173
+ *
174
+ * L'identité ne change PAS : un message est déjà traité dans la bulle
175
+ * `RequestContext` du handshake (`WebsocketContext` lie son écouteur par
176
+ * `AsyncResource.bind`), où le pare-feu a posé `user` et `token`. La bulle
177
+ * ouverte ici HÉRITE de ce store et n'y ajoute que la charge (`body`, que
178
+ * `@Body` lit dans l'ALS comme pour `api.request`) — une écriture
179
+ * `RequestContext.set()` dans l'action reste ainsi propre à l'appel.
180
+ *
181
+ * Une action sans paramètre décoré est rendue telle quelle : rien ne change
182
+ * pour elle, ni comportement ni coût. Les métadonnées se lisent ICI, une fois
183
+ * par connexion.
184
+ *
185
+ * @param ctx - le contexte de la connexion
186
+ * @returns l'enveloppe passée à {@link getRealtimeActions}
187
+ */
188
+ private actionParamsWrapper;
169
189
  /**
170
190
  * Handler de la méthode RPC `api.request {path}` — le **pont API souverain**
171
191
  * (POC Ph.3) : résout le path porté par le message via le Router
@@ -71,16 +71,13 @@ export declare const RESERVED_SYSTEM_PREFIXES: readonly ["nodefony:"];
71
71
  */
72
72
  export declare const isReservedSystemChannel: typeof isPlatformChannel;
73
73
  /**
74
- * Période de re-validation des identités RÉVOCABLES — F4 (revue 0.6).
75
- * Le verrou de frame est SYNC par doctrine (identité figée au handshake, cf
76
- * {@link FrameAuthorizer}) → il ne peut pas re-lire la session par frame. Un socket
77
- * survivrait donc à sa session (`subscribe` garderait ses flux après un logout HTTP),
78
- * là où `api.request` re-valide déjà par requête (`isValid()`). Ce tick ferme
79
- * l'écart : toutes les `REVOCATION_REVALIDATE_MS`, les tokens révocables sont
80
- * re-validés et la connexion fermée si l'identité n'est plus valide (≤ 1 fenêtre de
81
- * délai). Ordre de grandeur aligné sur le heartbeat WS (`keepaliveInterval` 20 s).
74
+ * Période de re-validation des identités RÉVOCABLES — F4 (revue 0.6). Le verrou
75
+ * de frame est SYNC (identité figée au handshake) : ce tick ferme l'écart avec
76
+ * `api.request`, qui re-valide par requête. Définie par le cœur, qui porte le
77
+ * registre partagé avec les flux SSE ; ré-exportée ici pour les consommateurs
78
+ * du hub.
82
79
  */
83
- export declare const REVOCATION_REVALIDATE_MS = 30000;
80
+ export { REVOCATION_REVALIDATE_MS } from "nodefony";
84
81
  /**
85
82
  * Le MOTIF de famille d'un canal — son dernier segment remplacé par `*`.
86
83
  *
@@ -156,7 +153,7 @@ export type ChannelFactory = (channel: string, publish: RealtimePublish) => (()
156
153
  * multiplexe des canaux ; le hub aiguille entre les sockets. La façade *consommateur*
157
154
  * côté serveur EXISTE — {@link ServerRealtimeSocket} (`subscribe/on/publish`), exportée
158
155
  * par le barrel du module : c'est elle qu'un service back tient, jamais ce hub
159
- * directement. Même rôle que `RealtimeClient` côté navigateur.
156
+ * directement. Même rôle que `NodefonySocket` côté navigateur.
160
157
  */
161
158
  export declare class RealtimeHub {
162
159
  #private;
@@ -288,14 +285,23 @@ export declare class RealtimeHub {
288
285
  */
289
286
  unregisterRevocable(entry: IRevocableConnection): void;
290
287
  /**
291
- * Tick de re-validation (F4) : re-lit l'identité de chaque connexion révocable
292
- * (`token.isValid()`) et FERME la socket (`4001`) si l'identité est morte : session
293
- * détruite ou passée à un autre compte, jeton expiré ou révoqué.
294
- * Fail-closed : une re-validation qui throw ferme aussi (parité `invokeApiRequest`).
295
- * Snapshot du registre AVANT les `await` (le `close` mute le registre via le cleanup
296
- * `onFinish`). Exposé (public) pour un test déterministe sans fake timers.
288
+ * Re-valide toutes les connexions révocables et ferme (`4001`) celles dont
289
+ * l'identité est morte : session détruite ou passée à un autre compte, jeton
290
+ * expiré ou révoqué. Fail-closed : une re-validation qui lève ferme aussi. Le
291
+ * registre est celui des flux SSE ({@link RevocationWatch}) — une garde, deux
292
+ * transports. Exposé (public) pour un test déterministe sans faux minuteur.
297
293
  */
298
294
  revalidateRevocable(): Promise<void>;
295
+ /**
296
+ * Ferme tout de suite les connexions qui ont figé cette identité — ses droits
297
+ * ont changé (`IDENTITY_CHANGED_EVENT`). Code {@link IDENTITY_REFRESH_CLOSE_CODE},
298
+ * reconnectable : le client revient avec ses rôles frais. Le registre est
299
+ * celui des flux SSE — une règle, deux transports.
300
+ *
301
+ * @param identifier - identifiant de connexion de l'identité changée.
302
+ * @returns le nombre de connexions fermées.
303
+ */
304
+ refreshIdentity(identifier: string): number;
299
305
  /**
300
306
  * Compte une frame entrante full-duplex (canaux gated SIP/bridge). Appelé par le
301
307
  * contrôleur quand un handler `realtimeInbound` traite un message client.
@@ -2,7 +2,7 @@ import { Service, Module, type JsonRpcPeer } from "nodefony";
2
2
  import { type RealtimeHub, type ChannelFactory, type ChannelSink, type FrameAuthorizer } from "../server/RealtimeHub.js";
3
3
  import type { IBackplane } from "../../interfaces/IBackplane.js";
4
4
  import type { IRealtimeProbe } from "../../interfaces/IRealtimeProbe.js";
5
- import type { IRealtimeConfig } from "../../config/defineModuleConfig.js";
5
+ import { type IRealtimeConfig } from "../../config/defineModuleConfig.js";
6
6
  import type { IRealtimeAuthenticator } from "../../interfaces/IRealtimeAuthenticator.js";
7
7
  import type { IRealtimeAuthenticatorMatcher } from "../../interfaces/IRealtimeAuthenticatorMatcher.js";
8
8
  import type { IRealtimeToken } from "../../interfaces/IRealtimeToken.js";
@@ -12,7 +12,7 @@
12
12
  *
13
13
  * Le harnais pilote le **côté serveur** : il injecte des frames JSON-RPC 2.0
14
14
  * telles qu'un client les enverrait, et lit celles qui sortent. Il n'embarque
15
- * PAS `RealtimeClient` — le client est un artefact navigateur du cœur, et le
15
+ * PAS `NodefonySocket` — le client est un artefact navigateur du cœur, et le
16
16
  * tirer ici le ferait entrer dans le paquet serveur publié. Ce qu'on veut
17
17
  * prouver d'un controller (ses canaux, ses actions, son plein-duplex, son
18
18
  * nettoyage) s'observe entièrement aux frames.
@@ -29,7 +29,7 @@
29
29
  *
30
30
  * @packageDocumentation
31
31
  */
32
- import type { IRealtimeDenied } from "nodefony";
32
+ import { type IRealtimeDenied } from "nodefony";
33
33
  import type { ContextType } from "@nodefony/http";
34
34
  import type { RealtimeController } from "../src/server/RealtimeController.js";
35
35
  import { type RealtimeHub, type FrameAuthorizer } from "../src/server/RealtimeHub.js";
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:556`). Une frame sans
120
+ portant cet `id` revient (`JsonRpcPeer.handleResponse()`, `JsonRpcPeer.ts:579`). 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:101`) suffit, et le
129
+ schéma à compiler : `@RealtimeAction("orders:quote")` (`realtimeDecorators.ts:191`) 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:118`). Pas de contexte injecté dans la signature : le `this` est lié à
134
+ (`JsonRpcPeer.ts:153`). 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
139
  `IRealtimeWelcome` (`RealtimeController.ts:12`) — et se lit côté client par
140
- `RealtimeClient.serverMethods` (`RealtimeClient.ts:597`). Une interface n'écrit donc jamais un nom d'action en dur : elle
140
+ `NodefonySocket.serverMethods` (`NodefonySocket.ts:800`). 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 { RealtimeClient } from "nodefony/client";
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 = RealtimeClient.shared({
210
+ const socket = NodefonySocket.shared({
211
211
  url: "wss://127.0.0.1:5152/orders/realtime",
212
212
  });
213
213
 
@@ -276,18 +276,18 @@ Le trajet complet d'une requête, des étapes qu'elle traverse aux branches par
276
276
 
277
277
  | Voie | Quand la choisir |
278
278
  | ------------------------------------------------------ | ----------------------------------------------------------- |
279
- | `@RealtimeAction("nom")` (`realtimeDecorators.ts:101`) | le cas courant — un nom fixe, une méthode, déclaratif |
280
- | `realtimeActions()` (`RealtimeController.ts:236`) | la table est **calculée** (noms dynamiques, boucle, config) |
279
+ | `@RealtimeAction("nom")` (`realtimeDecorators.ts:191`) | le cas courant — un nom fixe, une méthode, déclaratif |
280
+ | `realtimeActions()` (`RealtimeController.ts:281`) | 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:453`). Un endpoint sans aucune action ne paie rien — la table du pair est
284
+ (`RealtimeController.ts:598`). 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:499`) :
290
+ Trust stricte, appliquée dans `JsonRpcPeer.handleRequest()` (`JsonRpcPeer.ts:526`) :
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:71`), importée depuis `nodefony` — son `code` est un entier
302
+ > c'est `RpcError` (`JsonRpcPeer.ts:101`), 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.
@@ -360,21 +360,55 @@ donc réservée, quoi qu'en dise son décorateur. À savoir avant de nommer une
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:1130`) :
363
+ (`realtimeChannels`, `security/nodefony/config/config.ts:1447`) :
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:1007`) :
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:112`) et
406
+ > ferme la socket en `4001` si la session est morte (`RealtimeHub.ts:842`). 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
@@ -394,7 +428,7 @@ 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 (`RealtimeClient.request()`, `RealtimeClient.ts:727`) :
431
+ d'objet d'options (`NodefonySocket.request()`, `NodefonySocket.ts:936`) :
398
432
 
399
433
  ```ts ignore
400
434
  await socket.request("orders:quote", { orderId }); // 30 000 ms par défaut
@@ -403,12 +437,12 @@ await socket.request("orders:export", { scope }, 120_000); // action longue
403
437
 
404
438
  | Défaut | Valeur | Ancrage |
405
439
  | --------- | --------- | -------------------- |
406
- | `request` | 30 000 ms | `JsonRpcPeer.ts:319` |
440
+ | `request` | 30 000 ms | `JsonRpcPeer.ts:353` |
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:455`). Conséquence à connaître : une réponse qui
443
+ `RPC timeout: <méthode>` (`JsonRpcPeer.ts:502`). 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:539`) — pas de résolution tardive, pas de fuite.
445
+ (`JsonRpcPeer.ts:584`) — pas de résolution tardive, pas de fuite.
412
446
 
413
447
  ### Abandonner : ce qui existe vraiment
414
448
 
@@ -422,8 +456,8 @@ 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:453`), appelé au nettoyage de la socket
426
- (`RealtimeController.ts:553`). Là encore : côté client seulement.
459
+ coup (`JsonRpcPeer.ts:480`), appelé au nettoyage de la socket
460
+ (`RealtimeController.ts:749`). 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
463
  `nodefony:scaffold:cancel` (`StudioRealtimeController.ts:159`), pendant de `nodefony:scaffold:run`.
@@ -444,7 +478,7 @@ 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()` (`RealtimeClient.ts:797`), et c'est la garde `@Idempotent`
481
+ par la signature** de `mutate()` (`NodefonySocket.ts:1009`), et c'est la garde `@Idempotent`
448
482
  (`routerDecorators.ts:1171`) 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
 
@@ -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:118`), que
532
+ **Il n'y en a pas.** Une action rend **une** valeur (`RpcActionHandler`, `JsonRpcPeer.ts:153`), 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:949`). Il est **désactivé par défaut** et s'active en surchargeant
514
- `realtimeApiRequest()` (`RealtimeController.ts:276`).
547
+ (`RealtimeController.ts:1070`). Il est **désactivé par défaut** et s'active en surchargeant
548
+ `realtimeApiRequest()` (`RealtimeController.ts:321`).
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
- `RealtimeClient.request()` (`RealtimeClient.ts:727`). Écris une action RPC pour ce qui n'a de sens **que** sur la socket ;
555
+ `NodefonySocket.request()` (`NodefonySocket.ts:936`). É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
 
@@ -548,7 +582,7 @@ vivent dans la carte de l'aperçu, régénérée depuis les résultats réels, j
548
582
  - **Unitaires, déclaration** (`realtimeDecorators.test.ts`) : `@RealtimeAction` enregistre bien le
549
583
  nom et lie le `this` ; (`RealtimeController.test.ts`) : fusion décorateurs + override et
550
584
  annonce dans l'accueil.
551
- - **Unitaires, client** (`RealtimeClientCoverage.test.ts`) : formes de `request`, contrat de
585
+ - **Unitaires, client** (`NodefonySocketCoverage.test.ts`) : formes de `request`, contrat de
552
586
  streaming, rejet des appels en attente au `dispose`.
553
587
  - **Attaque, autorisation** (`realtimeFrameLock.test.ts`) : `nodefony:kernel:ping` et `nodefony:kernel:gc` refusés à
554
588
  l'anonyme **et** à l'utilisateur authentifié, acceptés à l'administrateur — la preuve que le
@@ -559,6 +593,55 @@ vivent dans la carte de l'aperçu, régénérée depuis les résultats réels, j
559
593
  fan-out, pas celui des appels corrélés). Pour en monter un, voir le skill
560
594
  `nodefony-load-test` ; pour la mémoire, `nodefony-check-memory-health`.
561
595
 
596
+ ### Éprouver SON action — le harnais, identité comprise
597
+
598
+ Une application teste ses actions sans serveur ni navigateur avec `createRealtimeHarness`
599
+ (`@nodefony/realtime/testing`, `nodefony/testing/index.ts:206`) : il monte le contrôleur sur une
600
+ fausse connexion, envoie les frames qu'un client enverrait et rend celles qui sortent. Sans option,
601
+ la connexion est **anonyme** — le repli Zero Trust. Une action qui lit `@CurrentUser()` s'éprouve
602
+ donc avec l'identité qu'aurait posée le pare-feu au handshake, passée en `identity`
603
+ (`nodefony/testing/index.ts:93`) :
604
+
605
+ ```ts ignore
606
+ import { describe, it, expect, afterEach } from "vitest";
607
+ import { getRealtimeHub } from "@nodefony/realtime";
608
+ import type { IRealtimeToken } from "@nodefony/realtime";
609
+ import { createRealtimeHarness } from "@nodefony/realtime/testing";
610
+ import ChatController from "../nodefony/controllers/ChatController";
611
+
612
+ describe("ChatController — actions", () => {
613
+ afterEach(() => getRealtimeHub().clear());
614
+
615
+ it("l'action reçoit son appelant par @CurrentUser", async () => {
616
+ const alice = { identifier: "alice" };
617
+ const attributes = new Map<string, unknown>([["user", alice]]);
618
+ const identity: IRealtimeToken = {
619
+ type: "session",
620
+ getUserIdentifier: () => alice.identifier,
621
+ isAuthenticated: () => true,
622
+ getRoles: () => ["ROLE_USER"],
623
+ getScopes: () => [],
624
+ getAttribute: <T>(key: string) => attributes.get(key) as T | undefined,
625
+ };
626
+ const h = createRealtimeHarness((ctx) => new ChatController(ctx), {
627
+ identity,
628
+ });
629
+ await h.connect();
630
+ const me = await h.call<{ identifier: string }>("chat:whoami");
631
+ expect(me.identifier).toBe("alice");
632
+ h.dispose();
633
+ });
634
+ });
635
+ ```
636
+
637
+ C'est l'attribut `user` du jeton que `@CurrentUser()` rend. Pour éprouver un **refus** (`@IsGranted`,
638
+ `policy` de canal), il faut en plus le verrou de frame de `@nodefony/security`, passé en
639
+ `frameAuthorizer` : `@nodefony/realtime` ne connaît pas la sécurité, et sans ce verrou une règle
640
+ déclarée n'est appliquée par personne — en test comme en production. La table des membres du
641
+ harnais et l'exemple de refus sont dans le `README.md` du paquet, section « Tester un controller ».
642
+ `nodefony create controller --kind realtime` génère ce test, identité comprise, quand l'application
643
+ porte la sécurité.
644
+
562
645
  Couverture : `npm run coverage` dans `@nodefony/realtime`.
563
646
 
564
647
  ## 🔗 Pour aller plus loin