@nodefony/realtime 10.0.0-beta.4 → 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 +79 -10
  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 +344 -85
  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 +93 -13
  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 +17 -4
  66. package/docs/actions.md +442 -19
  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 +333 -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 +294 -33
  77. package/docs/vocabulaire.md +13 -12
  78. package/package.json +14 -11
@@ -1,4 +1,11 @@
1
- import { JsonRpcPeer, isPlatformChannel } from "nodefony";
1
+ import { JsonRpcPeer, isPlatformChannel, type IFrameCodec, type IFrameCodecSet, type RealtimeFrameData } from "nodefony";
2
+ import { RealtimeConnection } from "./RealtimeConnection.js";
3
+ import type { IRealtimeConnection } from "../../interfaces/IRealtimeConnection.js";
4
+ import type { IChannelAdmission, UserChannelPayload } from "nodefony";
5
+ import { AdmissionBudget, type IAdmissionLimits } from "./admissionBudget.js";
6
+ import type { IChannelAdmissionRule } from "./connectionAdmission.js";
7
+ import type { IRealtimeConnectionState } from "../../interfaces/IRealtimeConnectionState.js";
8
+ import type { ContractServerEmit, EventNames, EventPayload, IRealtimeContract } from "nodefony";
2
9
  import type { RealtimePublish } from "../../interfaces/IRealtimeController.js";
3
10
  import type { IClientLogsLimits } from "../../interfaces/IClientLogsLimits.js";
4
11
  import type { IChannelPolicy } from "../../interfaces/IChannelPolicy.js";
@@ -108,18 +115,62 @@ export interface IRevocableConnection {
108
115
  * Sink d'un canal : pousse une charge vers UNE connexion abonnée (son `peer.notify`).
109
116
  * 1 sink = 1 connexion. Le hub fan-out la charge à tous les sinks d'un canal.
110
117
  *
111
- * `serialized` n'est fourni que si le canal a déclaré un {@link ChannelSerializer}
112
- * ET qu'il a plusieurs abonnés : la frame ayant été sérialisée une fois pour tous,
113
- * le sink peut l'envoyer telle quelle au lieu de refaire le travail. Absent, le
114
- * sink se débrouille comme avant — un sink historique à un seul paramètre ignore
115
- * simplement l'argument.
118
+ * `frame` n'est fourni que si le canal a déclaré un {@link ChannelFrameBuilder} ET
119
+ * qu'il a plusieurs abonnés : la frame est la MÊME pour tous, le sink en demande
120
+ * l'encodage pour le codec de SA connexion ({@link ChannelFrame.encodeFor}) au lieu
121
+ * de refaire le travail. Absent, le sink se débrouille comme avant — un sink à un
122
+ * seul paramètre ignore simplement l'argument.
116
123
  */
117
- export type ChannelSink = (payload: unknown, serialized?: string) => void;
124
+ export type ChannelSink = (payload: unknown, frame?: ChannelFrame) => void;
118
125
  /**
119
- * Sérialiseur de la frame d'un canal, fourni par l'abonné (le hub ignore tout du
120
- * protocole). Appelé **au plus une fois par publication**, jamais par abonné.
126
+ * Fabrique la frame protocolaire d'un canal (l'objet, pas ses octets), fournie par
127
+ * l'abonné : le hub ignore tout du protocole. Appelée **au plus une fois par
128
+ * publication**, jamais par abonné ; l'encodage, lui, se fait une fois par codec.
121
129
  */
122
- export type ChannelSerializer = (payload: unknown) => string;
130
+ export type ChannelFrameBuilder = (payload: unknown) => unknown;
131
+ /**
132
+ * Frame d'UNE publication diffusée à plusieurs abonnés, encodée **au plus une fois
133
+ * par codec** (ADR-0022 §2) : un canal suivi par des connexions texte et CBOR paie
134
+ * deux encodages, jamais un par connexion. L'encodage est paresseux — un codec
135
+ * qu'aucun abonné n'utilise ne coûte rien.
136
+ *
137
+ * Deux places suffisent : un hub sert au plus son codec texte et son codec
138
+ * binaire. Un troisième codec reste servi, sans mise en cache.
139
+ */
140
+ export declare class ChannelFrame {
141
+ private readonly message;
142
+ private codecA;
143
+ private dataA;
144
+ private codecB;
145
+ private dataB;
146
+ private claimedA;
147
+ private claimedB;
148
+ /** @param message - la frame protocolaire, telle que rendue par le {@link ChannelFrameBuilder}. */
149
+ constructor(message: unknown);
150
+ /**
151
+ * Rend la frame encodée pour un codec, en l'encodant à la première demande.
152
+ *
153
+ * @param codec - le codec de la connexion qui va l'écrire.
154
+ * @returns la trame à écrire telle quelle, ou `undefined` si la frame n'est pas
155
+ * encodable : l'abonné qui en {@link ChannelFrame.claimFailure | réclame}
156
+ * l'échec reprend son chemin habituel, qui porte le filet (journal).
157
+ */
158
+ encodeFor(codec: IFrameCodec): RealtimeFrameData | undefined;
159
+ /**
160
+ * Réclame l'échec d'encodage de cette frame pour un codec : `true` au PREMIER
161
+ * abonné qui le demande, `false` ensuite.
162
+ *
163
+ * Un seul abonné reprend alors son chemin habituel, dont le filet journalise
164
+ * l'échec ; les autres sautent la trame, qu'ils ne pourraient pas encoder non
165
+ * plus. Sans cela, une charge non encodable — un `$bytes` forgé par un client,
166
+ * une valeur cyclique — coûtait un encodage et une ligne de journal PAR abonné.
167
+ * Un codec hors des deux places réclame à chaque fois : il n'est pas retenu.
168
+ *
169
+ * @param codec - le codec dont {@link ChannelFrame.encodeFor} a rendu `undefined`.
170
+ * @returns `true` si l'appelant doit dire l'échec.
171
+ */
172
+ claimFailure(codec: IFrameCodec): boolean;
173
+ }
123
174
  /**
124
175
  * Fabrique le provider PARTAGÉ d'un canal (listener/ticker qui pousse via `publish`).
125
176
  * Appelée UNE fois, au 1ᵉʳ abonné. Renvoie son `dispose` (appelé au dernier
@@ -157,6 +208,12 @@ export type ChannelFactory = (channel: string, publish: RealtimePublish) => (()
157
208
  */
158
209
  export declare class RealtimeHub {
159
210
  #private;
211
+ /**
212
+ * Budget des admissions de l'exemplaire (appels d'`allow` en vol, coupe-circuit,
213
+ * `allowTimedOutTotal`/`allowSaturatedTotal`) — partagé par les files de toutes
214
+ * les connexions. Bornes posées par {@link setAdmissionLimits} au boot.
215
+ */
216
+ readonly admissionBudget: AdmissionBudget;
160
217
  /**
161
218
  * Abonnement demandé par une **connexion cliente** — même chose que
162
219
  * {@link subscribe}, plus le plancher des canaux de plateforme.
@@ -171,9 +228,11 @@ export declare class RealtimeHub {
171
228
  * sécurité en pose un, c'est lui qui décide (avec les rôles, ce que le hub ne
172
229
  * sait pas faire) et le plancher s'efface.
173
230
  *
231
+ * @param member - la connexion qui s'abonne : inscrite au registre du canal,
232
+ * elle devient un membre identifiable ({@link channelMembers}).
174
233
  * @returns `true` si abonné, `false` si canal inconnu ou fermé par le plancher.
175
234
  */
176
- subscribeClient(channel: string, sink: ChannelSink, factory: ChannelFactory, serialize?: ChannelSerializer): boolean;
235
+ subscribeClient(channel: string, sink: ChannelSink, factory: ChannelFactory, buildFrame?: ChannelFrameBuilder, member?: RealtimeConnection): boolean;
177
236
  /**
178
237
  * Abonne un consommateur qui **répond du canal** : il apporte la `factory`, donc
179
238
  * la réponse à « ce canal existe-t-il, et qui le produit ? ». Le provider partagé
@@ -187,7 +246,7 @@ export declare class RealtimeHub {
187
246
  *
188
247
  * @returns `true` si abonné, `false` si canal inconnu (`factory` a renvoyé `null`).
189
248
  */
190
- subscribe(channel: string, sink: ChannelSink, factory: ChannelFactory, serialize?: ChannelSerializer): boolean;
249
+ subscribe(channel: string, sink: ChannelSink, factory: ChannelFactory, buildFrame?: ChannelFrameBuilder): boolean;
191
250
  /**
192
251
  * **Écoute passive** — un service du serveur reçoit ce qui passe sur un canal
193
252
  * sans prétendre le produire. C'est la porte de {@link ServerRealtimeSocket} :
@@ -213,6 +272,99 @@ export declare class RealtimeHub {
213
272
  * et retire le canal (libère timers/listeners). No-op si non abonné.
214
273
  */
215
274
  unsubscribe(channel: string, sink: ChannelSink): void;
275
+ /**
276
+ * Les connexions abonnées à un canal par un client — **local à ce pod** : les membres servis par un autre exemplaire n'y figurent pas.
277
+ *
278
+ * Les écouteurs et services du serveur (`listen`, `subscribe`) ne sont pas
279
+ * des membres. Chemin froid : une copie à chaque appel.
280
+ *
281
+ * @param channel - nom exact du canal.
282
+ * @returns les membres de ce pod, vide si le canal n'en a aucun ici.
283
+ */
284
+ channelMembers(channel: string): IRealtimeConnection[];
285
+ /**
286
+ * Abonne d'office une connexion à un canal — **local à ce pod** : seule une connexion acceptée par ce processus peut l'être.
287
+ *
288
+ * Même porte qu'un `subscribe` du client (ADR-0022 §6 décision 2) : la
289
+ * politique fixe du canal (rôles, scopes, plancher de plateforme), puis sa
290
+ * règle `allow` s'il en déclare une. Un refus n'abonne pas, et seul
291
+ * l'appelant l'apprend ; jamais un abonnement qui contourne `allow` — le
292
+ * client rejoue ses abonnements à chaque reconnexion, et ce rejeu passe par
293
+ * `allow` : l'application écrit donc l'appartenance là où `allow` la lit.
294
+ *
295
+ * Le client est notifié par `realtime:subscribed { channel, imposed: true }`
296
+ * et inscrit le canal dans ses abonnements ; il peut le quitter.
297
+ *
298
+ * Plusieurs exemplaires : la requête qui décide doit atteindre le processus
299
+ * qui tient la socket — la passer par la socket elle-même (`@RealtimeInbound`,
300
+ * qui reçoit la connexion), ou faire autoriser l'entrée par `allow` (registre
301
+ * partagé) et laisser le navigateur s'abonner.
302
+ *
303
+ * @param channel - nom exact du canal.
304
+ * @param connection - la connexion à abonner (reçue par un gestionnaire `@RealtimeInbound`, ou lue dans {@link channelMembers}).
305
+ * @returns `true` une fois l'abonnement acquitté ; `false` si refusé, si la
306
+ * connexion est fermée ou inconnue de ce processus, ou si le client s'en
307
+ * désabonne avant le verdict.
308
+ */
309
+ subscribeConnection(channel: string, connection: IRealtimeConnection): Promise<boolean>;
310
+ /**
311
+ * Retire une connexion d'un canal — **local à ce pod** ; elle reçoit `realtime:closed { reason: "evicted" }`.
312
+ *
313
+ * ⚠️ **Éjecter coupe la connexion du canal, seul `allow` exclut**
314
+ * (ADR-0022 §6 décision 3). Le client ne rejoue pas un canal dont il a été
315
+ * éjecté, mais c'est une politesse : une page rechargée refait `subscribe`.
316
+ * Sur un canal à `allow`, l'éjecté revient si l'application n'a pas retiré
317
+ * l'appartenance là où `allow` la lit ; sur un canal sans `allow`, sa
318
+ * politique fixe le laisse rentrer.
319
+ *
320
+ * @param channel - nom exact du canal.
321
+ * @param connection - la connexion à retirer.
322
+ * @returns `false` si la connexion n'est pas membre du canal, est fermée, ou
323
+ * inconnue de ce processus — jamais un geste muet.
324
+ */
325
+ evictConnection(channel: string, connection: IRealtimeConnection): boolean;
326
+ /**
327
+ * Termine un canal pour tous ses membres, **sur tous les serveurs** : chacun reçoit `realtime:closed { reason: "ended" }`, et le producteur est libéré.
328
+ *
329
+ * **Traverse les pods** : avec un backplane branché, l'ordre part sur le bus
330
+ * (`PLATFORM_BUS_CHANNELS.control`, scellé comme toute publication) et chaque
331
+ * serveur l'applique à SES membres. Livraison « au plus une fois » du bus :
332
+ * un pod qui manque l'ordre, ou qui démarre après, est rattrapé par la
333
+ * fabrique, qui rend `null` pour une instance terminée. Un canal de la
334
+ * plateforme (`nodefony:`) reste local — il décrit un pod, pas une partie.
335
+ *
336
+ * Les écouteurs du serveur (`listen`) restent inscrits, sur un canal redevenu
337
+ * PASSIF : le prochain `subscribe` d'un client rejoue la fabrique, et c'est
338
+ * elle qui tranche. **Seule la fabrique clôt un canal** (ADR-0021 S4) — le
339
+ * hub ne garde aucune marque « fermé » : une fabrique qui rend encore un
340
+ * producteur pour ce nom le rouvre. D'où la règle d'écriture d'un canal à
341
+ * durée de vie : son nom porte l'identifiant de l'instance
342
+ * (`room:42:<partie>`), et la fabrique rend `null` pour une instance terminée.
343
+ *
344
+ * @param channel - nom exact du canal.
345
+ * @returns le nombre de membres de CE pod à qui la fin a été notifiée — les
346
+ * autres serveurs l'appliquent sans compte rendu.
347
+ */
348
+ closeChannel(channel: string): number;
349
+ /**
350
+ * Éjecte un compte d'un canal, **sur tous les serveurs** : chacune de ses connexions membres reçoit `realtime:closed { reason: "evicted" }`.
351
+ *
352
+ * Tous les onglets du compte sont visés, où qu'ils soient servis ; l'ordre
353
+ * traverse les pods comme {@link closeChannel}. ⚠️ **Éjecter coupe, seul
354
+ * `allow` exclut** (ADR-0022 §6 décision 3) : pour que le compte ne revienne
355
+ * pas, l'application retire l'appartenance là où `allow` la lit.
356
+ *
357
+ * ⚠️ Sur un bus PARTAGÉ, un tiers qui y écrit peut rejouer un ordre capté :
358
+ * sur un canal durable, il recouperait le compte à chaque réadmission (risque
359
+ * accepté et borné, ADR-0022 §6 d4) — bus privé exigé ; changer le secret du
360
+ * bus rend caducs les ordres déjà captés.
361
+ *
362
+ * @param channel - nom exact du canal.
363
+ * @param user - identifiant du compte (`IRealtimeConnection.userId`).
364
+ * @returns le nombre de connexions de CE pod éjectées. Un nom vide ou de plus
365
+ * de 1 024 caractères n'éjecte rien et ne part pas sur le bus.
366
+ */
367
+ evictUser(channel: string, user: string): number;
216
368
  /**
217
369
  * Publie une charge sur un canal : **fan-out local** **+** propagation conditionnelle
218
370
  * au {@link IBackplane} (autres workers/pods). C'est le point d'entrée des providers
@@ -226,8 +378,29 @@ export declare class RealtimeHub {
226
378
  *
227
379
  * Anti-boucle : un message **reçu** du backplane ne repasse PAS par ici (il appelle
228
380
  * `publishLocal` directement) → aucune ré-émission cross-process, pas de tempête.
381
+ *
382
+ * @param payload - objet, tableau ou octets ; omise = signal sans charge.
383
+ * @throws TypeError si la charge est un scalaire ou `null` ({@link isRealtimePayload},
384
+ * ADR-0021 S1) — un scalaire se publie dans un objet (`{ value }`).
229
385
  */
230
- publish(channel: string, payload: unknown): void;
386
+ publish(channel: string, payload?: object): void;
387
+ /**
388
+ * Livre une charge à UNE identité — tous ses onglets, tous les serveurs, et personne d'autre (ADR-0022 §6, D1).
389
+ *
390
+ * Publie sur son canal personnel `user:<userId>` : seuls les onglets de cette
391
+ * identité ont pu s'y abonner (politique intégrée, admission par égalité de
392
+ * chaîne), et le préfixe traverse le bus dès qu'un bus est branché. Rien à
393
+ * déclarer côté application. Personne n'écoute ? La charge est perdue, sans
394
+ * erreur : c'est une diffusion, pas une boîte aux lettres.
395
+ *
396
+ * @param userId - l'`id` STABLE du compte (`RealtimeIdentity.id`), jamais le
397
+ * login.
398
+ * @param payload - objet, tableau ou octets ; omise = signal sans charge.
399
+ * @throws TypeError si la charge est un scalaire ou `null` (cf {@link publish}).
400
+ */
401
+ publishToUser(userId: string, payload?: object): void;
402
+ /** Préfixes déclarés diffusables entre serveurs, dans l'ordre de déclaration — lu par la ligne de démarrage. */
403
+ broadcastPrefixes(): readonly string[];
231
404
  /**
232
405
  * Fan-out d'une charge aux **seuls abonnés locaux** d'un canal (process courant).
233
406
  * NE propage PAS au backplane → c'est la voie d'**ingress** d'un message venu d'un
@@ -269,7 +442,11 @@ export declare class RealtimeHub {
269
442
  * sur le chemin chaud). À équilibrer par {@link unregisterConnection} au close.
270
443
  */
271
444
  registerConnection(conn: IRealtimeConnProbe): void;
272
- /** Retire une connexion du registre de la sonde (au close). No-op si absente. */
445
+ /**
446
+ * Retire une connexion du registre de la sonde (au close) et verse ses cumuls à
447
+ * ceux des connexions fermées : les totaux de {@link probe} restent monotones.
448
+ * No-op si absente — une double sortie ne compte pas deux fois.
449
+ */
273
450
  unregisterConnection(conn: IRealtimeConnProbe): void;
274
451
  /**
275
452
  * Inscrit une connexion à identité RÉVOCABLE au registre de
@@ -361,6 +538,30 @@ export declare class RealtimeHub {
361
538
  * `defineRealtimeConfig().slowConsumer.bytes` (sinon la clé de config serait morte).
362
539
  */
363
540
  setSlowConsumerBytes(bytes: number): void;
541
+ /**
542
+ * Les codecs de frame du serveur (ADR-0022 §2) : le texte, et le codec binaire
543
+ * accordé aux clients qui l'offrent — `null` tant que la configuration n'en
544
+ * active aucun. Lu une fois par connexion, au handshake, jamais par trame.
545
+ */
546
+ get frameCodecs(): IFrameCodecSet;
547
+ /**
548
+ * Pose le codec binaire du serveur — 1× au boot par `RealtimeService.init()`,
549
+ * depuis la clé `codec` de la configuration. Sans argument : texte seul.
550
+ *
551
+ * @param codec - le codec activé (`CBOR_FRAME_CODEC` de `nodefony/cbor`).
552
+ */
553
+ setFrameCodec(codec?: IFrameCodec): void;
554
+ /**
555
+ * Le codec des connexions texte — celui qu'emprunte aussi le REST du plan
556
+ * d'administration, pour qu'une valeur lue par `GET` soit celle que la socket
557
+ * livre (#618 D9). Avec un codec binaire activé, c'est le JSON à convention
558
+ * d'octets ; sinon le JSON natif.
559
+ */
560
+ get textFrameCodec(): IFrameCodec;
561
+ /**
562
+ * Pose les bornes des admissions (`authenticateTimeoutMs`, `allowTimeoutMs`, `maxPendingAllowsPerConnection`, `maxPendingAllowsTotal`) — 1× au boot par `RealtimeService.init()`.
563
+ */
564
+ setAdmissionLimits(limits: IAdmissionLimits): void;
364
565
  /**
365
566
  * Associe un token au peer (posé par `RealtimeController.onHandshake()` après
366
567
  * succès `authenticator.authenticate()` ou fallback anonyme). Lazy alloc de
@@ -439,9 +640,18 @@ export declare class RealtimeHub {
439
640
  *
440
641
  * @param name - nom exact du canal (subscribe), de la méthode inbound, ou
441
642
  * MOTIF de famille s'il contient `*`.
643
+ * Une politique à `allow` porte sa règle d'admission : une flèche est
644
+ * retenue telle quelle ; une classe exige son instance, résolue au démarrage
645
+ * par l'injecteur, dont la méthode est liée ICI, une fois — le `subscribe`
646
+ * n'appelle qu'une fonction prête.
647
+ *
442
648
  * @param policy - exigences ({@link IChannelPolicy}) lues par security.
649
+ * @param admission - instance de la classe d'admission quand `policy.allow`
650
+ * en est une ; ignorée sinon.
651
+ * @throws BootConfigurationError La politique désigne une classe d'admission
652
+ * et aucune instance de CETTE classe n'est fournie.
443
653
  */
444
- registerChannelPolicy(name: string, policy: IChannelPolicy): void;
654
+ registerChannelPolicy(name: string, policy: IChannelPolicy, admission?: IChannelAdmission): void;
445
655
  /**
446
656
  * Nombre maximum de `*` dans un motif.
447
657
  *
@@ -459,6 +669,31 @@ export declare class RealtimeHub {
459
669
  * la frame `subscribe`/inbound. O(1), 0 alloc quand le registre est vide.
460
670
  */
461
671
  resolveChannelPolicy(name: string): IChannelPolicy | null;
672
+ /**
673
+ * Règle d'admission prête à appeler pour ce canal — `null` s'il ne déclare pas `allow`. Lue au `subscribe`, jamais par trame.
674
+ *
675
+ * Même résolution que {@link resolveChannelPolicy} (nom exact, puis motif le
676
+ * plus spécifique) : la règle suit TOUJOURS la politique qui s'applique.
677
+ */
678
+ resolveChannelAdmission(name: string): IChannelAdmissionRule | null;
679
+ /**
680
+ * Les politiques de cette classe de contrôleur sont-elles déjà inscrites ? Une poignée de main n'en paie que ce `has`.
681
+ *
682
+ * @param owner - le constructeur du contrôleur.
683
+ */
684
+ hasPolicyOwner(owner: object): boolean;
685
+ /**
686
+ * Marque les politiques de cette classe comme inscrites — APRÈS l'inscription réussie, jamais avant : une politique refusée doit l'être de nouveau à la poignée de main suivante, pas laisser le canal ouvert.
687
+ *
688
+ * @param owner - le constructeur du contrôleur.
689
+ */
690
+ addPolicyOwner(owner: object): void;
691
+ /**
692
+ * Une politique est-elle déclarée sous ce nom EXACT — motifs ignorés ? C'est la question du défaut fermé des actions.
693
+ *
694
+ * @param name - nom d'action ou de canal.
695
+ */
696
+ hasExactChannelPolicy(name: string): boolean;
462
697
  /** Des politiques de canal sont-elles déclarées (au moins une) ? */
463
698
  hasChannelPolicies(): boolean;
464
699
  /**
@@ -466,14 +701,14 @@ export declare class RealtimeHub {
466
701
  * DYNAMIQUE n'est couvert par aucune politique déclarée, alors que d'autres
467
702
  * canaux du module en portent une.
468
703
  *
469
- * Le registre des politiques est indexé par nom **EXACT** : `@RealtimeChannel`
470
- * déclare `"chat:room"`, mais un canal dérivé (`chat:room:1000` — cadence,
471
- * forage, suffixe) est servi par la fabrique dynamique et **ne matche pas**.
472
- * L'auteur croit alors son canal gardé ; il ne l'est pas, et rien ne le lui
473
- * dit. C'est ce silence qu'on ferme ici — et depuis qu'une politique peut se
474
- * déclarer par MOTIF (`chat:room:*`), cet avertissement ne parle plus que des
475
- * canaux qu'aucun motif ne couvre : il désigne un vrai trou, pas une limite
476
- * de l'outil.
704
+ * Le registre des politiques lit le nom **EXACT**, puis les **MOTIFS**
705
+ * déclarés ({@link resolveChannelPolicy}) : `@RealtimeChannel` déclare
706
+ * `"chat:room"`, mais un canal dérivé (`chat:room:1000` — cadence, forage,
707
+ * suffixe) servi par la fabrique dynamique ne matche ce nom que si un motif
708
+ * (`chat:room:*`) le couvre. Sans motif, l'auteur croit son canal gardé ; il
709
+ * ne l'est pas, et rien ne le lui dit. C'est ce silence qu'on ferme ici :
710
+ * l'avertissement ne parle que des canaux qu'aucun motif ne couvre — un vrai
711
+ * trou, pas une limite de l'outil.
477
712
  *
478
713
  * Appelé par le contrôleur, seul à savoir par quelle voie le canal a été
479
714
  * servi. Muet si aucune politique n'est déclarée (module aux canaux libres :
@@ -514,6 +749,21 @@ export declare class RealtimeHub {
514
749
  /** Bornes en vigueur, ou `null` si le canal montant est fermé. */
515
750
  get clientLogsLimits(): IClientLogsLimits | null;
516
751
  runAuthorizer(frame: unknown, peer: JsonRpcPeer): boolean;
752
+ /**
753
+ * Inscrit une connexion qui vient de retenir son premier « oui » du verrou.
754
+ *
755
+ * Appelé par `gateFrame`, jamais par trame chaude ; à équilibrer par
756
+ * {@link untrackRetainedVerdicts} à la fermeture.
757
+ *
758
+ * @param state - état de la connexion.
759
+ */
760
+ trackRetainedVerdicts(state: IRealtimeConnectionState): void;
761
+ /**
762
+ * Retire une connexion fermée du registre des verdicts retenus.
763
+ *
764
+ * @param state - état de la connexion.
765
+ */
766
+ untrackRetainedVerdicts(state: IRealtimeConnectionState): void;
517
767
  /**
518
768
  * Ce peer pourrait-il s'abonner à ce canal ? — même décision que `subscribe`,
519
769
  * sans trace ni effet de bord.
@@ -533,6 +783,53 @@ export declare class RealtimeHub {
533
783
  /** Dispose tous les providers et vide le hub (arrêt process / reset de test). */
534
784
  clear(): void;
535
785
  }
536
- /** Renvoie le hub realtime du process (singleton lazy). */
786
+ /**
787
+ * Le hub lu à travers le contrat `C` : mêmes méthodes que {@link RealtimeHub},
788
+ * mais `publish` n'accepte que les canaux de `serverToClient`, chacun avec la
789
+ * charge qu'il déclare. C'est le MÊME objet que le hub libre — une vue de type,
790
+ * sans un octet ni une instruction de plus.
791
+ *
792
+ * @typeParam C - le contrat de l'application.
793
+ */
794
+ export interface IContractRealtimeHub<C extends IRealtimeContract> extends Omit<RealtimeHub, "publish" | "publishToUser"> {
795
+ /**
796
+ * Livre à UNE identité la charge que le contrat déclare pour le canal
797
+ * personnel (clé gabarit `` [k: `user:${string}`]: … `` de `serverToClient`)
798
+ * — cf {@link RealtimeHub.publishToUser}. Contrat sans cette clé : ne compile
799
+ * pas, la forme du message privé se déclare d'abord.
800
+ *
801
+ * @param userId - l'`id` STABLE du compte, jamais le login.
802
+ */
803
+ publishToUser(userId: string, payload: UserChannelPayload<ContractServerEmit<C>> & object): void;
804
+ /**
805
+ * Publie sur un canal déclaré par le contrat (fan-out local, puis backplane
806
+ * si le canal est broadcast) — cf {@link RealtimeHub.publish}.
807
+ *
808
+ * @throws TypeError si la charge n'est pas un objet ({@link isRealtimePayload}).
809
+ */
810
+ publish<K extends EventNames<ContractServerEmit<C>>>(channel: K, payload: EventPayload<ContractServerEmit<C>, K> & object): void;
811
+ }
812
+ /**
813
+ * Renvoie le hub realtime du process (singleton lazy) — forme LIBRE : tout nom
814
+ * de canal, toute charge objet.
815
+ */
537
816
  export declare function getRealtimeHub(): RealtimeHub;
817
+ /**
818
+ * Renvoie le hub realtime du process lu à travers le contrat `C` : la diffusion
819
+ * d'un service, d'un job ou d'une boucle de jeu est vérifiée par le contrat
820
+ * partagé avec le navigateur. Même objet que la forme libre : 0 octet, 0 ns.
821
+ *
822
+ * Le contrat est choisi par l'appelant — rien n'empêche d'en nommer un autre que
823
+ * celui du contrôleur qui sert le canal ; c'est le fichier partagé qui fait foi.
824
+ *
825
+ * @typeParam C - le contrat de l'application.
826
+ *
827
+ * @example
828
+ * ```ts
829
+ * const chat = getRealtimeHub<ChatContract>();
830
+ * chat.publish("chat:room", { from: "ana", text: "salut" }); // charge vérifiée
831
+ * getRealtimeHub().publish("n'importe:quoi", { x: 1 }); // forme libre
832
+ * ```
833
+ */
834
+ export declare function getRealtimeHub<C extends IRealtimeContract>(): IContractRealtimeHub<C>;
538
835
  export default RealtimeHub;
@@ -31,7 +31,7 @@ export declare class ServerRealtimeSocket<Emit extends EventsMap = DefaultEvents
31
31
  private readonly hub;
32
32
  constructor(hub?: import("./RealtimeHub.js").RealtimeHub);
33
33
  /** Émet sur un canal — fan-out aux abonnés via le hub (+ backplane si broadcast). */
34
- publish<K extends string>(channel: K, payload?: K extends EventNames<Emit> ? EventPayload<Emit, K> : unknown): void;
34
+ publish<K extends string>(channel: K, payload?: K extends EventNames<Emit> ? EventPayload<Emit, K> : object): void;
35
35
  /**
36
36
  * S'abonne (ref-compté) à un canal pour le RECEVOIR. Au 1ᵉʳ consommateur, pose un
37
37
  * sink d'**écoute passive** auprès du hub : le service reçoit ce qui passe, sans
@@ -0,0 +1,71 @@
1
+ /** Rendu par {@link AdmissionBudget.acquire} quand une place est réservée. */
2
+ export declare const ADMISSION_GRANTED = -1;
3
+ /** Rendu par {@link AdmissionBudget.authenticateWithin} quand l'authentification n'a pas répondu à temps. */
4
+ export declare const AUTHENTICATE_TIMED_OUT: unique symbol;
5
+ /** Bornes des admissions, lues dans la configuration realtime (`authenticateTimeoutMs`, `allowTimeoutMs`, `maxPendingAllowsPerConnection`, `maxPendingAllowsTotal`). */
6
+ export interface IAdmissionLimits {
7
+ readonly authenticateTimeoutMs: number;
8
+ readonly allowTimeoutMs: number;
9
+ readonly maxPendingAllowsPerConnection: number;
10
+ readonly maxPendingAllowsTotal: number;
11
+ }
12
+ /**
13
+ * Budget des admissions d'UN exemplaire — appels d'`allow` en vol, coupe-circuit et compteurs de refus (ADR-0021 §S10 bis point 3, §S9).
14
+ *
15
+ * Partagé par toutes les connexions du hub ; chaque connexion garde sa propre
16
+ * file ({@link ConnectionAdmission}). Aucune horloge n'est lue tant que le
17
+ * coupe-circuit est fermé : une règle synchrone ne paie que deux incréments.
18
+ */
19
+ export declare class AdmissionBudget {
20
+ #private;
21
+ /** Délai de l'authentification à la poignée de main (ADR-0021 §S11). */
22
+ authenticateTimeoutMs: number;
23
+ /** Délai d'un appel d'`allow`, compté depuis la réception du `subscribe`, file comprise. */
24
+ timeoutMs: number;
25
+ /** Appels d'`allow` en vol par connexion ; au-delà, la demande attend son tour. */
26
+ maxPerConnection: number;
27
+ /** Applique les bornes de la configuration (boot, cold path). */
28
+ configure(limits: IAdmissionLimits): void;
29
+ /**
30
+ * Réserve une place pour UN appel d'`allow`.
31
+ *
32
+ * @returns {@link ADMISSION_GRANTED} si la place est prise ; sinon la demande
33
+ * est comptée dans `allowSaturatedTotal` et la valeur rendue est le délai à
34
+ * annoncer au client (`retryAfterMs`, coupe-circuit ouvert), ou `0` quand il
35
+ * n'est pas connu (plafond global atteint).
36
+ */
37
+ acquire(): number;
38
+ /** Rend la place d'un appel terminé, dépassé ou abandonné à la fermeture. */
39
+ release(): void;
40
+ /** Un appel d'`allow` n'a pas répondu dans son délai : compté, et porté au coupe-circuit. */
41
+ recordTimeout(now: number): void;
42
+ /** Une demande a épuisé son délai dans la file, sans appel d'`allow`. */
43
+ recordQueueExpired(): void;
44
+ /** Délai restant du coupe-circuit à `now`, ou `undefined` s'il est fermé. */
45
+ retryAfter(now: number): number | undefined;
46
+ /** Faut-il journaliser cette cause maintenant ? Vrai au plus une fois par fenêtre et par cause. */
47
+ shouldWarn(kind: "timeout" | "throw" | "authenticate", now: number): boolean;
48
+ /**
49
+ * Borne l'authentification d'une poignée de main par `authenticateTimeoutMs` (ADR-0021 §S11).
50
+ *
51
+ * Une identité arrivée après le délai est IGNORÉE : la promesse rendue est
52
+ * déjà réglée sur {@link AUTHENTICATE_TIMED_OUT}, et un rejet tardif ne lève
53
+ * rien (la promesse d'origine garde son gestionnaire). Le minuteur est coupé
54
+ * dès la réponse et ne retient pas le processus à l'arrêt. Chaque dépassement
55
+ * compte une poignée de main dans `authenticateTimedOutTotal`.
56
+ *
57
+ * @param pending - la promesse rendue par `authenticate()`
58
+ * @returns le jeton, ou {@link AUTHENTICATE_TIMED_OUT} au dépassement
59
+ */
60
+ authenticateWithin<T>(pending: Promise<T>): Promise<T | typeof AUTHENTICATE_TIMED_OUT>;
61
+ /** Appels d'`allow` en vol sur l'exemplaire. */
62
+ get pending(): number;
63
+ /** Cumul `allowTimedOutTotal` — unité : un appel d'`allow`. */
64
+ get timedOutTotal(): number;
65
+ /** Cumul `allowSaturatedTotal` — unité : une demande d'abonnement. */
66
+ get saturatedTotal(): number;
67
+ /** Cumul `authenticateTimedOutTotal` — unité : une poignée de main. */
68
+ get authenticateTimedOutTotal(): number;
69
+ /** Remet compteurs, coupe-circuit et bornes à leurs défauts (hub vidé). */
70
+ reset(): void;
71
+ }
@@ -0,0 +1,34 @@
1
+ import type { ChannelAdmissionClass, IChannelAdmission } from "nodefony";
2
+ import type RealtimeHub from "./RealtimeHub.js";
3
+ /**
4
+ * Résout l'instance d'une classe d'admission pour un canal — fournie par le module realtime au démarrage, absente à la poignée de main.
5
+ */
6
+ export type AdmissionResolver = (admissionClass: ChannelAdmissionClass, channel: string) => IChannelAdmission;
7
+ /**
8
+ * Inscrit au hub les politiques déclarées par une classe de contrôleur — une fois par classe et par hub, et seulement si l'inscription entière réussit.
9
+ *
10
+ * Appelée pour chaque contrôleur connu au démarrage du noyau, puis à la
11
+ * poignée de main pour une classe qui y aurait échappé : celle-ci ne paie alors
12
+ * qu'un `has`. Une politique refusée ne marque PAS la classe — elle est refusée
13
+ * de nouveau à la poignée de main suivante plutôt que de laisser le canal
14
+ * ouvert.
15
+ *
16
+ * @param hub - le hub qui porte le registre.
17
+ * @param ctor - constructeur du contrôleur.
18
+ * @param resolveAdmission - résolution des classes d'admission ; `null` hors
19
+ * démarrage, et le hub refuse alors une politique qui en désigne une.
20
+ * @throws BootConfigurationError Politique illisible, `allow` posé sur une
21
+ * action, ou classe d'admission non résolue.
22
+ */
23
+ export declare function registerControllerPolicies(hub: RealtimeHub, ctor: object, resolveAdmission: AdmissionResolver | null): void;
24
+ /**
25
+ * Fabrique la résolution des classes d'admission du démarrage : portée singleton exigée, instance du conteneur réutilisée, sinon construite UNE fois par l'injecteur.
26
+ *
27
+ * @param get - l'instance déjà construite d'une classe : le démarrage la lit
28
+ * dans le conteneur du noyau par sa clé (`null` tant que la classe n'y a
29
+ * jamais été rangée), le harnais de test parmi les instances que le test lui
30
+ * donne. `undefined` → construite par l'injecteur.
31
+ * @param log - journal du module (une ligne par canal résolu).
32
+ * @returns le résolveur, qui mémorise l'instance de chaque classe.
33
+ */
34
+ export declare function createAdmissionResolver(get: (key: string | null, admissionClass: ChannelAdmissionClass) => unknown, log: (message: string) => void): AdmissionResolver;
@@ -0,0 +1,68 @@
1
+ import type { ChannelAllowFunction, ISubscriberIdentity } from "nodefony";
2
+ import { type AdmissionBudget } from "./admissionBudget.js";
3
+ /**
4
+ * Règle d'admission d'un canal telle que le hub la tient prête — construite UNE fois à l'inscription de la politique, lue au `subscribe`.
5
+ */
6
+ export interface IChannelAdmissionRule {
7
+ /** La règle, flèche ou méthode `allow` déjà liée à son instance. */
8
+ readonly allow: ChannelAllowFunction;
9
+ /** `anonymous: true` de la politique : l'anonyme est présenté à `allow` au lieu d'être refusé d'office. */
10
+ readonly anonymous: boolean;
11
+ }
12
+ /**
13
+ * Ce que la file d'admission demande à sa connexion — ouvrir le canal, le refuser, journaliser. Implémenté par `RealtimeController`, une fois par connexion.
14
+ */
15
+ export interface IAdmissionHost {
16
+ /** Ouvre l'abonnement admis (et l'acquitte). */
17
+ open(channel: string): void;
18
+ /** Rend le refus observable (`realtime:denied`) ; `detail` n'est rédigé qu'hors production. */
19
+ deny(channel: string, reason: "forbidden" | "unavailable", detail: string | undefined, retryAfterMs?: number): void;
20
+ /** Journal d'exploitation (déjà limité en fréquence par l'appelant). */
21
+ warn(message: string): void;
22
+ }
23
+ /**
24
+ * Admission des abonnements d'UNE connexion aux canaux qui déclarent `allow` — appels bornés, file d'attente, délai, verdict (ADR-0021 §S10 et §S10 bis).
25
+ *
26
+ * Coûts, par construction :
27
+ * - une règle qui répond `true`/`false` sans promesse ne crée ni minuteur, ni
28
+ * promesse, ni entrée d'attente ;
29
+ * - au-delà de `maxPendingAllowsPerConnection` appels en vol, la demande ATTEND
30
+ * son tour (jamais `limit` : le rejeu au `welcome` renvoie tous les canaux
31
+ * d'un coup) ; la file reste `null` tant que personne n'attend ;
32
+ * - un seul minuteur par connexion, armé sur l'échéance de tête : les demandes
33
+ * arrivent dans l'ordre de leurs échéances, la plus ancienne non tranchée est
34
+ * donc toujours la prochaine à expirer.
35
+ *
36
+ * Seul `=== true` admet ; une exception ou un rejet valent `forbidden` ; un
37
+ * délai dépassé ou une saturation valent `unavailable`, et un verdict arrivé
38
+ * après coup est ignoré.
39
+ */
40
+ export declare class ConnectionAdmission {
41
+ #private;
42
+ /**
43
+ * @param host - la connexion qui ouvre ou refuse.
44
+ * @param budget - le budget de l'exemplaire (plafond global, coupe-circuit).
45
+ * @param who - identité d'admission gelée de la connexion.
46
+ * @param explain - rédiger le détail des refus (hors production).
47
+ */
48
+ constructor(host: IAdmissionHost, budget: AdmissionBudget, who: ISubscriberIdentity, explain: boolean);
49
+ /** Demandes en attente de verdict — comptées dans le plafond de canaux de la connexion. */
50
+ get unsettled(): number;
51
+ /**
52
+ * Un abonnement à ce canal attend-il déjà son verdict ? S'il avait été annulé par un `unsubscribe`, il est ranimé : le verdict à venir répondra.
53
+ *
54
+ * @returns `true` si la demande est déjà en attente (rien d'autre à faire).
55
+ */
56
+ revive(channel: string): boolean;
57
+ /** `unsubscribe` pendant l'attente : le verdict à venir sera jeté sans réponse. */
58
+ cancel(channel: string): void;
59
+ /**
60
+ * Demande l'admission sur `channel` selon sa règle — l'anonyme est refusé d'office sauf `anonymous: true`.
61
+ *
62
+ * @param channel - nom exact du canal demandé.
63
+ * @param rule - règle résolue par le hub pour ce canal.
64
+ */
65
+ request(channel: string, rule: IChannelAdmissionRule): void;
66
+ /** Fermeture de la connexion : minuteur coupé, places rendues, verdicts à venir jetés. */
67
+ close(): void;
68
+ }