@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.
Files changed (63) hide show
  1. package/README.md +2 -2
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  3. package/dist/index.js +18 -13
  4. package/dist/nodefony/config/config.js +7 -5
  5. package/dist/nodefony/config/configSchema.js +18 -0
  6. package/dist/nodefony/config/defineModuleConfig.js +43 -17
  7. package/dist/nodefony/decorators/realtimeDecorators.js +69 -19
  8. package/dist/nodefony/interfaces/IRealtimeConnectionState.js +1 -0
  9. package/dist/nodefony/src/backplane/ClusterBackplane.js +1 -2
  10. package/dist/nodefony/src/backplane/LoopbackBackplane.js +1 -2
  11. package/dist/nodefony/src/backplane/RedisBackplane.js +22 -6
  12. package/dist/nodefony/src/backplane/backplaneRegistry.js +1 -2
  13. package/dist/nodefony/src/backplane/bootNotices.js +42 -0
  14. package/dist/nodefony/src/backplane/envelope.js +1 -2
  15. package/dist/nodefony/src/backplane/originId.js +1 -2
  16. package/dist/nodefony/src/backplane/publishQueue.js +1 -5
  17. package/dist/nodefony/src/cluster/ClusterProbeClient.js +16 -6
  18. package/dist/nodefony/src/guards.js +40 -0
  19. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +2 -6
  20. package/dist/nodefony/src/server/RealtimeAdminApi.js +1 -2
  21. package/dist/nodefony/src/server/RealtimeController.js +226 -82
  22. package/dist/nodefony/src/server/RealtimeHub.js +32 -54
  23. package/dist/nodefony/src/server/ServerRealtimeSocket.js +30 -13
  24. package/dist/nodefony/src/server/deniedDetail.js +1 -2
  25. package/dist/nodefony/src/server/subscribedAck.js +29 -0
  26. package/dist/nodefony/src/server/syslogUplink.js +5 -4
  27. package/dist/nodefony/src/server/welcomeEnv.js +1 -2
  28. package/dist/nodefony/src/service/RealtimeService.js +4 -4
  29. package/dist/nodefony/src/transport/WsConnectionTransport.js +42 -2
  30. package/dist/nodefony/testing/index.js +118 -5
  31. package/dist/types/index.d.ts +1 -0
  32. package/dist/types/nodefony/config/configSchema.d.ts +12 -0
  33. package/dist/types/nodefony/config/defineModuleConfig.d.ts +29 -6
  34. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +19 -2
  35. package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +40 -0
  36. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +1 -1
  37. package/dist/types/nodefony/interfaces/IRealtimeHandshake.d.ts +1 -1
  38. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +13 -0
  39. package/dist/types/nodefony/interfaces/index.d.ts +1 -0
  40. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +7 -0
  41. package/dist/types/nodefony/src/backplane/bootNotices.d.ts +25 -0
  42. package/dist/types/nodefony/src/guards.d.ts +32 -0
  43. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +13 -0
  44. package/dist/types/nodefony/src/server/RealtimeController.d.ts +82 -4
  45. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +22 -16
  46. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +18 -8
  47. package/dist/types/nodefony/src/server/subscribedAck.d.ts +23 -0
  48. package/dist/types/nodefony/src/service/RealtimeService.d.ts +1 -1
  49. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +26 -1
  50. package/dist/types/nodefony/testing/index.d.ts +8 -3
  51. package/docs/actions.md +129 -45
  52. package/docs/architecture.md +143 -135
  53. package/docs/cadence-et-contre-pression.md +643 -0
  54. package/docs/configuration.md +126 -97
  55. package/docs/cookbook-chat.md +50 -43
  56. package/docs/index.md +63 -38
  57. package/docs/observabilite.md +58 -56
  58. package/docs/protocole.md +110 -76
  59. package/docs/securite.md +128 -117
  60. package/docs/vocabulaire.md +133 -129
  61. package/package.json +17 -13
  62. package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
  63. package/docs/couverture-bancs.md +0 -139
@@ -1,4 +1,4 @@
1
- import { z } from "zod";
1
+ import type { z } from "zod";
2
2
  import { realtimeConfigSchema, type IRealtimeSchemaConfig } from "./config.js";
3
3
  import type { IBackplane } from "../interfaces/IBackplane.js";
4
4
  /**
@@ -19,11 +19,13 @@ import type { IBackplane } from "../interfaces/IBackplane.js";
19
19
  * faire dans un JSON Schema Studio). L'instance, si fournie, prend précédence
20
20
  * sur `driver` côté service ; ce dernier reste source d'introspection/UI.
21
21
  *
22
- * **Layering d'environnement** (convention-frère avec `REDIS_*` de
23
- * `@nodefony/redis`) : `NF_REALTIME_DRIVER` surcharge le driver backplane
24
- * APRÈS le parse (schéma pur, déterministe). Indispensable en déploiement
25
- * (k8s/Docker : `NF_REALTIME_DRIVER=redis`) et fiable quel que soit le
26
- * timing du merge `module-realtime` côté app (cf chantier ordering config).
22
+ * **Variables d'environnement** : `NF_REALTIME_DRIVER`,
23
+ * `NF_REALTIME_BACKPLANE_SECRET` et `NF_REALTIME_BACKPLANE_NAMESPACE` ne se
24
+ * lisent PAS ici. Chacune est déclarée sur son champ dans `config.ts`
25
+ * (`.meta({ env })`) et posée par le Kernel AVANT la validation : le schéma
26
+ * les valide comme une valeur de l'application (un secret de moins de 32
27
+ * caractères arrête le démarrage). Un appel direct de ce builder, hors Kernel,
28
+ * ne les voit donc pas.
27
29
  *
28
30
  * @param config - configuration brute (sections omises = défauts sûrs).
29
31
  * @param options.backplane - instance `IBackplane` custom (override le driver à
@@ -34,6 +36,27 @@ import type { IBackplane } from "../interfaces/IBackplane.js";
34
36
  export declare function defineRealtimeConfig(config?: IRealtimeConfigInput, options?: {
35
37
  backplane?: IBackplane;
36
38
  }): IRealtimeConfig;
39
+ /**
40
+ * Le builder pour une entrée NON typée — la config reçue par le module au
41
+ * démarrage, dont le schéma décide seul de la forme. Interne au paquet : une
42
+ * application passe par {@link defineRealtimeConfig}, qui type ce qu'elle écrit.
43
+ *
44
+ * @param config - configuration brute, validée par le schéma
45
+ * @param options.backplane - instance `IBackplane` custom
46
+ * @returns config gelée prête pour `RealtimeService`.
47
+ * @throws BootConfigurationError si invalide.
48
+ */
49
+ export declare function buildRealtimeConfig(config?: unknown, options?: {
50
+ backplane?: IBackplane;
51
+ }): IRealtimeConfig;
52
+ /**
53
+ * Vrai si la valeur est une config produite par {@link buildRealtimeConfig}
54
+ * (donc validée par le schéma). Lu une fois, à l'init du service.
55
+ *
56
+ * @param value - la config lue sur le module
57
+ * @returns `true` si elle sort du builder
58
+ */
59
+ export declare function isRealtimeConfig(value: unknown): value is IRealtimeConfig;
37
60
  /**
38
61
  * JSON Schema introspectable de la config realtime — destiné au formulaire
39
62
  * d'édition Studio (futur). N'inclut PAS `backplane.instance` (non sérialisable).
@@ -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. */
@@ -0,0 +1,40 @@
1
+ import type { JsonRpcPeer } from "nodefony";
2
+ import type { WsConnectionTransport } from "../src/transport/WsConnectionTransport.js";
3
+ import type { ChannelSink } from "../src/server/RealtimeHub.js";
4
+ import type { RealtimeInboundHandler } from "./IRealtimeController.js";
5
+ /**
6
+ * État du protocole temps réel d'UNE connexion WebSocket — posé au welcome, et
7
+ * relu à chaque frame jusqu'à la fermeture.
8
+ *
9
+ * Il vit sur la CONNEXION (`WebsocketContext`), pas sur le contrôleur : un
10
+ * `forward` fait construire une instance neuve qui sert la même socket, et
11
+ * l'état doit lui survivre.
12
+ *
13
+ * Le pair y est rangé sous son contrat EFFACÉ, ce qu'il est d'ailleurs à sa
14
+ * construction. Plusieurs contrôleurs peuvent servir la même connexion, et aucun
15
+ * type ne relie cet état à l'un d'eux : chacun le relit sous son propre contrat
16
+ * (`RealtimeController.requestClient` → `JsonRpcPeer.requestAs`).
17
+ */
18
+ export interface IRealtimeConnectionState {
19
+ /** `realtime:welcome` émis : la poignée de main ne se rejoue pas. */
20
+ welcomed: boolean;
21
+ /** Le pair JSON-RPC de la connexion, sous son contrat effacé. */
22
+ peer: JsonRpcPeer;
23
+ /** Le transport qui relie le pair à la socket (contre-pression, sonde). */
24
+ transport: WsConnectionTransport;
25
+ /** Canal → sink de CETTE connexion auprès du hub partagé, pour s'en désabonner. */
26
+ channels: Map<string, ChannelSink>;
27
+ /** Canaux full-duplex qui acceptent une entrée client ; `null` s'il n'y en a aucun. */
28
+ inbound: Record<string, RealtimeInboundHandler> | null;
29
+ }
30
+ declare module "@nodefony/http" {
31
+ interface IWebsocketConnectionState {
32
+ /**
33
+ * État du protocole temps réel de la connexion, posé par
34
+ * `@nodefony/realtime` au welcome ; `undefined` avant, et sur toute socket
35
+ * qu'aucun `RealtimeController` ne sert. Réservé au module : le lire,
36
+ * jamais l'écrire.
37
+ */
38
+ __nfRealtime?: IRealtimeConnectionState;
39
+ }
40
+ }
@@ -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>;
@@ -45,4 +45,17 @@ export interface IRealtimeToken {
45
45
  * (publish/subscribe), payé seulement sur `api.request`.
46
46
  */
47
47
  isValid?(nowMs?: number): boolean | Promise<boolean>;
48
+ /**
49
+ * Le COMPTE qui a ouvert la socket existe-t-il toujours, actif, et est-ce
50
+ * bien le même ? `false` si le compte a disparu, a été désactivé ou
51
+ * verrouillé, ou si un AUTRE compte a repris son identifiant (suppression
52
+ * hors framework puis recréation). Optionnel — absent = considéré confirmé.
53
+ *
54
+ * Distinct d'{@link isValid} parce que son coût l'est : il relit l'annuaire
55
+ * des comptes (une lecture, comme une requête HTTP en zone), là où
56
+ * `isValid` ne lit qu'un store de jetons ou de sessions. Appelé par le pont
57
+ * `api.request` SEULEMENT, jamais par la re-validation périodique du hub —
58
+ * qui le paierait pour chaque socket ouverte, à chaque tour.
59
+ */
60
+ confirmAccount?(): Promise<boolean>;
48
61
  }
@@ -11,6 +11,7 @@
11
11
  export type { IBackplane, IBackplaneMessage, BackplaneHandler, } from "./IBackplane.js";
12
12
  export type { IRealtimeController, RealtimePublish, RealtimeInboundHandler, } from "./IRealtimeController.js";
13
13
  export type { IChannelPolicy } from "./IChannelPolicy.js";
14
+ export type { IRealtimeConnectionState } from "./IRealtimeConnectionState.js";
14
15
  export type { IRealtimeProbe, IRealtimeHealth, IRealtimeClusterHealth, IRealtimeChannelStat, IRealtimeConnProbe, } from "./IRealtimeProbe.js";
15
16
  export type { IRealtimeToken } from "./IRealtimeToken.js";
16
17
  export type { IRealtimeHandshake } from "./IRealtimeHandshake.js";
@@ -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 —
@@ -26,6 +26,7 @@ import type { IRealtimeController, RealtimePublish, RealtimeInboundHandler } fro
26
26
  * d'action n'est payé que sur une requête `id`. C'est aussi le seam du backplane Redis.
27
27
  */
28
28
  export declare abstract class RealtimeController<Emit extends EventsMap = DefaultEventsMap, Actions extends ActionsMap = DefaultActionsMap> extends Controller implements IRealtimeController {
29
+ #private;
29
30
  /**
30
31
  * Une instance PAR CONNEXION, par construction — le défaut `singleton` des
31
32
  * contrôleurs ne s'applique pas ici. Le pair JSON-RPC de la connexion capture
@@ -111,15 +112,48 @@ export declare abstract class RealtimeController<Emit extends EventsMap = Defaul
111
112
  * est structurel, pas un choix : c'est au client d'attendre `realtime:welcome`
112
113
  * avant de pousser.
113
114
  *
114
- * ⚠️ Cette phrase a longtemps ajouté « ce que `RealtimeClient` fait nativement ».
115
+ * ⚠️ Cette phrase a longtemps ajouté « ce que le client fait nativement ».
115
116
  * C'était FAUX : le client rejouait ses abonnements dès l'ouverture de la socket,
116
117
  * si bien qu'un `subscribe` posé avant le démarrage et TOUS ceux d'après une
117
118
  * reconnexion étaient perdus ici même, sans un mot des deux côtés. Un contrat écrit
118
119
  * 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`.
120
+ * `NodefonySocket.replaySubscriptions` et les deux cas « la fenêtre où le serveur
121
+ * écoute (welcome) » de `NodefonySocketCoverage.test.ts`.
121
122
  */
122
123
  protected handleRealtime(message: string | Buffer | null): void;
124
+ /**
125
+ * Lit les trames BINAIRES de cette connexion — le point d'extension d'un
126
+ * protocole binaire écrit par l'application, à côté du JSON-RPC ; pendant
127
+ * serveur de `NodefonySocket.onBinary`.
128
+ *
129
+ * Le protocole temps réel est du JSON-RPC texte : il ne sait pas à quel canal
130
+ * une trame binaire appartient. Sans cette méthode, une trame binaire est
131
+ * écartée, comptée (`binaryDiscarded` de la sonde de connexion) et journalisée
132
+ * une fois par connexion. La surcharger la reçoit telle quelle, octets exacts,
133
+ * en `Uint8Array` — le type que livre aussi `NodefonySocket.onBinary` dans le
134
+ * navigateur : un décodeur s'écrit une fois pour les deux côtés. À l'exécution
135
+ * c'est un `Buffer` de `ws`, qui en est un.
136
+ *
137
+ * ⚠️ Hors du verrou de trame, et pour toute la série 10 : la trame brute ne
138
+ * passe ni par l'autorisation `beforeDispatch` ni par les gardes d'action et de
139
+ * canal du JSON-RPC. La connexion, elle, a déjà franchi Origin, l'authentificateur
140
+ * et le pare-feu. L'identité se lit par `RequestContext.getAuthenticatedUser()`,
141
+ * et toute décision d'accès sur une trame binaire appartient à l'application.
142
+ *
143
+ * ⚠️ Un `DataView` se construit sur `data.buffer` AVEC `data.byteOffset` et
144
+ * `data.byteLength` : un `Buffer` issu du pool de Node ne commence pas à 0.
145
+ *
146
+ * @param data - les octets reçus.
147
+ */
148
+ protected onRealtimeBinary?(data: Uint8Array): void;
149
+ /**
150
+ * Faute de câblage : `handleRealtime` reçu par une instance qui ne sert pas
151
+ * une connexion WebSocket — route sans `methods: ["WEBSOCKET"]`, ou faux
152
+ * contexte de test sans `type`. Levée plutôt que passée sous silence : le
153
+ * client attendrait un welcome qui ne vient jamais. Chemin froid.
154
+ */
155
+ private outsideWebsocketError;
156
+ private receiveBinary;
123
157
  /**
124
158
  * **L1 — duplex serveur→client.** Émet une REQUÊTE vers le client de CETTE
125
159
  * connexion (sur une action qu'il a exposée via `client.register(...)`) et attend
@@ -143,6 +177,22 @@ export declare abstract class RealtimeController<Emit extends EventsMap = Defaul
143
177
  * handshake n'est pas terminé.
144
178
  */
145
179
  protected notifyClient<K extends EventNames<Emit>>(method: K, params?: EventPayload<Emit, K>): void;
180
+ /**
181
+ * Émet une trame BINAIRE vers CETTE connexion, sans la sérialiser — pendant
182
+ * sortant de {@link onRealtimeBinary}, et de `NodefonySocket.sendBinary` côté
183
+ * client. Même contre-pression que les frames JSON-RPC.
184
+ *
185
+ * @param data - les octets à émettre (`Uint8Array`, donc un `Buffer` aussi).
186
+ * @returns `false` si le handshake n'est pas terminé, la connexion fermée ou
187
+ * la trame jetée par la contre-pression.
188
+ */
189
+ protected sendBinaryToClient(data: Uint8Array): boolean;
190
+ /**
191
+ * État realtime de CETTE connexion, `undefined` avant le welcome — pour les
192
+ * chemins FROIDS (requête sortante, binaire). `notifyClient` et la réception
193
+ * gardent leur lecture en ligne : ils sont sur le chemin de chaque frame.
194
+ */
195
+ private coldConnState;
146
196
  /**
147
197
  * Handshake : seams sécurité #4 (Origin RFC 6455 §10.2) + #2 (authenticator
148
198
  * réseau) PUIS crée peer+transport, enregistre les actions, welcome + cleanup.
@@ -164,8 +214,36 @@ export declare abstract class RealtimeController<Emit extends EventsMap = Defaul
164
214
  * canaux FULL-DUPLEX déclarés (entrée client → handler `realtimeInbound`).
165
215
  */
166
216
  private onRealtimeNotification;
217
+ /**
218
+ * Acquitte un abonnement ACCEPTÉ (`realtime:subscribed`) — le seul signal
219
+ * qui dise au client à partir de quand le flux du canal lui est livré.
220
+ *
221
+ * Un refus n'y passe jamais : il a `realtime:denied`. Cold path (1×/canal et
222
+ * par connexion).
223
+ */
224
+ private acknowledgeSubscription;
167
225
  /** Abonne la connexion à un canal via le hub partagé (idempotent par connexion). */
168
226
  private startChannel;
227
+ /**
228
+ * Enveloppe des actions `@RealtimeAction` dont un paramètre est décoré
229
+ * (`@Body`, `@CurrentUser`…) : l'action reçoit ses arguments comme une route,
230
+ * résolus par la fonction du framework et non par une copie.
231
+ *
232
+ * L'identité ne change PAS : un message est déjà traité dans la bulle
233
+ * `RequestContext` du handshake (`WebsocketContext` lie son écouteur par
234
+ * `AsyncResource.bind`), où le pare-feu a posé `user` et `token`. La bulle
235
+ * ouverte ici HÉRITE de ce store et n'y ajoute que la charge (`body`, que
236
+ * `@Body` lit dans l'ALS comme pour `api.request`) — une écriture
237
+ * `RequestContext.set()` dans l'action reste ainsi propre à l'appel.
238
+ *
239
+ * Une action sans paramètre décoré est rendue telle quelle : rien ne change
240
+ * pour elle, ni comportement ni coût. Les métadonnées se lisent ICI, une fois
241
+ * par connexion.
242
+ *
243
+ * @param ctx - le contexte de la connexion
244
+ * @returns l'enveloppe passée à {@link getRealtimeActions}
245
+ */
246
+ private actionParamsWrapper;
169
247
  /**
170
248
  * Handler de la méthode RPC `api.request {path}` — le **pont API souverain**
171
249
  * (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.
@@ -16,8 +16,9 @@ import { type IRealtimeSocket, type IRealtimeChannel, type IChannelStats, type R
16
16
  * l'état créé est passif, et c'est la fabrique du controller qui tranchera quand
17
17
  * une connexion cliente le demandera. Un service n'invente donc jamais un canal
18
18
  * pour les autres.
19
- * - `request` : **non supporté** — un handle au-dessus du hub n'a PAS de pair unique
20
- * (le hub est multi-clients). Pour un RPC serveur→client 1-1, utiliser
19
+ * - `request` : **non supporté, et déprécié** — un handle au-dessus du hub n'a PAS de
20
+ * pair unique (le hub est multi-clients), et le contrat commun `IRealtimeSocket` ne
21
+ * le promet plus. Pour un RPC serveur→client 1-1, utiliser
21
22
  * `RealtimeController.requestClient` (par connexion, L1).
22
23
  *
23
24
  * Lazy : aucune structure allouée tant que le service n'`on`/`subscribe`/`publish` pas.
@@ -25,7 +26,7 @@ import { type IRealtimeSocket, type IRealtimeChannel, type IChannelStats, type R
25
26
  * @typeParam Emit — canaux pub/sub SORTANTS (`publish`) = ServerToClient.
26
27
  * @typeParam Listen — canaux pub/sub RÉCEPTIONNÉS (`subscribe`/`on`) = ClientToServer.
27
28
  */
28
- export declare class ServerRealtimeSocket<Emit extends EventsMap = DefaultEventsMap, Listen extends EventsMap = DefaultEventsMap, Actions extends ActionsMap = DefaultActionsMap> implements IRealtimeSocket<Emit, Listen, Actions> {
29
+ export declare class ServerRealtimeSocket<Emit extends EventsMap = DefaultEventsMap, Listen extends EventsMap = DefaultEventsMap, Actions extends ActionsMap = DefaultActionsMap> implements IRealtimeSocket<Emit, Listen> {
29
30
  #private;
30
31
  private readonly hub;
31
32
  constructor(hub?: import("./RealtimeHub.js").RealtimeHub);
@@ -39,14 +40,23 @@ export declare class ServerRealtimeSocket<Emit extends EventsMap = DefaultEvents
39
40
  subscribe(channel: EventNames<Listen> | (string & {})): void;
40
41
  /** Désabonne (ref-compté) : coupe le sink du hub au DERNIER consommateur. */
41
42
  unsubscribe(channel: EventNames<Listen> | (string & {})): void;
42
- /** Branche un handler de réception sur un canal. Renvoie un `dispose`. */
43
+ /**
44
+ * Branche un handler de réception sur un canal. Renvoie un `dispose`.
45
+ *
46
+ * Le handler typé par `Listen` se range sans conversion sous
47
+ * `RealtimeHandler`, qui porte la frontière du contrat typé : il reçoit ce
48
+ * que le hub publie, dont `Listen` décrit la forme sans la valider.
49
+ */
43
50
  on<K extends string>(channel: K, handler: K extends EventNames<Listen> ? (payload: EventPayload<Listen, K>) => void : RealtimeHandler): () => void;
44
- /** Retire un handler d'un canal. */
51
+ /** Retire un handler d'un canal — même frontière que {@link on}. */
45
52
  off<K extends string>(channel: K, handler: K extends EventNames<Listen> ? (payload: EventPayload<Listen, K>) => void : RealtimeHandler): void;
46
53
  /**
47
- * **Non supporté.** Un handle au-dessus du hub n'a pas de pair unique
48
- * (multi-clients). Pour un RPC serveur→client 1-1, utiliser
49
- * `RealtimeController.requestClient` (par connexion, L1).
54
+ * **Non supporté** : rejette toujours. Un handle au-dessus du hub n'a pas de pair
55
+ * unique (multi-clients), et le contrat commun `IRealtimeSocket` ne promet plus
56
+ * `request`. Comportement inchangé pour toute la série 10.
57
+ *
58
+ * @deprecated Retrait en 11.0. Pour un RPC serveur→client 1-1, utiliser
59
+ * `RealtimeController.requestClient` (par connexion, L1).
50
60
  */
51
61
  request<K extends string, T = unknown>(_method: K, _params?: ContractParams<Actions, K>, _timeoutMs?: number): Promise<ContractResult<Actions, K, T>>;
52
62
  /** Vue par-canal ({@link IRealtimeChannel}) — fine liaison sur les primitives. */
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Trame d'acquittement d'abonnement — `realtime:subscribed`, écrite d'un bloc.
3
+ *
4
+ * Tout ce qui précède le nom du canal ne dépend que du protocole : il est
5
+ * calculé une fois au chargement du module. Seul le canal se sérialise par
6
+ * trame. On évite ainsi l'enveloppe allouée et le `JSON.stringify` générique
7
+ * de `JsonRpcPeer.notify` — mesuré à ~0,5 µs par acquittement, soit l'essentiel
8
+ * du surcoût d'un `subscribe` acquitté.
9
+ *
10
+ * ⚠️ C'est une SECONDE écriture du format de notification : son égalité avec
11
+ * `JsonRpcPeer.buildNotification` est verrouillée par `subscribedAck.test.ts`,
12
+ * sur des noms de canal hostiles (guillemets, barres obliques inverses,
13
+ * caractères de contrôle, Unicode).
14
+ *
15
+ * @internal
16
+ */
17
+ /**
18
+ * Sérialise la notification `realtime:subscribed { channel }`.
19
+ *
20
+ * @param channel - le canal acquitté (entrée réseau : échappé par `JSON.stringify`).
21
+ * @returns la trame JSON-RPC 2.0 prête à écrire sur le transport.
22
+ */
23
+ export declare function subscribedAckFrame(channel: string): string;
@@ -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";
@@ -6,7 +6,7 @@ import type { IRealtimeConnProbe } from "../../interfaces/IRealtimeProbe.js";
6
6
  * `ctx.connection` (WebsocketContext) la fournit.
7
7
  */
8
8
  export interface RawWsConnection {
9
- send(data: string, cb?: (err?: Error) => void): void;
9
+ send(data: string | ArrayBuffer | ArrayBufferView, cb?: (err?: Error) => void): void;
10
10
  close(code?: number, reason?: string): void;
11
11
  readonly readyState: number;
12
12
  /**
@@ -46,6 +46,8 @@ export declare class WsConnectionTransport implements IRealtimeTransport, IRealt
46
46
  private _bytesSent;
47
47
  private _messagesSent;
48
48
  private _dropped;
49
+ private _binaryDiscarded;
50
+ private _malformedDiscarded;
49
51
  private readonly _max;
50
52
  private readonly _policy;
51
53
  private readonly _closeAfterDrops;
@@ -56,6 +58,29 @@ export declare class WsConnectionTransport implements IRealtimeTransport, IRealt
56
58
  });
57
59
  connect(): void;
58
60
  send(raw: string): void;
61
+ /**
62
+ * Émet des octets tels quels — trame binaire RFC 6455, hors JSON-RPC (#617).
63
+ * Même contre-pression (`decideSend`, la règle unique) et mêmes compteurs que
64
+ * {@link send}. Écrite à part, et non en factorisant `send` : `send` est le
65
+ * chemin de CHAQUE frame JSON-RPC, et n'y paie pas un appel de plus.
66
+ *
67
+ * @param data - les octets à émettre.
68
+ * @returns `true` si la trame est partie, `false` si la connexion était
69
+ * fermée ou la contre-pression l'a jetée.
70
+ */
71
+ sendBinary(data: Uint8Array): boolean;
72
+ /**
73
+ * Compte une trame ENTRANTE écartée et rend le cumul de sa nature — `1` dit
74
+ * « première », le seul instant où l'appelant journalise.
75
+ *
76
+ * @param kind - `binary` (sans lecteur) ou `malformed` (texte non JSON).
77
+ * @returns le cumul de cette nature, après incrément.
78
+ */
79
+ discard(kind: "binary" | "malformed"): number;
80
+ /** Cumul de trames BINAIRES reçues et écartées faute de lecteur — monotone. */
81
+ get binaryDiscarded(): number;
82
+ /** Cumul de trames TEXTE reçues et écartées parce que non JSON — monotone. */
83
+ get malformedDiscarded(): number;
59
84
  close(code?: number, reason?: string): void;
60
85
  get readyState(): number;
61
86
  /** Octets en file d'envoi non drainés (`ws.bufferedAmount`) — risque #1. `0` si mock. */
@@ -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";
@@ -130,7 +130,10 @@ export interface IRealtimeHarness<C extends RealtimeController> {
130
130
  * échec) : lire `closes` pour en connaître la cause.
131
131
  */
132
132
  connect(): Promise<IRealtimeFrame>;
133
- /** Demande un canal, comme le ferait un client. */
133
+ /**
134
+ * Demande un canal, comme le ferait un client, et attend la réponse du
135
+ * serveur (acquittement `realtime:subscribed` ou refus `realtime:denied`).
136
+ */
134
137
  subscribe(channel: string): Promise<void>;
135
138
  /** Rend un canal — le provider est disposé au dernier abonné. */
136
139
  unsubscribe(channel: string): Promise<void>;
@@ -149,6 +152,8 @@ export interface IRealtimeHarness<C extends RealtimeController> {
149
152
  messages(channel: string): readonly unknown[];
150
153
  /** Les refus d'abonnement notifiés (`realtime:denied`). */
151
154
  denials(): readonly IHarnessDenied[];
155
+ /** Les canaux dont l'abonnement a été acquitté (`realtime:subscribed`), dans l'ordre. */
156
+ subscriptions(): readonly string[];
152
157
  /** Ferme la socket côté client : la base dispose alors tous ses providers. */
153
158
  close(): void;
154
159
  /** `close()` puis remise à zéro du hub. À appeler en fin de cas. */