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

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 (78) hide show
  1. package/README.md +50 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +79 -12
  4. package/dist/nodefony/config/config.js +7 -0
  5. package/dist/nodefony/decorators/realtimeDecorators.js +125 -19
  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 +143 -31
  9. package/dist/nodefony/src/backplane/controlOrder.js +57 -0
  10. package/dist/nodefony/src/backplane/envelope.js +113 -12
  11. package/dist/nodefony/src/cluster/ClusterProbeClient.js +2 -0
  12. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
  13. package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
  14. package/dist/nodefony/src/server/RealtimeConnection.js +110 -0
  15. package/dist/nodefony/src/server/RealtimeController.js +340 -81
  16. package/dist/nodefony/src/server/RealtimeHub.js +604 -61
  17. package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
  18. package/dist/nodefony/src/server/admissionBudget.js +176 -0
  19. package/dist/nodefony/src/server/channelPolicyRegistry.js +73 -0
  20. package/dist/nodefony/src/server/connectionAdmission.js +242 -0
  21. package/dist/nodefony/src/server/contractRoutes.js +85 -0
  22. package/dist/nodefony/src/server/deniedDetail.js +10 -3
  23. package/dist/nodefony/src/server/frameAdmission.js +168 -0
  24. package/dist/nodefony/src/server/subscribedAck.js +10 -1
  25. package/dist/nodefony/src/server/syslogUplink.js +3 -4
  26. package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
  27. package/dist/nodefony/src/service/RealtimeService.js +42 -1
  28. package/dist/nodefony/src/service/publicOrigin.js +40 -0
  29. package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
  30. package/dist/nodefony/testing/backplaneConformance.js +271 -0
  31. package/dist/nodefony/testing/index.js +21 -4
  32. package/dist/types/index.d.ts +34 -7
  33. package/dist/types/nodefony/config/config.d.ts +9 -0
  34. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +86 -6
  35. package/dist/types/nodefony/interfaces/IBackplane.d.ts +34 -50
  36. package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
  37. package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
  38. package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
  39. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
  40. package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
  41. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
  42. package/dist/types/nodefony/interfaces/index.d.ts +2 -1
  43. package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
  44. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +79 -16
  45. package/dist/types/nodefony/src/backplane/controlOrder.d.ts +52 -0
  46. package/dist/types/nodefony/src/backplane/envelope.d.ts +34 -0
  47. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +10 -0
  48. package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
  49. package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +17 -0
  50. package/dist/types/nodefony/src/server/RealtimeController.d.ts +91 -11
  51. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +321 -24
  52. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
  53. package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
  54. package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +34 -0
  55. package/dist/types/nodefony/src/server/connectionAdmission.d.ts +68 -0
  56. package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
  57. package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
  58. package/dist/types/nodefony/src/server/frameAdmission.d.ts +70 -0
  59. package/dist/types/nodefony/src/server/subscribedAck.d.ts +7 -0
  60. package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
  61. package/dist/types/nodefony/src/service/RealtimeService.d.ts +10 -2
  62. package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
  63. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
  64. package/dist/types/nodefony/testing/backplaneConformance.d.ts +66 -0
  65. package/dist/types/nodefony/testing/index.d.ts +12 -1
  66. package/docs/actions.md +419 -18
  67. package/docs/architecture.md +83 -8
  68. package/docs/cadence-et-contre-pression.md +87 -11
  69. package/docs/canaux-a-membres.md +267 -0
  70. package/docs/cas-d-usage.md +1702 -0
  71. package/docs/configuration.md +137 -17
  72. package/docs/cookbook-chat.md +314 -85
  73. package/docs/index.md +11 -1
  74. package/docs/observabilite.md +71 -14
  75. package/docs/protocole.md +133 -24
  76. package/docs/securite.md +284 -32
  77. package/docs/vocabulaire.md +13 -12
  78. package/package.json +14 -11
@@ -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;
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;
68
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).
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`.
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}.
89
145
  */
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.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Ordres de contrôle entre serveurs — la LISTE FERMÉE de ce qui circule sur le
3
+ * canal `PLATFORM_BUS_CHANNELS.control` (ADR-0022 §6 décision 4).
4
+ *
5
+ * Un ordre ne fait qu'ENLEVER : terminer un canal, éjecter un compte d'un
6
+ * canal. Aucun n'admet — seul `allow` fait entrer. Il voyage dans la charge
7
+ * d'un message ordinaire du bus, donc sous le sceau de l'enveloppe
8
+ * (`envelope.ts#sealBackplaneEnvelope`), sans champ neuf dans l'enveloppe ni
9
+ * méthode neuve dans `IBackplane` : un driver tiers le transporte sans le savoir.
10
+ */
11
+ /** Longueur maximale d'un champ texte d'un ordre (nom de canal, identifiant de compte). */
12
+ export declare const MAX_CONTROL_FIELD_LENGTH = 1024;
13
+ /** Terminer un canal sur chaque serveur : ses membres reçoivent `realtime:closed { reason: "ended" }`. */
14
+ export interface ICloseChannelOrder {
15
+ /** Discriminant de l'ordre. */
16
+ readonly op: "close";
17
+ /** Nom exact du canal. */
18
+ readonly channel: string;
19
+ }
20
+ /** Éjecter d'un canal, sur chaque serveur, toutes les connexions d'un compte. */
21
+ export interface IEvictUserOrder {
22
+ /** Discriminant de l'ordre. */
23
+ readonly op: "evictUser";
24
+ /** Nom exact du canal. */
25
+ readonly channel: string;
26
+ /** Identifiant du compte (`IRealtimeConnection.userId`). */
27
+ readonly user: string;
28
+ /**
29
+ * Génération d'appartenance visée — RÉSERVÉE : aucun exemplaire 10.0 ne
30
+ * l'émet, et un exemplaire qui ne sait pas s'en servir l'ignore et éjecte
31
+ * toutes les générations (plus large, jamais moins). Elle portera en 10.x la
32
+ * parade au rejeu d'une éjection sur un canal durable (ADR-0022 §6 d4, S-rt-48).
33
+ */
34
+ readonly generation?: number;
35
+ }
36
+ /** Un ordre de contrôle reconnu. */
37
+ export type ControlOrder = ICloseChannelOrder | IEvictUserOrder;
38
+ /**
39
+ * Lit un ordre de contrôle dans une charge venue du bus — `null` si la forme n'est pas celle d'un ordre connu.
40
+ *
41
+ * Une seule garde pour les deux bords : l'émetteur la passe avant de publier
42
+ * (il n'émet jamais ce que ses pairs refuseraient), le récepteur avant
43
+ * d'appliquer. Lecture par champs PROPRES (`ownField`) : une charge dont le
44
+ * prototype porterait `op` ne passe pas. Un champ inconnu est toléré — c'est
45
+ * ce qui laisse une version suivante ajouter un champ sans faire refuser ses
46
+ * ordres pendant un déploiement progressif ; un champ CONNU de forme fausse
47
+ * fait refuser l'ordre entier.
48
+ *
49
+ * @param payload - la charge du message reçu (forme inconnue).
50
+ * @returns l'ordre, ou `null`.
51
+ */
52
+ export declare function readControlOrder(payload: unknown): ControlOrder | null;
@@ -1,3 +1,4 @@
1
+ import { type IFrameCodec } from "nodefony";
1
2
  import type { IBackplaneMessage } from "../../interfaces/IBackplane.js";
2
3
  /**
3
4
  * Sérialise un message backplane, scellé si un secret est fourni.
@@ -25,3 +26,36 @@ export declare function sealBackplaneEnvelope(msg: IBackplaneMessage, secret?: s
25
26
  * scellé alors qu'un secret est exigé, sceau invalide).
26
27
  */
27
28
  export declare function openBackplaneEnvelope(raw: string, secret?: string | null): IBackplaneMessage | null;
29
+ /**
30
+ * Encode un message backplane pour le **sujet binaire** du bus (ADR-0021 S6) :
31
+ * la MÊME enveloppe `{ channel, payload, originId }`, encodée par le codec
32
+ * binaire du serveur, octets natifs dans `payload`, scellée si un secret est
33
+ * fourni.
34
+ *
35
+ * Scellée, la charge est encodée deux fois — une pour le sceau, une dans
36
+ * l'enveloppe : le codec générique ne permet pas d'assembler l'enveloppe à
37
+ * partir de fragments déjà encodés, comme le fait la voie texte.
38
+ *
39
+ * @param msg - message à transporter (canal, charge, origine).
40
+ * @param codec - le codec binaire du serveur (`CBOR_FRAME_CODEC` de `nodefony/cbor`).
41
+ * @param secret - secret partagé entre pods ; `null`/`undefined` → enveloppe nue.
42
+ * @returns les octets à publier sur le sujet binaire.
43
+ * @throws TypeError si le codec rend du texte.
44
+ */
45
+ export declare function sealBackplaneEnvelopeBytes(msg: IBackplaneMessage, codec: IFrameCodec, secret?: string | null): Uint8Array;
46
+ /**
47
+ * Décode et **vérifie** une enveloppe reçue sur le sujet binaire du bus — même
48
+ * règle que {@link openBackplaneEnvelope} : fail-closed strict sous un secret,
49
+ * aucun downgrade en retirant `sig`.
50
+ *
51
+ * Le sceau se recalcule sur le RÉ-ENCODAGE de la charge décodée, comme la voie
52
+ * texte le fait sur sa re-sérialisation : le codec CBOR du cœur rend la forme la
53
+ * plus courte de chaque valeur, donc émetteur et récepteur signent les mêmes
54
+ * octets.
55
+ *
56
+ * @param raw - octets lus sur le sujet binaire.
57
+ * @param codec - le codec binaire du serveur.
58
+ * @param secret - secret partagé attendu, ou `null`/`undefined` si aucun.
59
+ * @returns le message vérifié, ou `null` s'il doit être ignoré.
60
+ */
61
+ export declare function openBackplaneEnvelopeBytes(raw: Uint8Array, codec: IFrameCodec, secret?: string | null): IBackplaneMessage | null;
@@ -1,4 +1,14 @@
1
+ import type { ISubscriberIdentity } from "nodefony";
1
2
  import type { IRealtimeToken } from "../../interfaces/IRealtimeToken.js";
3
+ /**
4
+ * Identité d'admission de l'ANONYME — gelée et partagée par toutes les connexions non authentifiées (0 allocation par connexion).
5
+ *
6
+ * Porte l'invariant de l'ADR-0021 §S10 : anonyme ⇔ `authenticated === false`,
7
+ * `id === null` et `issuer === null`. Un canal qui déclare `allow` refuse
8
+ * cette identité d'office, sauf option explicite `anonymous: true` — ce qui
9
+ * garantit que `user:${who.id}` ne vaut jamais `"user:null"`.
10
+ */
11
+ export declare const ANONYMOUS_SUBSCRIBER_IDENTITY: ISubscriberIdentity;
2
12
  /**
3
13
  * Token realtime ANONYME — fallback Zero Trust quand aucun matcher
4
14
  * `IRealtimeAuthenticatorMatcher` ne capture le handshake.
@@ -0,0 +1,138 @@
1
+ import type { ContractActionNames, ContractClientEmit, ContractServerActions, ContractServerEmit, ContractServerListen, ContractServerMethods, EventNames, IRealtimeContract, TypedRpcActionHandler } from "nodefony";
2
+ import { RealtimeController } from "./RealtimeController.js";
3
+ /**
4
+ * Clé de l'étiquette de type qui relie une classe à son contrat. Déclaration
5
+ * ambiante : aucun symbole n'existe à l'exécution, seul le compilateur la voit.
6
+ */
7
+ declare const contractType: unique symbol;
8
+ /**
9
+ * Table COMPLÈTE des méthodes que le serveur doit au contrat : une entrée par nom
10
+ * de `serverMethods`, chacune recevant ses paramètres lus par `Jsonify` et rendant
11
+ * le résultat déclaré. Une méthode oubliée, un nom hors contrat ou une forme
12
+ * fausse ne compilent pas.
13
+ */
14
+ export type ContractRealtimeMethodHandlers<C extends IRealtimeContract> = {
15
+ [K in ContractActionNames<ContractServerMethods<C>>]: TypedRpcActionHandler<ContractServerMethods<C>, K>;
16
+ };
17
+ /**
18
+ * Noms qu'un contrat déclare et qu'une politique peut garder : ses méthodes
19
+ * serveur et les canaux de ses deux directions. Un canal déclaré par gabarit
20
+ * (`user:${string}`) admet aussi son motif (`user:*`). Direction non déclarée :
21
+ * tout nom.
22
+ */
23
+ export type ContractPolicyNames<C extends IRealtimeContract> = ContractActionNames<ContractServerMethods<C>> | EventNames<ContractServerEmit<C>> | EventNames<ContractClientEmit<C>>;
24
+ /**
25
+ * Le contrat qu'une classe de contrôleur implémente, lu sur son étiquette de
26
+ * type ; `never` pour un contrôleur sans contrat.
27
+ */
28
+ export type RealtimeContractOf<T> = T extends abstract new (...args: never[]) => {
29
+ readonly [contractType]?: infer C;
30
+ } ? unknown extends C ? never : C extends IRealtimeContract ? C : never : never;
31
+ /**
32
+ * Socle commun des deux formes de {@link ContractRealtimeController} : l'étiquette
33
+ * de contrat et les canaux annoncés. Les actions y gardent le défaut de la base
34
+ * (`{}`) — un contrat SANS `serverMethods` n'a rien à écrire.
35
+ *
36
+ * Ne s'étend pas directement : on étend {@link ContractRealtimeController}, qui
37
+ * choisit cette forme ou la forme stricte selon le contrat.
38
+ *
39
+ * @typeParam C - le contrat de l'application.
40
+ */
41
+ export declare abstract class ContractRealtimeControllerBase<C extends IRealtimeContract> extends RealtimeController<ContractServerEmit<C>, ContractServerActions<C>, ContractServerListen<C>> {
42
+ /**
43
+ * Étiquette de TYPE : relie la classe à son contrat pour que `@RealtimePolicy`
44
+ * le retrouve. `declare` — aucun champ n'existe à l'exécution.
45
+ */
46
+ readonly [contractType]?: C;
47
+ /** Canaux annoncés au handshake, contraints aux noms de `serverToClient`. */
48
+ protected realtimeChannels(): EventNames<ContractServerEmit<C>>[];
49
+ }
50
+ /**
51
+ * Forme stricte de {@link ContractRealtimeController}, retenue quand le contrat
52
+ * déclare des `serverMethods` : `realtimeActions()` y devient ABSTRAITE et typée
53
+ * par le contrat — la seule construction qui fait d'une méthode OUBLIÉE une
54
+ * erreur de compilation (un décorateur ne voit jamais la classe entière).
55
+ *
56
+ * Ne s'étend pas directement : on étend {@link ContractRealtimeController}.
57
+ *
58
+ * @typeParam C - le contrat de l'application.
59
+ */
60
+ export declare abstract class ContractRealtimeControllerStrict<C extends IRealtimeContract> extends ContractRealtimeControllerBase<C> {
61
+ /**
62
+ * Les méthodes du contrat servies par ce contrôleur — toutes, et par cette
63
+ * table SEULE : une action servie ici ne se décore pas en plus de
64
+ * `@RealtimeAction` (handler fabriqué puis écrasé à chaque connexion). Sa
65
+ * politique se pose sur la classe, par `@RealtimePolicy`.
66
+ *
67
+ * Appelée à chaque connexion. Des handlers qui n'ont pas besoin de `this` se
68
+ * rangent dans une table CONSTANTE du module, rendue telle quelle : aucune
69
+ * allocation par connexion.
70
+ */
71
+ protected abstract realtimeActions(): ContractRealtimeMethodHandlers<C>;
72
+ }
73
+ /**
74
+ * Vrai quand le contrat `C` ne déclare aucune méthode serveur. Le tuple empêche
75
+ * la distribution du conditionnel sur `never`.
76
+ */
77
+ type HasNoServerMethods<C extends IRealtimeContract> = [
78
+ ContractActionNames<ContractServerMethods<C>>
79
+ ] extends [never] ? true : false;
80
+ /**
81
+ * L'instance d'un contrôleur de contrat : forme stricte si `C` déclare des
82
+ * `serverMethods`, socle sinon.
83
+ *
84
+ * @typeParam C - le contrat de l'application.
85
+ */
86
+ export type ContractRealtimeController<C extends IRealtimeContract> = HasNoServerMethods<C> extends true ? ContractRealtimeControllerBase<C> : ContractRealtimeControllerStrict<C>;
87
+ /**
88
+ * Le constructeur de {@link ContractRealtimeController} : celui de la forme
89
+ * stricte, dont l'instance se lit par le contrat. Les statiques (`scope`,
90
+ * `assertScope`) restent ceux de la classe.
91
+ */
92
+ type ContractRealtimeControllerConstructor = Omit<typeof ContractRealtimeControllerStrict, "prototype"> & (abstract new <C extends IRealtimeContract>(...args: ConstructorParameters<typeof RealtimeController>) => ContractRealtimeController<C>);
93
+ /**
94
+ * Base d'un contrôleur qui IMPLÉMENTE un contrat temps réel — la porte du
95
+ * serveur. Le contrat s'écrit UNE fois, sur `extends` ; la direction du serveur
96
+ * en est dérivée (il émet `serverToClient`, écoute `clientToServer`, appelle
97
+ * `clientMethods`) et il DOIT servir chaque méthode de `serverMethods`.
98
+ *
99
+ * Quand le contrat déclare des `serverMethods`, `realtimeActions()` est
100
+ * abstraite et typée par lui : une méthode OUBLIÉE ne compile pas. Un contrat
101
+ * SANS `serverMethods` (tableau de bord, chat qui ne fait que diffuser) n'a rien
102
+ * à écrire. Les entrées client (`realtimeInbound()`) restent facultatives canal
103
+ * par canal : accepter une entrée est une permission, pas une promesse. Les
104
+ * politiques d'accès se posent sur la classe par `@RealtimePolicy`, qui lit le
105
+ * contrat sur cette classe.
106
+ *
107
+ * Le fichier du contrat, fait de types seuls, est partagé avec le navigateur, qui
108
+ * entre par `NodefonySocket.fromContract<C>()`.
109
+ *
110
+ * Un contrat qui déclare des `serverRoutes` (routes du pont, typées côté client
111
+ * par le schéma que la route valide) se double d'une table statique de VALEURS,
112
+ * `static readonly serverRoutes = { … } satisfies ContractRouteSchemas<C>` : le
113
+ * compilateur y exige chaque route du contrat avec son schéma, et le démarrage
114
+ * vérifie que chacune est montée, servie par la socket et valide LE MÊME schéma
115
+ * (`BootConfigurationError` sinon). Sans cette table, aucun contrôle.
116
+ *
117
+ * @typeParam C - le contrat de l'application.
118
+ *
119
+ * @example
120
+ * ```ts
121
+ * @RealtimePolicy({ "chat:send": { roles: ["ROLE_USER"] } })
122
+ * class ChatRt extends ContractRealtimeController<ChatContract> {
123
+ * protected realtimeActions() {
124
+ * return { "chat:send": (p: { text: string }) => ({ id: this.store(p.text) }) };
125
+ * }
126
+ * }
127
+ *
128
+ * class DashRt extends ContractRealtimeController<DashContract> {} // sans serverMethods
129
+ *
130
+ * class ChatRoutesRt extends ContractRealtimeController<ChatContract> {
131
+ * static readonly serverRoutes = {
132
+ * "POST /chat/send": { body: SendBody }, // le schéma de la route, pas une copie
133
+ * } satisfies ContractRouteSchemas<ChatContract>;
134
+ * }
135
+ * ```
136
+ */
137
+ export declare const ContractRealtimeController: ContractRealtimeControllerConstructor;
138
+ export {};
@@ -0,0 +1,17 @@
1
+ import type { RealtimeClosedReason } from "nodefony";
2
+ /**
3
+ * Ce que la poignée d'une connexion demande au contrôleur qui la sert — imposer
4
+ * un abonnement, y mettre fin. Implémenté par `RealtimeController`, une fois par
5
+ * connexion.
6
+ */
7
+ export interface IRealtimeConnectionHost {
8
+ /** Lance l'abonnement imposé à `channel` ; le verdict passe par {@link RealtimeConnection.settleImposed}. */
9
+ impose(channel: string): void;
10
+ /**
11
+ * Met fin à l'abonnement de cette connexion à `channel` (hub ET état de la
12
+ * connexion) et le lui notifie (`realtime:closed`).
13
+ *
14
+ * @returns `false` si la connexion n'est pas abonnée à ce canal.
15
+ */
16
+ end(channel: string, reason: RealtimeClosedReason): boolean;
17
+ }
@@ -1,8 +1,8 @@
1
- import { type RpcActionHandler, type EventsMap, type ActionsMap, type DefaultEventsMap, type DefaultActionsMap, type EventNames, type EventPayload, type ActionNames, type ActionParams, type ActionResult } from "nodefony";
1
+ import { type EventsMap, type ActionsMap, type DefaultEventsMap, type DefaultActionsMap, type EventNames, type EventPayload, type ActionNames, type ActionParams, type ActionResult } from "nodefony";
2
2
  import type { ContextType } from "@nodefony/http";
3
3
  import { Controller } from "@nodefony/framework";
4
4
  import type { ControllerScope } from "@nodefony/framework";
5
- import type { IRealtimeController, RealtimePublish, RealtimeInboundHandler } from "../../interfaces/IRealtimeController.js";
5
+ import type { IRealtimeController, RealtimePublish, RealtimeInboundHandlers, RealtimeActionHandler } from "../../interfaces/IRealtimeController.js";
6
6
  /**
7
7
  * RealtimeController — base d'un endpoint WebSocket temps réel SERVEUR (JSON-RPC 2.0).
8
8
  *
@@ -25,7 +25,7 @@ import type { IRealtimeController, RealtimePublish, RealtimeInboundHandler } fro
25
25
  * créé au 1ᵉʳ abonné, `dispose()` au dernier (`unsubscribe` ou `onFinish`). Le dispatch
26
26
  * d'action n'est payé que sur une requête `id`. C'est aussi le seam du backplane Redis.
27
27
  */
28
- export declare abstract class RealtimeController<Emit extends EventsMap = DefaultEventsMap, Actions extends ActionsMap = DefaultActionsMap> extends Controller implements IRealtimeController {
28
+ export declare abstract class RealtimeController<Emit extends EventsMap = DefaultEventsMap, Actions extends ActionsMap = DefaultActionsMap, Listen extends EventsMap = DefaultEventsMap> extends Controller implements IRealtimeController {
29
29
  #private;
30
30
  /**
31
31
  * Une instance PAR CONNEXION, par construction — le défaut `singleton` des
@@ -72,7 +72,7 @@ export declare abstract class RealtimeController<Emit extends EventsMap = Defaul
72
72
  */
73
73
  createRealtimeChannel(_channel: string, _publish: RealtimePublish): (() => void) | null;
74
74
  /** Actions RPC exposées (requête→réponse). À surcharger ; défaut : aucune. */
75
- protected realtimeActions(): Record<string, RpcActionHandler>;
75
+ protected realtimeActions(): Record<string, RealtimeActionHandler>;
76
76
  /** Canaux annoncés au handshake. À surcharger ; défaut : aucun. */
77
77
  protected realtimeChannels(): string[];
78
78
  /**
@@ -88,13 +88,16 @@ export declare abstract class RealtimeController<Emit extends EventsMap = Defaul
88
88
  * surcharger ; défaut : **aucun** (sûr — un client ne peut rien pousser au serveur
89
89
  * tant qu'un canal n'est pas explicitement déclaré ici). Seam des backings entrants
90
90
  * (SIP, bridge). Cf {@link RealtimeInboundHandler} (params NON FIABLES).
91
+ *
92
+ * Typée par `Listen` (ce que le client émet, 3ᵉ générique) : un canal hors de
93
+ * la carte ne compile pas, et chaque handler reçoit la forme de SON canal.
91
94
  */
92
- protected realtimeInbound(): Record<string, RealtimeInboundHandler>;
95
+ protected realtimeInbound(): RealtimeInboundHandlers<Listen, Emit>;
93
96
  /**
94
97
  * Opt-in du **pont API** (une action, deux transports) : `true` → la
95
98
  * connexion expose la méthode RPC `api.request {path}` qui re-route le path
96
99
  * porté par le message vers l'action controller correspondante — la MÊME
97
- * action que le GET REST (`socket.request("/nodefony/kernel/api/modules")`
100
+ * action que le GET REST (`socket.get("/nodefony/kernel/api/modules")`
98
101
  * côté client). Le pont n'atteint QUE les routes déclarant le transport
99
102
  * `WEBSOCKET` dans leurs `methods` (zéro bypass : une action dit à quels
100
103
  * transports elle répond). À surcharger ; défaut : `false` — aucune surface
@@ -120,7 +123,7 @@ export declare abstract class RealtimeController<Emit extends EventsMap = Defaul
120
123
  * `NodefonySocket.replaySubscriptions` et les deux cas « la fenêtre où le serveur
121
124
  * écoute (welcome) » de `NodefonySocketCoverage.test.ts`.
122
125
  */
123
- protected handleRealtime(message: string | Buffer | null): void;
126
+ protected handleRealtime(message: string | Uint8Array | null): void;
124
127
  /**
125
128
  * Lit les trames BINAIRES de cette connexion — le point d'extension d'un
126
129
  * protocole binaire écrit par l'application, à côté du JSON-RPC ; pendant
@@ -128,7 +131,7 @@ export declare abstract class RealtimeController<Emit extends EventsMap = Defaul
128
131
  *
129
132
  * Le protocole temps réel est du JSON-RPC texte : il ne sait pas à quel canal
130
133
  * 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
134
+ * écartée, comptée (`binaryDiscardedTotal` de la sonde du hub) et journalisée
132
135
  * une fois par connexion. La surcharger la reçoit telle quelle, octets exacts,
133
136
  * en `Uint8Array` — le type que livre aussi `NodefonySocket.onBinary` dans le
134
137
  * navigateur : un décodeur s'écrit une fois pour les deux côtés. À l'exécution
@@ -140,6 +143,10 @@ export declare abstract class RealtimeController<Emit extends EventsMap = Defaul
140
143
  * et le pare-feu. L'identité se lit par `RequestContext.getAuthenticatedUser()`,
141
144
  * et toute décision d'accès sur une trame binaire appartient à l'application.
142
145
  *
146
+ * ⚠️ Connexions TEXTE seulement : une connexion qui a négocié un codec binaire
147
+ * (configuration `codec: "cbor"`) lit toute trame binaire comme une frame du
148
+ * codec, et cette méthode n'y est jamais appelée (ADR-0022 §2).
149
+ *
143
150
  * ⚠️ Un `DataView` se construit sur `data.buffer` AVEC `data.byteOffset` et
144
151
  * `data.byteLength` : un `Buffer` issu du pool de Node ne commence pas à 0.
145
152
  *
@@ -183,10 +190,20 @@ export declare abstract class RealtimeController<Emit extends EventsMap = Defaul
183
190
  * client. Même contre-pression que les frames JSON-RPC.
184
191
  *
185
192
  * @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.
193
+ * Sur une connexion qui a négocié un codec BINAIRE, refusé (ADR-0022 §2) : le
194
+ * client y lirait ces octets comme une frame du codec. Des octets passent
195
+ * alors par un canal, avec ses portes.
196
+ *
197
+ * @returns `false` si le handshake n'est pas terminé, la connexion fermée, la
198
+ * trame jetée par la contre-pression, ou la connexion en codec binaire.
188
199
  */
189
200
  protected sendBinaryToClient(data: Uint8Array): boolean;
201
+ /**
202
+ * Journalise, une fois par connexion, le flux brut refusé sous codec binaire.
203
+ *
204
+ * @param state - l'état de la connexion.
205
+ */
206
+ private noticeRawBinaryUnderCodec;
190
207
  /**
191
208
  * État realtime de CETTE connexion, `undefined` avant le welcome — pour les
192
209
  * chemins FROIDS (requête sortante, binaire). `notifyClient` et la réception
@@ -207,6 +224,8 @@ export declare abstract class RealtimeController<Emit extends EventsMap = Defaul
207
224
  * Échecs (close WebSocket, codes plage applicative RFC 6455 §7.4.2) :
208
225
  * - Origin refusée → code 4003 (`forbidden`).
209
226
  * - `authenticate` throw → code 4001 (`unauthorized`).
227
+ * - `authenticate` sans réponse dans `authenticateTimeoutMs` → code 1013
228
+ * (`try again later`, ADR-0021 §S11) : le client recule puis revient.
210
229
  */
211
230
  private onHandshake;
212
231
  /**
@@ -222,8 +241,69 @@ export declare abstract class RealtimeController<Emit extends EventsMap = Defaul
222
241
  * par connexion).
223
242
  */
224
243
  private acknowledgeSubscription;
225
- /** Abonne la connexion à un canal via le hub partagé (idempotent par connexion). */
244
+ /**
245
+ * Poignée publique de la connexion, créée au premier besoin (abonnement, trame entrante) puis gardée sur son état.
246
+ *
247
+ * Cold path : une fois par connexion. Les deux fermetures la relient à CE
248
+ * contrôleur, dont les fabriques servent ses canaux.
249
+ */
250
+ private connectionOf;
251
+ /**
252
+ * Abonnement imposé par le serveur (`RealtimeHub.subscribeConnection`) — la politique fixe par le verrou des trames, puis le chemin d'un `subscribe` du client, `allow` compris (ADR-0022 §6 décision 2).
253
+ *
254
+ * Le verdict est rendu à l'appelant par `RealtimeConnection.settleImposed` :
255
+ * à l'acquittement ({@link acknowledgeSubscription}) ou au refus
256
+ * ({@link refuseChannel}) — qui n'envoie alors rien au client, qui n'a rien
257
+ * demandé.
258
+ */
259
+ private imposeChannel;
260
+ /**
261
+ * Met fin à l'abonnement de la connexion à un canal, sur décision du serveur, et le lui notifie (`realtime:closed`).
262
+ *
263
+ * Même chemin que l'`unsubscribe` du client (hub ET état de la connexion),
264
+ * jamais un second : retirer le seul sink du hub laisserait l'entrée de la
265
+ * connexion, et le `subscribe` suivant serait acquitté sans rien rouvrir.
266
+ * Retire aussi la marque « admise » et le verdict retenu de ce canal.
267
+ *
268
+ * @returns `false` si la connexion n'est pas abonnée à ce canal.
269
+ */
270
+ private endChannel;
271
+ /**
272
+ * Retire la connexion d'un canal : hub, état de la connexion, admission de sa règle — le chemin UNIQUE d'une sortie, demandée par le client ou décidée par le serveur.
273
+ *
274
+ * @returns `false` si la connexion n'était pas abonnée.
275
+ */
276
+ private leaveChannel;
277
+ /**
278
+ * Refus d'un abonnement : rendu à l'appelant serveur s'il s'agit d'une imposition, au client sinon — et aux deux quand le client avait aussi demandé ce canal.
279
+ */
280
+ private refuseChannel;
281
+ /**
282
+ * Abonne la connexion à un canal via le hub partagé (idempotent par connexion).
283
+ *
284
+ * Un canal dont la politique déclare `allow` passe d'abord par la file
285
+ * d'admission de la connexion ; tout autre canal s'ouvre tout de suite, sans
286
+ * rien lire de plus qu'aujourd'hui (une recherche de politique au `subscribe`,
287
+ * trame de contrôle rare).
288
+ */
226
289
  private startChannel;
290
+ /**
291
+ * Soumet l'abonnement à la règle `allow` du canal — APRÈS le verrou des trames, qui a déjà jugé `authenticated`/`roles`/`scopes` (ADR-0022 §6, décision 1 ter, moment 2).
292
+ *
293
+ * `allow` s'applique même sans module de sécurité : il ne fait que retirer
294
+ * des abonnés, jamais en ajouter — l'ignorer ouvrirait `user:<id>` à tous.
295
+ * La file de la connexion n'est créée qu'à son premier canal à `allow`.
296
+ */
297
+ private admitChannel;
298
+ /**
299
+ * Rend un refus d'abonnement observable (`realtime:denied`) — le détail ne part qu'hors production.
300
+ *
301
+ * @param detail - phrase pour le développeur ; `undefined` quand l'appelant
302
+ * ne l'a pas rédigée (production).
303
+ */
304
+ private denyChannel;
305
+ /** Ouvre l'abonnement au hub et l'acquitte — canal sans `allow`, ou admis par sa règle. */
306
+ private openChannel;
227
307
  /**
228
308
  * Enveloppe des actions `@RealtimeAction` dont un paramètre est décoré
229
309
  * (`@Body`, `@CurrentUser`…) : l'action reçoit ses arguments comme une route,