@nodefony/realtime 10.0.0-beta.5 → 10.0.0-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +55 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +91 -12
  4. package/dist/nodefony/config/config.js +7 -0
  5. package/dist/nodefony/decorators/realtimeDecorators.js +341 -43
  6. package/dist/nodefony/interfaces/IRealtimeConnection.js +1 -0
  7. package/dist/nodefony/src/backplane/ClusterBackplane.js +32 -4
  8. package/dist/nodefony/src/backplane/RedisBackplane.js +146 -41
  9. package/dist/nodefony/src/backplane/controlOrder.js +86 -0
  10. package/dist/nodefony/src/backplane/envelope.js +505 -27
  11. package/dist/nodefony/src/backplane/ingress.js +93 -0
  12. package/dist/nodefony/src/backplane/presenceMessage.js +205 -0
  13. package/dist/nodefony/src/backplane/publishQueue.js +97 -3
  14. package/dist/nodefony/src/cluster/ClusterProbeClient.js +2 -0
  15. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
  16. package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
  17. package/dist/nodefony/src/server/RealtimeConnection.js +135 -0
  18. package/dist/nodefony/src/server/RealtimeController.js +409 -107
  19. package/dist/nodefony/src/server/RealtimeHub.js +1158 -111
  20. package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
  21. package/dist/nodefony/src/server/admissionBudget.js +176 -0
  22. package/dist/nodefony/src/server/channelPolicyRegistry.js +95 -0
  23. package/dist/nodefony/src/server/channelPolicyResolution.js +191 -0
  24. package/dist/nodefony/src/server/channelPresence.js +488 -0
  25. package/dist/nodefony/src/server/connectionAdmission.js +242 -0
  26. package/dist/nodefony/src/server/contractRoutes.js +75 -0
  27. package/dist/nodefony/src/server/deniedDetail.js +10 -3
  28. package/dist/nodefony/src/server/frameAdmission.js +160 -0
  29. package/dist/nodefony/src/server/presenceBus.js +486 -0
  30. package/dist/nodefony/src/server/realtimeShutdown.js +31 -0
  31. package/dist/nodefony/src/server/remotePresence.js +541 -0
  32. package/dist/nodefony/src/server/subscribedAck.js +13 -1
  33. package/dist/nodefony/src/server/syslogUplink.js +3 -4
  34. package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
  35. package/dist/nodefony/src/service/RealtimeService.js +51 -1
  36. package/dist/nodefony/src/service/publicOrigin.js +40 -0
  37. package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
  38. package/dist/nodefony/testing/backplaneConformance.js +347 -0
  39. package/dist/nodefony/testing/index.js +43 -12
  40. package/dist/nodefony/testing/presenceClient.js +54 -0
  41. package/dist/types/index.d.ts +39 -7
  42. package/dist/types/nodefony/config/config.d.ts +9 -0
  43. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +198 -17
  44. package/dist/types/nodefony/interfaces/IBackplane.d.ts +46 -51
  45. package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
  46. package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
  47. package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
  48. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
  49. package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
  50. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
  51. package/dist/types/nodefony/interfaces/index.d.ts +2 -1
  52. package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
  53. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +89 -18
  54. package/dist/types/nodefony/src/backplane/controlOrder.d.ts +75 -0
  55. package/dist/types/nodefony/src/backplane/envelope.d.ts +77 -0
  56. package/dist/types/nodefony/src/backplane/ingress.d.ts +48 -0
  57. package/dist/types/nodefony/src/backplane/presenceMessage.d.ts +150 -0
  58. package/dist/types/nodefony/src/backplane/publishQueue.d.ts +25 -1
  59. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +10 -0
  60. package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
  61. package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +20 -0
  62. package/dist/types/nodefony/src/server/RealtimeController.d.ts +110 -12
  63. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +448 -42
  64. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
  65. package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
  66. package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +41 -0
  67. package/dist/types/nodefony/src/server/channelPolicyResolution.d.ts +112 -0
  68. package/dist/types/nodefony/src/server/channelPresence.d.ts +155 -0
  69. package/dist/types/nodefony/src/server/connectionAdmission.d.ts +68 -0
  70. package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
  71. package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
  72. package/dist/types/nodefony/src/server/frameAdmission.d.ts +66 -0
  73. package/dist/types/nodefony/src/server/presenceBus.d.ts +112 -0
  74. package/dist/types/nodefony/src/server/realtimeShutdown.d.ts +21 -0
  75. package/dist/types/nodefony/src/server/remotePresence.d.ts +151 -0
  76. package/dist/types/nodefony/src/server/subscribedAck.d.ts +10 -0
  77. package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
  78. package/dist/types/nodefony/src/service/RealtimeService.d.ts +17 -2
  79. package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
  80. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
  81. package/dist/types/nodefony/testing/backplaneConformance.d.ts +78 -0
  82. package/dist/types/nodefony/testing/index.d.ts +13 -2
  83. package/dist/types/nodefony/testing/presenceClient.d.ts +20 -0
  84. package/docs/actions.md +420 -18
  85. package/docs/architecture.md +101 -11
  86. package/docs/cadence-et-contre-pression.md +87 -11
  87. package/docs/canaux-a-membres.md +277 -0
  88. package/docs/cas-d-usage.md +1703 -0
  89. package/docs/configuration.md +175 -19
  90. package/docs/cookbook-chat.md +701 -224
  91. package/docs/index.md +11 -1
  92. package/docs/observabilite.md +97 -21
  93. package/docs/protocole.md +142 -33
  94. package/docs/securite.md +347 -41
  95. package/docs/vocabulaire.md +13 -12
  96. package/package.json +16 -13
@@ -1,6 +1,8 @@
1
- import type { JsonRpcPeer } from "nodefony";
1
+ import type { IFrameCodec, JsonRpcPeer } from "nodefony";
2
2
  import type { WsConnectionTransport } from "../src/transport/WsConnectionTransport.js";
3
3
  import type { ChannelSink } from "../src/server/RealtimeHub.js";
4
+ import type { RealtimeConnection } from "../src/server/RealtimeConnection.js";
5
+ import type { ConnectionAdmission } from "../src/server/connectionAdmission.js";
4
6
  import type { RealtimeInboundHandler } from "./IRealtimeController.js";
5
7
  /**
6
8
  * État du protocole temps réel d'UNE connexion WebSocket — posé au welcome, et
@@ -22,10 +24,47 @@ export interface IRealtimeConnectionState {
22
24
  peer: JsonRpcPeer;
23
25
  /** Le transport qui relie le pair à la socket (contre-pression, sonde). */
24
26
  transport: WsConnectionTransport;
27
+ /**
28
+ * Le codec BINAIRE accordé à cette connexion au handshake (ADR-0022 §2), `null`
29
+ * pour une connexion restée en texte. Non nul, toute trame binaire reçue est une
30
+ * frame du codec, et le flux brut (`onRealtimeBinary`, `sendBinaryToClient`) est
31
+ * refusé : les octets passent par les canaux, et leurs portes.
32
+ */
33
+ binaryCodec: IFrameCodec | null;
25
34
  /** Canal → sink de CETTE connexion auprès du hub partagé, pour s'en désabonner. */
26
35
  channels: Map<string, ChannelSink>;
27
- /** Canaux full-duplex qui acceptent une entrée client ; `null` s'il n'y en a aucun. */
36
+ /**
37
+ * Canaux full-duplex qui acceptent une entrée client ; `null` s'il n'y en a aucun.
38
+ * Objet ordinaire, de même forme pour toutes les connexions d'un contrôleur :
39
+ * un nom hérité de son prototype (`__proto__`, `toString`) est refusé par le
40
+ * verrou des trames avant d'atteindre la distribution.
41
+ */
28
42
  inbound: Record<string, RealtimeInboundHandler> | null;
43
+ /**
44
+ * File d'admission aux canaux à `allow` — `null` tant que la connexion n'a
45
+ * demandé aucun de ces canaux (la plupart ne le font jamais).
46
+ */
47
+ admission: ConnectionAdmission | null;
48
+ /**
49
+ * Canaux entrants dont les trames passent sans le verrou : ouverts par `allow`,
50
+ * ou dont le verrou a déjà dit oui (ADR-0022 §6, décision 1 ter). Le coût d'une
51
+ * trame chaude tient dans cette lecture (`admitted[method] === true`). Mêmes
52
+ * clés que {@link inbound}, à `false` tant que non admises : une seule forme
53
+ * d'objet pour toutes les connexions d'un contrôleur. `null` jusqu'à la
54
+ * première admission.
55
+ */
56
+ admitted: Record<string, boolean> | null;
57
+ /**
58
+ * Ceux de {@link admitted} que le VERROU a admis — au plus 64, vidés quand une
59
+ * politique arrive après coup. `null` s'il n'y en a aucun.
60
+ */
61
+ retained: string[] | null;
62
+ /**
63
+ * Poignée publique de la connexion (membre d'un canal, argument des
64
+ * gestionnaires `@RealtimeInbound`) — `null` jusqu'au premier abonnement ou à
65
+ * la première trame entrante qui la demande.
66
+ */
67
+ connection: RealtimeConnection | null;
29
68
  }
30
69
  declare module "@nodefony/http" {
31
70
  interface IWebsocketConnectionState {
@@ -1,5 +1,33 @@
1
- /** Publie une charge sur un canal (notification serveur→client). */
2
- export type RealtimePublish = (channel: string, payload: unknown) => void;
1
+ import type { ContractServerEmit, EventNames, EventPayload, EventsMap, IRealtimeContract } from "nodefony";
2
+ import type { IRealtimeConnection } from "./IRealtimeConnection.js";
3
+ /**
4
+ * Publie une charge sur un canal (notification serveur→client) — forme LIBRE :
5
+ * tout nom, toute charge objet. Sous un contrat, préférer {@link ContractRealtimePublish}.
6
+ */
7
+ export type RealtimePublish = (channel: string, payload?: object) => void;
8
+ /**
9
+ * Publie sur un canal DÉCLARÉ par le contrat `C` : le nom est l'un des canaux de
10
+ * `serverToClient`, la charge est celle qu'il déclare. Une faute de frappe dans
11
+ * le nom ou dans la charge ne compile pas.
12
+ *
13
+ * C'est le `publish` qu'une fabrique de canal déclare sous un contrat — méthode
14
+ * `@RealtimeChannel` ou surcharge `createRealtimeChannel`. La fonction reçue à
15
+ * l'exécution est la même qu'en forme libre ({@link RealtimePublish}) : seul le
16
+ * compilateur voit la différence. La charge est un objet : le hub refuse un
17
+ * scalaire à l'exécution (`isRealtimePayload`), le type le dit avant.
18
+ *
19
+ * @typeParam C - le contrat de l'application.
20
+ *
21
+ * @example
22
+ * ```ts
23
+ * @RealtimeChannel("chat:presence", { authenticated: true })
24
+ * presence(_channel: string, publish: ContractRealtimePublish<ChatContract>) {
25
+ * publish("chat:presence", { online: 3 });
26
+ * return () => undefined;
27
+ * }
28
+ * ```
29
+ */
30
+ export type ContractRealtimePublish<C extends IRealtimeContract> = <K extends EventNames<ContractServerEmit<C>>>(channel: K, payload: EventPayload<ContractServerEmit<C>, K> & object) => void;
3
31
  /**
4
32
  * Handler d'un message ENTRANT sur un canal full-duplex (client → serveur). C'est le
5
33
  * seam des backings entrants (SIP, bridge) : le client `publish(channel, payload)` ; si
@@ -9,10 +37,43 @@ export type RealtimePublish = (channel: string, payload: unknown) => void;
9
37
  * valider (origine non authentifiée au niveau transport).
10
38
  * - `reply` = pousse une charge serveur→client sur le MÊME canal, vers CETTE connexion
11
39
  * uniquement (ex. réponse SIP). Per-connexion (≠ fan-out).
40
+ * - `connection` = la connexion émettrice, locale à ce processus : de quoi l'abonner
41
+ * d'office à un canal (`RealtimeHub.subscribeConnection`) ou l'en éjecter. Un
42
+ * gestionnaire qui n'en a pas l'usage l'omet.
12
43
  *
13
44
  * Par défaut aucun canal n'accepte d'entrée (sûr) : un contrôleur opte explicitement.
14
45
  */
15
- export type RealtimeInboundHandler = (params: unknown, reply: (payload: unknown) => void) => void;
46
+ export type RealtimeInboundHandler = (params: unknown, reply: (payload: unknown) => void, connection: IRealtimeConnection) => void;
47
+ /**
48
+ * Handler d'entrée TYPÉ par le contrat : `params` est ce que le client a émis sur
49
+ * ce canal, lu par `Jsonify` ; `reply` pousse la charge que le serveur émet sur le
50
+ * même canal. Les paramètres restent NON FIABLES : le type n'est pas une validation.
51
+ *
52
+ * Écrit en signature de MÉTHODE pour que ses paramètres se comparent de façon
53
+ * bivariante : c'est ce qui permet à une table typée de remplacer la table
54
+ * permissive de la base, que le contrôleur lit en `unknown` à l'exécution.
55
+ */
56
+ export type TypedRealtimeInboundHandler<P, R> = {
57
+ handle(params: P, reply: (payload: R) => void, connection: IRealtimeConnection): void;
58
+ }["handle"];
59
+ /**
60
+ * Table des entrées client d'un contrôleur : un handler par canal de `Listen`,
61
+ * chacun facultatif — accepter une entrée reste un choix explicite, canal par
62
+ * canal (par défaut, un client ne peut rien pousser). `Listen` non déclaré :
63
+ * n'importe quel nom, `params: unknown`.
64
+ */
65
+ export type RealtimeInboundHandlers<Listen extends EventsMap, Emit extends EventsMap> = {
66
+ [K in EventNames<Listen>]?: TypedRealtimeInboundHandler<EventPayload<Listen, K>, K extends keyof Emit ? Emit[K] : unknown>;
67
+ };
68
+ /**
69
+ * Handler d'action RPC tel que la table de `realtimeActions()` le range. Signature
70
+ * de MÉTHODE (paramètre comparé de façon bivariante) : une table typée par le
71
+ * contrat, dont chaque handler attend sa forme, remplace ainsi la table
72
+ * permissive de la base.
73
+ */
74
+ export type RealtimeActionHandler = {
75
+ handle(params: unknown): unknown;
76
+ }["handle"];
16
77
  /**
17
78
  * IRealtimeController — contrat d'un contrôleur temps réel SERVEUR (endpoint WS
18
79
  * JSON-RPC 2.0). Le protocole (discrimination, dispatch, actions, pending, pub/sub,
@@ -13,8 +13,7 @@
13
13
  * seuil de drop). Tous les cumuls sont **monotones** → le débit/s se dérive côté
14
14
  * lecteur (delta `total`/`ts`, comme le CPU% ou le flux ORM) : 0 état de lecture.
15
15
  */
16
- import type { IProcessHealth, IProcessRich, IOrmLeanHealth, IInstanceErrorHealth } from "nodefony";
17
- import type { IBackplaneInfo } from "./IBackplane.js";
16
+ export type { IRealtimeChannelStat, IRealtimeProbe, IRealtimeHealth, IRealtimeClusterHealth, RealtimeHealthReport, } from "nodefony";
18
17
  /** Vue d'UNE connexion realtime pour la sonde (backpressure = risque #1). */
19
18
  export interface IRealtimeConnProbe {
20
19
  /** État du transport (0..3, aligné `WebSocket.readyState`). */
@@ -35,172 +34,14 @@ export interface IRealtimeConnProbe {
35
34
  * coupe un slow-consumer (`close 1013`). `> 0` = ce client n'absorbe pas le flux.
36
35
  */
37
36
  readonly dropped: number;
38
- }
39
- /** Stat per-canal du hub (fan-out). */
40
- export interface IRealtimeChannelStat {
41
- /** Nom du canal (suffixe `:<ms>` de granularité inclus). */
42
- channel: string;
43
- /** Abonnés locaux vivants (sinks) — instantané. */
44
- subscribers: number;
45
- /** Publications cumulées sur ce canal (monotone) → débit dérivé côté lecteur. */
46
- messages: number;
47
- }
48
- /**
49
- * Snapshot PUR du hub (sans identité process) — ce que renvoie
50
- * {@link RealtimeHub.probe}. Per-instance/pod (cloud-native) ; l'agrégat multi-pod
51
- * = backplane Redis / Prometheus (P13).
52
- */
53
- export interface IRealtimeProbe {
54
- /** Horodatage du snapshot (ms epoch) — base du calcul de débit côté lecteur. */
55
- ts: number;
56
- /** Canaux actifs (≥ 1 abonné) avec abonnés + publications cumulées. */
57
- channels: IRealtimeChannelStat[];
58
- /** Nombre de canaux actifs (= 1 provider/canal/pod). */
59
- channelCount: number;
60
- /** Appels `publish` cumulés (monotone). */
61
- publishTotal: number;
62
- /** Livraisons cumulées (`publish` × abonnés) — vrai coût de fan-out (monotone). */
63
- fanoutTotal: number;
64
- /** Frames entrantes full-duplex cumulées (canaux gated SIP/bridge) (monotone). */
65
- inboundTotal: number;
66
- /**
67
- * Messages d'ingress backplane REFUSÉS (canal non déclaré broadcast) — monotone.
68
- * Reste à 0 en fonctionnement normal : un pair légitime n'émet que des canaux
69
- * broadcast. Un compteur qui décolle = pair mal configuré, ou écriture tierce
70
- * dans un bus partagé (Redis mutualisé). Cf `RealtimeHub` (contrôle d'admission).
71
- */
72
- ingressRejectedTotal: number;
73
37
  /**
74
- * Abonnements clients REFUSÉS par le plancher des canaux de plateforme —
75
- * monotone. Un canal réservé (`syslog:`, `orm:`…) a été demandé alors qu'aucun
76
- * module de sécurité n'est chargé : sans identité vérifiable, le hub ferme.
77
- * `> 0` signale une configuration incomplète (sécurité absente) autant qu'une
78
- * tentative d'accès — dans les deux cas, quelque chose est à regarder.
38
+ * Cumul de trames BINAIRES reçues et écartées faute de lecteur (monotone) :
39
+ * connexion texte sans `onRealtimeBinary`. Absent = la connexion ne compte pas.
79
40
  */
80
- systemFloorDeniedTotal: number;
81
- /** Connexions realtime vivantes. */
82
- connectionCount: number;
83
- /** Somme cumulée des octets envoyés, toutes connexions (monotone). */
84
- bytesSentTotal: number;
85
- /** Somme cumulée des frames envoyées, toutes connexions (monotone). */
86
- messagesSentTotal: number;
87
- /** Backpressure agrégée (instantanée) — risque #1. */
88
- backpressure: {
89
- /** Pire `bufferedAmount` parmi les connexions (octets). */
90
- maxBufferedAmount: number;
91
- /** Somme des `bufferedAmount` (octets en attente process-wide). */
92
- totalBufferedAmount: number;
93
- /** Connexions au-dessus du seuil d'alerte slow-consumer. */
94
- slowConsumers: number;
95
- /**
96
- * Cumul de frames jetées par back-pressure (drop latest-wins + close
97
- * slow-consumer), toutes connexions (monotone) → taux de perte dérivé côté
98
- * lecteur. `0` en régime sain ; croît = des clients lents sont contenus pour
99
- * protéger la mémoire process (sinon file `ws` non bornée → OOM).
100
- */
101
- drops: number;
102
- };
41
+ readonly binaryDiscarded?: number;
103
42
  /**
104
- * Carte d'identité du backplane effectif (driver, transport, cross-pod, canal).
105
- * Reflète l'état RÉEL : `local` si le hub n'a aucun backplane (mono-process ou
106
- * fallback fail-soft), sinon le descripteur du driver branché. Optionnel pour
107
- * rétrocompat des consommateurs qui construisent un probe partiel.
43
+ * Cumul de trames reçues et écartées parce qu'illisibles par le codec de la
44
+ * connexion (monotone). Absent = la connexion ne compte pas.
108
45
  */
109
- backplane?: IBackplaneInfo;
110
- }
111
- /** Snapshot enrichi de l'identité d'instance — santé per-instance d'un worker/pod. */
112
- export interface IRealtimeHealth extends IRealtimeProbe {
113
- /** Identifiant de CE process/pod (per-instance). */
114
- instanceId: string;
115
- /**
116
- * Santé PROCESS du worker (CPU/mém/event-loop) — additif : la grille « salle des
117
- * machines » de la vue pod lit `instances[].process` par worker. Optionnel (absent si
118
- * sonde process coupée) → les consommateurs realtime existants l'ignorent (non-breaking).
119
- */
120
- process?: IProcessHealth;
121
- /**
122
- * Sonde process **riche** (GC/heap-spaces/handles/ELU active-idle/ctx-switches) —
123
- * présente UNIQUEMENT pendant un drill-down de CE worker (Phase 2 : le master a envoyé
124
- * `nf:probe:enrich` à ce pid). Absente sinon → « on paie ce qu'on regarde » (0 surcoût
125
- * hors drill). Fusionnée côté front avec `process` pour la vue Supervision complète.
126
- */
127
- rich?: IProcessRich;
128
- /**
129
- * Santé ORM **lean** du worker (connecteurs/requêtes/erreurs/latence EWMA) — additif,
130
- * comme `process`. Présent si un driver ORM a branché sa sonde (`setOrmHealthProvider`),
131
- * absent sinon → les consommateurs realtime l'ignorent (non-breaking). Agrégée pod dans
132
- * {@link IRealtimeClusterHealth.totals}.`orm`.
133
- */
134
- orm?: IOrmLeanHealth;
135
- /**
136
- * Compteurs d'erreurs Syslog du worker (ERROR/CRITIC cumulés) — additif. Permet une
137
- * carte « erreurs par worker » + un taux d'erreur pod (delta côté lecteur). Absent si la
138
- * sonde n'a pu lire le syslog du kernel.
139
- */
140
- errors?: IInstanceErrorHealth;
141
- /**
142
- * Drivers de backplane **enregistrés dans ce process** (clés du registre ouvert :
143
- * natifs + ceux qu'une application a ajoutés par `registerBackplaneDriver`). Sert la
144
- * même question que la liste des backends côté stores : « qu'est-ce que je POURRAIS
145
- * brancher ? », à laquelle `backplane.driver` (l'actif) ne répond pas. Absent si le
146
- * registre n'est pas joignable.
147
- */
148
- backplaneDrivers?: string[];
149
- /**
150
- * Diagnostic ORM **riche** du worker (par connecteur : ping/latence/stockage/pool +
151
- * flux requêtes) — blob OPAQUE `{ health, flow }` produit par le driver, présent
152
- * UNIQUEMENT pendant un drill ORM de CE worker (facette `"orm"` : le master a envoyé
153
- * `nf:probe:enrich {facet:"orm"}` à ce pid). Absent sinon → « on paie ce qu'on regarde »
154
- * (0 ping ORM hors drill). Consommé par la page drill `/nodefony/orm/<pid>` (canal
155
- * `nodefony:orm:rich@<pid>`) → diagnostic riche du pid EXACT (≠ round-robin).
156
- */
157
- ormRich?: unknown;
158
- }
159
- /**
160
- * Vue **POD agrégée** de la socket (Phase 4c, mode cluster) — la santé de TOUS les workers
161
- * du pod consolidée. Servie par n'importe quel worker via l'endpoint santé quand le master
162
- * pousse le snapshot agrégé (sinon on retombe sur {@link IRealtimeHealth} per-instance).
163
- *
164
- * `cluster: true` est le discriminant : un consommateur (Studio) distingue la vue pod de la
165
- * vue per-instance. `instances` garde le détail par worker (drill-down) ; `totals` somme les
166
- * scalaires (la backpressure `maxBufferedAmount` est un MAX, pas une somme — c'est le pire pod).
167
- */
168
- export interface IRealtimeClusterHealth {
169
- /** Discriminant : vue agrégée multi-worker (≠ {@link IRealtimeHealth} per-instance). */
170
- cluster: true;
171
- /** Horodatage du snapshot agrégé (ms epoch). */
172
- ts: number;
173
- /** Nombre de workers présents dans l'agrégat. */
174
- instanceCount: number;
175
- /** Santé par worker (détail per-instance pour le drill-down). */
176
- instances: IRealtimeHealth[];
177
- /** Totaux pod (sommes des scalaires ; backpressure max = pire worker). */
178
- totals: {
179
- channelCount: number;
180
- publishTotal: number;
181
- fanoutTotal: number;
182
- inboundTotal: number;
183
- /** Ingress backplane refusés, tous workers (signal de sécurité pod-wide). */
184
- ingressRejectedTotal: number;
185
- connectionCount: number;
186
- bytesSentTotal: number;
187
- messagesSentTotal: number;
188
- backpressure: {
189
- /** Pire `maxBufferedAmount` parmi les workers (octets) — pas une somme. */
190
- maxBufferedAmount: number;
191
- /** Somme des octets en attente, tous workers. */
192
- totalBufferedAmount: number;
193
- /** Total des slow-consumers du pod. */
194
- slowConsumers: number;
195
- /** Total des frames jetées par back-pressure (drop + close), tous workers. */
196
- drops: number;
197
- };
198
- /**
199
- * Santé ORM agrégée du pod (sommes ; `maxEwmaMs` = pire worker). Présent si ≥ 1 worker
200
- * a remonté sa sonde ORM, absent sinon.
201
- */
202
- orm?: IOrmLeanHealth;
203
- /** Erreurs Syslog agrégées du pod (sommes). Présent si ≥ 1 worker a remonté ses compteurs. */
204
- errors?: IInstanceErrorHealth;
205
- };
46
+ readonly malformedDiscarded?: number;
206
47
  }
@@ -1,3 +1,4 @@
1
+ import type { ISubscriberIdentity, TokenStatus } from "nodefony";
1
2
  /**
2
3
  * Jeton realtime — surface MINIMALE de l'identité d'une connexion WebSocket.
3
4
  *
@@ -45,6 +46,15 @@ export interface IRealtimeToken {
45
46
  * (publish/subscribe), payé seulement sur `api.request`.
46
47
  */
47
48
  isValid?(nowMs?: number): boolean | Promise<boolean>;
49
+ /**
50
+ * La CAUSE de l'état de l'identité, quand elle est connaissable : `active`,
51
+ * `expired` (jeton porteur arrivé à `exp`) ou `revoked` (révoqué l'emporte sur
52
+ * expiré). Le hub en tire le code de fermeture d'une socket ouverte : `expired` →
53
+ * `TOKEN_EXPIRED_CLOSE_CODE` (4006), sinon `IDENTITY_DEAD_CLOSE_CODE` (4001) —
54
+ * ADR-0021 §S11. Optionnel : absent, `isValid` décide et son `false` vaut 4001
55
+ * (une session ne sait pas dire si elle a expiré ou été détruite).
56
+ */
57
+ status?(nowMs?: number): TokenStatus | Promise<TokenStatus>;
48
58
  /**
49
59
  * Le COMPTE qui a ouvert la socket existe-t-il toujours, actif, et est-ce
50
60
  * bien le même ? `false` si le compte a disparu, a été désactivé ou
@@ -58,4 +68,15 @@ export interface IRealtimeToken {
58
68
  * qui le paierait pour chaque socket ouverte, à chaque tour.
59
69
  */
60
70
  confirmAccount?(): Promise<boolean>;
71
+ /**
72
+ * Identité d'ADMISSION de la connexion — la donnée plate que reçoit la règle `allow` d'un canal, construite UNE fois et gelée.
73
+ *
74
+ * Rend toujours la MÊME référence : elle est lue au `welcome` puis à chaque
75
+ * admission, jamais recalculée. Optionnelle pour un authentificateur tiers ;
76
+ * absente, la connexion n'entre sur AUCUN canal qui déclare `allow` (refus
77
+ * `forbidden` et une ligne de journal) — jamais un `id` déduit de
78
+ * {@link getUserIdentifier}, qui est un login et se réattribue
79
+ * (ADR-0021 §S10).
80
+ */
81
+ getIdentity?(): ISubscriberIdentity;
61
82
  }
@@ -9,9 +9,10 @@
9
9
  * `@nodefony/security` (P6) les implémente sans coupler ce module au security.
10
10
  */
11
11
  export type { IBackplane, IBackplaneMessage, BackplaneHandler, } from "./IBackplane.js";
12
- export type { IRealtimeController, RealtimePublish, RealtimeInboundHandler, } from "./IRealtimeController.js";
12
+ export type { IRealtimeController, RealtimePublish, ContractRealtimePublish, RealtimeInboundHandler, } from "./IRealtimeController.js";
13
13
  export type { IChannelPolicy } from "./IChannelPolicy.js";
14
14
  export type { IRealtimeConnectionState } from "./IRealtimeConnectionState.js";
15
+ export type { IRealtimeConnection } from "./IRealtimeConnection.js";
15
16
  export type { IRealtimeProbe, IRealtimeHealth, IRealtimeClusterHealth, IRealtimeChannelStat, IRealtimeConnProbe, } from "./IRealtimeProbe.js";
16
17
  export type { IRealtimeToken } from "./IRealtimeToken.js";
17
18
  export type { IRealtimeHandshake } from "./IRealtimeHandshake.js";
@@ -2,8 +2,11 @@ import type { IBackplane, BackplaneHandler, IBackplaneMessage, IBackplaneInfo }
2
2
  import { CLUSTER_RT_KIND } from "nodefony";
3
3
  /**
4
4
  * Enveloppe IPC d'une publication realtime = {@link IBackplaneMessage} + discriminant.
5
- * Sérialisée une fois par `process.send`/`worker.send` (`structuredClone` IPC) → le
6
- * `payload` doit rester structurellement clonable (cf contrat IBackplane).
5
+ * Sérialisée une fois par `process.send`/`worker.send`. Le master de Nodefony forke
6
+ * ses workers en sérialisation `advanced` (clonage structuré,
7
+ * `src/nodefony/src/service/cluster/clusterMaster.ts#startClusterMaster`) : un
8
+ * `Uint8Array` y traverse intact, sans codec — l'IPC ne quitte pas la machine, il
9
+ * n'a ni sceau ni second sujet à porter.
7
10
  */
8
11
  export interface ClusterBackplaneEnvelope extends IBackplaneMessage {
9
12
  readonly kind: typeof CLUSTER_RT_KIND;
@@ -26,6 +29,11 @@ export interface IClusterBackplaneTransport {
26
29
  * Appelé une seule fois, au `start()`.
27
30
  */
28
31
  onReceive(cb: (msg: unknown) => void): void;
32
+ /**
33
+ * Le canal porte-t-il un `Uint8Array` intact (sérialisation `advanced`) ?
34
+ * Optionnel : absent, les octets restent sur ce process.
35
+ */
36
+ carriesBytes?(): boolean;
29
37
  }
30
38
  /**
31
39
  * Transport de production : `process.send` (émission) + `process.on("message")`
@@ -60,7 +68,7 @@ export declare class ClusterBackplane implements IBackplane {
60
68
  static readonly driver = "cluster";
61
69
  readonly originId: string;
62
70
  constructor(transport?: IClusterBackplaneTransport, originId?: string);
63
- /** Branche la réception IPC. Idempotent. */
71
+ /** Branche la réception IPC. Idempotent, et redémarrable après {@link stop}. */
64
72
  start(): void;
65
73
  /**
66
74
  * Propage une publication locale aux autres pairs via le master. NE refait PAS le
@@ -69,7 +77,17 @@ export declare class ClusterBackplane implements IBackplane {
69
77
  publish(channel: string, payload: unknown): void;
70
78
  /** Un seul handler d'ingress ; un appel ultérieur remplace le précédent. */
71
79
  onMessage(handler: BackplaneHandler): void;
72
- /** Détache le handler. Le listener du transport prod (`process`) survit au process. */
80
+ /**
81
+ * Les octets traversent l'IPC tels quels quand le canal est en clonage
82
+ * structuré : aucun codec n'est appliqué, l'argument est ignoré.
83
+ *
84
+ * @returns `true` si le transport constate un canal `advanced`.
85
+ */
86
+ enableBytes(): boolean;
87
+ /**
88
+ * Coupe la réception et détache le handler. Le récepteur du transport survit
89
+ * (`process` ne le retire pas) mais ne livre plus rien jusqu'au prochain {@link start}.
90
+ */
73
91
  stop(): void;
74
92
  describe(): IBackplaneInfo;
75
93
  }
@@ -1,4 +1,5 @@
1
1
  import type { IBackplane, BackplaneHandler, IBackplaneInfo } from "../../interfaces/IBackplane.js";
2
+ import { type IFrameCodec } from "nodefony";
2
3
  import { type BackplaneNotice } from "./publishQueue.js";
3
4
  /**
4
5
  * BASE du canal Redis **dédié** transportant les enveloppes realtime entre pods.
@@ -22,6 +23,17 @@ export declare const REDIS_RT_CHANNEL = "nodefony:realtime";
22
23
  * @returns le nom du canal Redis à `SUBSCRIBE`/`PUBLISH`.
23
24
  */
24
25
  export declare function resolveRedisChannel(namespace?: string): string;
26
+ /**
27
+ * Construit le **sujet binaire** du bus (ADR-0021 S6) : le canal texte suffixé du
28
+ * protocole du codec. Un second sujet, versionné par son nom, que les exemplaires
29
+ * plus anciens n'écoutent pas — le message texte n'est pas touché, et un codec
30
+ * binaire d'un autre format aurait son propre sujet.
31
+ *
32
+ * @param redisChannel - le canal texte effectif ({@link resolveRedisChannel}).
33
+ * @param codec - le codec binaire du serveur.
34
+ * @returns le nom du canal Redis qui porte les enveloppes binaires.
35
+ */
36
+ export declare function resolveRedisBytesChannel(redisChannel: string, codec: IFrameCodec): string;
25
37
  /**
26
38
  * Transport pub/sub du backplane Redis — abstrait `PUBLISH` / `SUBSCRIBE` derrière
27
39
  * un seam **injectable** (même pattern que `IClusterBackplaneTransport`). Permet de
@@ -39,12 +51,18 @@ export interface IRedisBackplaneTransport {
39
51
  * {@link BackplanePublishQueue}). Un transport synchrone (bus mémoire de test) rend
40
52
  * `void` — il n'a pas de file, la borne reste alors inerte.
41
53
  */
42
- publish(channel: string, message: string): void | Promise<unknown>;
54
+ publish(channel: string, message: string | Uint8Array): void | Promise<unknown>;
43
55
  /**
44
56
  * Abonne au canal Redis. Le listener reçoit le message brut (string) ; le tri
45
57
  * (parse + anti-echo) est fait par le backplane. Async (Redis) ou sync (fake).
46
58
  */
47
59
  subscribe(channel: string, onMessage: (message: string) => void): void | Promise<void>;
60
+ /**
61
+ * Abonne au canal Redis en mode **octets** : le listener reçoit le message tel
62
+ * que publié, sans décodage UTF-8 (sujet binaire, ADR-0021 S6). Optionnel : un
63
+ * transport qui ne l'offre pas laisse les octets sur le serveur qui les publie.
64
+ */
65
+ subscribeBytes?(channel: string, onMessage: (message: Uint8Array) => void): void | Promise<void>;
48
66
  /** Désabonne du canal Redis et libère le listener associé. Idempotent. */
49
67
  unsubscribe(channel: string): void | Promise<void>;
50
68
  }
@@ -54,18 +72,53 @@ export interface IRedisBackplaneTransport {
54
72
  * en dépendance de `@nodefony/realtime`.
55
73
  */
56
74
  export interface IRedisPublisher {
57
- publish(channel: string, message: string): unknown;
75
+ publish(channel: string, message: string | Buffer): unknown;
58
76
  }
77
+ /** Écouteur texte d'un client Redis abonné. */
78
+ type RedisTextListener = (message: string, channel: string) => unknown;
79
+ /** Écouteur octets d'un client Redis abonné (`bufferMode`). */
80
+ type RedisBytesListener = (message: Buffer, channel: Buffer) => unknown;
59
81
  /**
60
82
  * Client Redis **subscriber** dédié — surface structurelle minimale (compatible
61
83
  * `RedisClientType` v5). Un client abonné ne peut plus émettre de commandes
62
84
  * normales (protocole Redis) → publisher et subscriber sont 2 connexions distinctes
63
85
  * (cf `@nodefony/redis` : connexions `publish` / `subscribe`).
86
+ *
64
87
  */
65
88
  export interface IRedisSubscriber {
66
- subscribe(channel: string, listener: (message: string, channel: string) => unknown): unknown;
67
- unsubscribe(channel: string, listener?: (message: string, channel: string) => unknown): unknown;
89
+ subscribe(channel: string, listener: RedisTextListener): unknown;
90
+ unsubscribe(channel: string, listener?: RedisTextListener): unknown;
68
91
  }
92
+ /**
93
+ * Client Redis abonné en mode **octets** — le troisième argument `true`
94
+ * (`bufferMode` de node-redis) rend les messages en `Buffer`, sans décodage
95
+ * UTF-8 : c'est lui qui porte le sujet binaire intact (ADR-0021 S6).
96
+ *
97
+ * Interface SÉPARÉE de {@link IRedisSubscriber}, et non une surcharge de
98
+ * celle-ci : les deux formes réunies dans une même interface ne sont plus
99
+ * satisfaites ni par le type de node-redis (générique sur le mode), ni par une
100
+ * doublure de test qui ne connaît que le texte. Séparées, chacune l'est.
101
+ */
102
+ export interface IRedisBytesSubscriber {
103
+ subscribe(channel: string, listener: RedisBytesListener, bufferMode: true): unknown;
104
+ unsubscribe(channel: string, listener: RedisBytesListener, bufferMode: true): unknown;
105
+ }
106
+ /**
107
+ * Vrai si la valeur expose la surface d'un client Redis publisher — vérifié au
108
+ * démarrage, sur le client que rend le service `redis`.
109
+ */
110
+ export declare function isRedisPublisher(value: unknown): value is IRedisPublisher;
111
+ /** Vrai si la valeur expose la surface d'un client Redis subscriber dédié. */
112
+ export declare function isRedisSubscriber(value: unknown): value is IRedisSubscriber;
113
+ /**
114
+ * Vrai si la valeur expose la surface d'un client abonné capable du mode octets.
115
+ *
116
+ * La forme ne dit pas si le troisième argument est compris : ce garde vaut pour
117
+ * le client que rend le service `redis` (node-redis, qui l'accepte). Un client qui
118
+ * l'ignorerait livrerait du texte au sujet binaire — illisible pour le codec,
119
+ * donc jeté, compté sous un secret (`sealRejectedTotal`), jamais réinjecté.
120
+ */
121
+ export declare function isRedisBytesSubscriber(value: unknown): value is IRedisBytesSubscriber;
69
122
  /**
70
123
  * Adapte deux clients Redis (publisher + subscriber dédié) au seam
71
124
  * {@link IRedisBackplaneTransport}. SEUL point de contact avec la lib `redis` —
@@ -80,17 +133,17 @@ export interface IRedisSubscriber {
80
133
  * defineRealtimeConfig({ backplane: { driver: "redis" } }, { backplane: bp });
81
134
  * ```
82
135
  *
83
- * Le listener Redis est conservé pour un `unsubscribe(channel, listener)` ciblé
84
- * (n'arrache pas d'éventuels autres abonnés du même client).
136
+ * Chaque listener Redis est conservé, par canal, pour un `unsubscribe(channel,
137
+ * listener)` ciblé (n'arrache pas d'éventuels autres abonnés du même client).
138
+ *
139
+ * @param publisher - connexion de publication.
140
+ * @param subscriber - connexion d'abonnement dédiée.
141
+ * @param bytesSubscriber - la MÊME connexion d'abonnement, vue en mode octets
142
+ * ({@link IRedisBytesSubscriber}) ; absente, le transport n'offre pas de sujet
143
+ * binaire et les octets restent sur le serveur qui les publie.
144
+ * @returns le transport du {@link RedisBackplane}.
85
145
  */
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;
93
- export declare function createRedisServiceTransport(publisher: IRedisPublisher, subscriber: IRedisSubscriber): IRedisBackplaneTransport;
146
+ export declare function createRedisServiceTransport(publisher: IRedisPublisher, subscriber: IRedisSubscriber, bytesSubscriber?: IRedisBytesSubscriber): IRedisBackplaneTransport;
94
147
  /**
95
148
  * Réglages optionnels du driver — regroupés en objet pour ne pas allonger une
96
149
  * liste de paramètres positionnels déjà à quatre entrées.
@@ -103,8 +156,8 @@ export interface IRedisBackplaneOptions {
103
156
  */
104
157
  maxQueueBytes?: number;
105
158
  /**
106
- * Annonce des transitions de la file (saturation / retour à la normale) — le
107
- * wiring y branche le syslog du module. Omis = silencieux : acceptable en test,
159
+ * Annonce des transitions de la file (saturation / retour à la normale) et des
160
+ * sceaux refusés à l'entrée — le wiring y branche le syslog du module. Omis = silencieux : acceptable en test,
108
161
  * jamais en production (une dégradation doit être annoncée).
109
162
  */
110
163
  onNotice?: BackplaneNotice;
@@ -152,6 +205,16 @@ export declare class RedisBackplane implements IBackplane {
152
205
  * un 2ᵉ appel concurrent ne ré-abonne pas).
153
206
  */
154
207
  start(): Promise<void>;
208
+ /**
209
+ * Arme le sujet binaire (ADR-0021 S6) : les charges qui contiennent des octets
210
+ * partent en enveloppe encodée par `codec` sur un second canal Redis, et ce
211
+ * canal est écouté. Les charges sans octets gardent le sujet texte, intact pour
212
+ * les exemplaires plus anciens. Idempotent.
213
+ *
214
+ * @param codec - le codec binaire du serveur.
215
+ * @returns `false` si le transport ne sait pas s'abonner en octets.
216
+ */
217
+ enableBytes(codec: IFrameCodec): boolean;
155
218
  /**
156
219
  * Propage une publication locale aux autres pods via Redis. NE refait PAS le
157
220
  * fan-out local (déjà fait par le hub). Sérialisé une fois ici.
@@ -159,12 +222,20 @@ export declare class RedisBackplane implements IBackplane {
159
222
  * L'envoi passe par la **file bornée** : si le bus n'acquitte plus, les
160
223
  * publications suivantes sont jetées et comptées plutôt que d'empiler des
161
224
  * mégaoctets dans le client Redis (sémantique at-most-once déjà assumée par le
162
- * port ; le client realtime re-synchronise).
225
+ * port ; le client realtime re-synchronise). Un ordre de contrôle, lui, part
226
+ * toujours (cf {@link BackplanePublishQueue.send}).
163
227
  */
164
228
  publish(channel: string, payload: unknown): void;
165
229
  /** Un seul handler d'ingress ; un appel ultérieur remplace le précédent. */
166
230
  onMessage(handler: BackplaneHandler): void;
167
- /** Désabonne du canal Redis et détache le handler. Idempotent. */
231
+ /**
232
+ * Laisse partir les publications déjà émises (acquittées ou refusées par Redis), puis désabonne du canal et détache le handler. Idempotent.
233
+ *
234
+ * Sans l'attente, un arrêt suivi de la sortie du processus perdait ce qui
235
+ * était encore dans la file — la preuve de vie FINALE de la présence comprise,
236
+ * et les pairs gardaient alors ce pod jusqu'au délai d'oubli. Un Redis qui ne
237
+ * répond plus fait attendre : l'appelant borne (l'arrêt du module le fait).
238
+ */
168
239
  stop(): Promise<void>;
169
240
  describe(): IBackplaneInfo;
170
241
  }