@nodefony/realtime 10.0.0-alpha.1

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 (81) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +207 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/index.js +218 -0
  6. package/dist/nodefony/config/config.js +63 -0
  7. package/dist/nodefony/config/defineModuleConfig.js +60 -0
  8. package/dist/nodefony/decorators/realtimeDecorators.js +240 -0
  9. package/dist/nodefony/interfaces/IBackplane.js +1 -0
  10. package/dist/nodefony/interfaces/IChannelPolicy.js +1 -0
  11. package/dist/nodefony/interfaces/IClientLogsLimits.js +1 -0
  12. package/dist/nodefony/interfaces/IRealtimeAuthenticator.js +1 -0
  13. package/dist/nodefony/interfaces/IRealtimeAuthenticatorMatcher.js +1 -0
  14. package/dist/nodefony/interfaces/IRealtimeController.js +1 -0
  15. package/dist/nodefony/interfaces/IRealtimeHandshake.js +1 -0
  16. package/dist/nodefony/interfaces/IRealtimeProbe.js +1 -0
  17. package/dist/nodefony/interfaces/IRealtimeToken.js +1 -0
  18. package/dist/nodefony/interfaces/index.js +1 -0
  19. package/dist/nodefony/src/backplane/ClusterBackplane.js +105 -0
  20. package/dist/nodefony/src/backplane/LoopbackBackplane.js +40 -0
  21. package/dist/nodefony/src/backplane/RedisBackplane.js +176 -0
  22. package/dist/nodefony/src/backplane/backplaneRegistry.js +20 -0
  23. package/dist/nodefony/src/backplane/envelope.js +109 -0
  24. package/dist/nodefony/src/backplane/originId.js +35 -0
  25. package/dist/nodefony/src/backplane/publishQueue.js +96 -0
  26. package/dist/nodefony/src/cluster/ClusterProbeClient.js +284 -0
  27. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +35 -0
  28. package/dist/nodefony/src/server/RealtimeAdminApi.js +87 -0
  29. package/dist/nodefony/src/server/RealtimeController.js +601 -0
  30. package/dist/nodefony/src/server/RealtimeHub.js +926 -0
  31. package/dist/nodefony/src/server/ServerRealtimeSocket.js +168 -0
  32. package/dist/nodefony/src/server/deniedDetail.js +29 -0
  33. package/dist/nodefony/src/server/syslogUplink.js +105 -0
  34. package/dist/nodefony/src/server/welcomeEnv.js +25 -0
  35. package/dist/nodefony/src/service/RealtimeService.js +240 -0
  36. package/dist/nodefony/src/transport/WsConnectionTransport.js +94 -0
  37. package/dist/nodefony/testing/index.js +163 -0
  38. package/dist/types/index.d.ts +99 -0
  39. package/dist/types/nodefony/config/config.d.ts +43 -0
  40. package/dist/types/nodefony/config/defineModuleConfig.d.ts +49 -0
  41. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +190 -0
  42. package/dist/types/nodefony/interfaces/IBackplane.d.ts +128 -0
  43. package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +28 -0
  44. package/dist/types/nodefony/interfaces/IClientLogsLimits.d.ts +22 -0
  45. package/dist/types/nodefony/interfaces/IRealtimeAuthenticator.d.ts +53 -0
  46. package/dist/types/nodefony/interfaces/IRealtimeAuthenticatorMatcher.d.ts +46 -0
  47. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +36 -0
  48. package/dist/types/nodefony/interfaces/IRealtimeHandshake.d.ts +27 -0
  49. package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +206 -0
  50. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +48 -0
  51. package/dist/types/nodefony/interfaces/index.d.ts +18 -0
  52. package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +76 -0
  53. package/dist/types/nodefony/src/backplane/LoopbackBackplane.d.ts +29 -0
  54. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +164 -0
  55. package/dist/types/nodefony/src/backplane/backplaneRegistry.d.ts +51 -0
  56. package/dist/types/nodefony/src/backplane/envelope.d.ts +27 -0
  57. package/dist/types/nodefony/src/backplane/originId.d.ts +22 -0
  58. package/dist/types/nodefony/src/backplane/publishQueue.d.ts +71 -0
  59. package/dist/types/nodefony/src/cluster/ClusterProbeClient.d.ts +90 -0
  60. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +3 -0
  61. package/dist/types/nodefony/src/server/RealtimeAdminApi.d.ts +18 -0
  62. package/dist/types/nodefony/src/server/RealtimeController.d.ts +165 -0
  63. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +516 -0
  64. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +63 -0
  65. package/dist/types/nodefony/src/server/deniedDetail.d.ts +25 -0
  66. package/dist/types/nodefony/src/server/syslogUplink.d.ts +58 -0
  67. package/dist/types/nodefony/src/server/welcomeEnv.d.ts +21 -0
  68. package/dist/types/nodefony/src/service/RealtimeService.d.ts +167 -0
  69. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +78 -0
  70. package/dist/types/nodefony/testing/index.d.ts +169 -0
  71. package/docs/actions.md +573 -0
  72. package/docs/architecture.md +889 -0
  73. package/docs/configuration.md +803 -0
  74. package/docs/cookbook-chat.md +1619 -0
  75. package/docs/couverture-bancs.md +139 -0
  76. package/docs/index.md +372 -0
  77. package/docs/observabilite.md +592 -0
  78. package/docs/protocole.md +518 -0
  79. package/docs/securite.md +845 -0
  80. package/docs/vocabulaire.md +845 -0
  81. package/package.json +83 -0
@@ -0,0 +1,926 @@
1
+ import { LoopbackBackplane } from "../backplane/LoopbackBackplane.js";
2
+ import { ANONYMOUS_REALTIME_TOKEN } from "./AnonymousRealtimeToken.js";
3
+ import { NODEFONY_CHANNEL_NAMESPACE, isPlatformChannel } from "nodefony";
4
+ //#region nodefony/src/server/RealtimeHub.ts
5
+ /**
6
+ * Seuil d'alerte slow-consumer (octets de `bufferedAmount`). Au-delà, la connexion
7
+ * est comptée comme « lente » par la sonde (PAS encore de drop/close — la sonde MESURE
8
+ * avant qu'on optimise : stringify unique → seuil de drop → coalescing). 1 MiB =
9
+ * `websocket.maxPayload` par défaut : une file qui dépasse une frame max pleine est
10
+ * déjà anormale pour des canaux d'ÉTAT (latest-wins).
11
+ */
12
+ const SLOW_CONSUMER_BYTES = 1 << 20;
13
+ /**
14
+ * **Namespace de canaux RÉSERVÉ À LA PLATEFORME** — un seul préfixe, `nodefony:`.
15
+ *
16
+ * Les canaux qu'il couvre exposent l'état interne du pod : journaux, requêtes de
17
+ * base, métriques process, supervision, contrôle du noyau, journal d'audit. Aucun
18
+ * n'appartient au métier d'une application — ils décrivent le serveur lui-même.
19
+ *
20
+ * La liste reste exposée **ici** (au pluriel, sous forme de tableau) parce que le hub
21
+ * est propriétaire de l'espace de nommage des canaux : c'est lui qui les sert.
22
+ * `@nodefony/security`, quand il est chargé, y attache des POLITIQUES (quels rôles)
23
+ * qu'il lit sur cette liste via la surface de service — il ne la redéclare pas. Deux
24
+ * listes auraient divergé au premier namespace ajouté. Le tableau survit à la
25
+ * réduction à une entrée : la forme est un contrat public (`reservedSystemPrefixes()`),
26
+ * et rien n'interdit qu'un jour un second namespace de plateforme apparaisse.
27
+ *
28
+ * Le nom des canaux, lui, vient de la table `PLATFORM_CHANNELS` du cœur (isomorphe) :
29
+ * le navigateur doit connaître les mêmes noms que le serveur.
30
+ *
31
+ * Sans module de sécurité, ces canaux sont **fermés** aux connexions clientes
32
+ * (cf {@link RealtimeHub.subscribeClient}) : aucune identité n'existe alors, donc
33
+ * personne ne peut prouver qu'il a le droit de les lire.
34
+ */
35
+ const RESERVED_SYSTEM_PREFIXES = [NODEFONY_CHANNEL_NAMESPACE];
36
+ /**
37
+ * Le canal appartient-il au namespace réservé à la plateforme ?
38
+ *
39
+ * Alias de {@link isPlatformChannel} (cœur isomorphe) sous le nom que porte cette
40
+ * notion côté hub : le hub raisonne en « canal réservé », le client en « surface de
41
+ * plateforme ». Une seule implémentation — la comparaison est insensible à la casse,
42
+ * sinon `NODEFONY:syslog` échapperait au plancher que `nodefony:syslog` subit.
43
+ *
44
+ * @param channel - nom du canal demandé (suffixes de cadence et de forage inclus).
45
+ */
46
+ const isReservedSystemChannel = isPlatformChannel;
47
+ /**
48
+ * Période de re-validation des identités RÉVOCABLES — F4 (revue 0.6).
49
+ * Le verrou de frame est SYNC par doctrine (identité figée au handshake, cf
50
+ * {@link FrameAuthorizer}) → il ne peut pas re-lire la session par frame. Un socket
51
+ * survivrait donc à sa session (`subscribe` garderait ses flux après un logout HTTP),
52
+ * là où `api.request` re-valide déjà par requête (`isValid()`). Ce tick ferme
53
+ * l'écart : toutes les `REVOCATION_REVALIDATE_MS`, les tokens révocables sont
54
+ * re-validés et la connexion fermée si l'identité n'est plus valide (≤ 1 fenêtre de
55
+ * délai). Ordre de grandeur aligné sur le heartbeat WS (`keepaliveInterval` 20 s).
56
+ */
57
+ const REVOCATION_REVALIDATE_MS = 3e4;
58
+ /**
59
+ * RealtimeHub — broker temps réel **PAR INSTANCE** (1 pod = 1 process = 1 hub local).
60
+ *
61
+ * Élève le realtime serveur du modèle **per-connexion** (chaque connexion son propre
62
+ * ticker/listener = N fois le même travail) au modèle **canaux PARTAGÉS** : un canal a
63
+ * **UN provider** créé au 1ᵉʳ abonné et un **fan-out** vers tous les abonnés. C'est le
64
+ * gain cloud-native (1 ticker/canal/pod) ET le **seam du backplane Redis** (le cross-pod
65
+ * se branchera dans {@link publish} : fan-out local + forward Redis, l'ingress backplane
66
+ * ne refaisant QUE du fan-out local — règle anti-boucle). Cf vision « la socket Nodefony ».
67
+ *
68
+ * Périmètre actuel : canaux **broadcast** (même flux pour tous les abonnés — stats,
69
+ * syslog, orm…). Les canaux **privés/par-connexion** (ex. une ligne SIP par user) =
70
+ * seam futur (le provider serait per-connexion, pas partagé).
71
+ *
72
+ * Perf (règle ABSOLUE) : map des canaux **lazy** (rien alloué tant qu'aucun abonné) ;
73
+ * provider créé au 1ᵉʳ abonné, **disposé au dernier** (aucun timer/listener orphelin) ;
74
+ * fan-out isolé (une connexion fautive ne casse pas la diffusion). Le hub lui-même est
75
+ * **sans dépendance** : ce sont les *factories* (fournies par les contrôleurs) qui
76
+ * portent les deps.
77
+ *
78
+ * Vocabulaire : la **socket** ({@link IRealtimeSocket}) est la prise que tient le métier ;
79
+ * ce **hub** est le broker serveur caché derrière (registre + fan-out local). La socket
80
+ * multiplexe des canaux ; le hub aiguille entre les sockets. La façade *consommateur*
81
+ * côté serveur EXISTE — {@link ServerRealtimeSocket} (`subscribe/on/publish`), exportée
82
+ * par le barrel du module : c'est elle qu'un service back tient, jamais ce hub
83
+ * directement. Même rôle que `RealtimeClient` côté navigateur.
84
+ */
85
+ var RealtimeHub = class RealtimeHub {
86
+ #channels = null;
87
+ #publishTotal = 0;
88
+ #fanoutTotal = 0;
89
+ #inboundTotal = 0;
90
+ #ingressRejectedTotal = 0;
91
+ #systemFloorDeniedTotal = 0;
92
+ #notice = null;
93
+ #noticed = null;
94
+ #connections = null;
95
+ #revocable = null;
96
+ #revalidateTimer = null;
97
+ #backplane = null;
98
+ #authenticators = null;
99
+ #peerTokens = null;
100
+ #frameAuthorizer = null;
101
+ #channelProbe = null;
102
+ #channelPolicies = null;
103
+ /**
104
+ * Politiques déclarées par MOTIF de nom (`chat:room:*`), triées du plus
105
+ * spécifique au plus général, et `null` tant qu'aucune n'est déclarée.
106
+ *
107
+ * 🔴 Pourquoi une liste À PART de la Map exacte, et pourquoi elle reste
108
+ * `null` : `resolveChannelPolicy` est appelé à CHAQUE `subscribe`. Le nom
109
+ * exact doit rester une lecture O(1) sans allocation ; les motifs ne sont
110
+ * parcourus que si aucun nom exact ne répond ET qu'il en existe. Une
111
+ * application qui n'en déclare aucun ne paie rien — pas même le test d'un
112
+ * tableau vide.
113
+ *
114
+ * Le tri à l'insertion (littéral le plus long d'abord) évite de trancher au
115
+ * moment de la lecture : entre `chat:*` et `chat:room:*`, c'est le second qui
116
+ * doit gagner sur `chat:room:12`, et l'ordre le dit une fois pour toutes.
117
+ */
118
+ #channelPolicyPatterns = null;
119
+ #systemChannelFactories = null;
120
+ #clientLogs = null;
121
+ #originGuard = null;
122
+ #maxChannelsPerConnection = 256;
123
+ #slowConsumerBytes = SLOW_CONSUMER_BYTES;
124
+ #broadcastPrefixes = null;
125
+ /**
126
+ * Abonnement demandé par une **connexion cliente** — même chose que
127
+ * {@link subscribe}, plus le plancher des canaux de plateforme.
128
+ *
129
+ * Pourquoi deux portes : un service du serveur qui écoute ses propres journaux
130
+ * est légitime ; une connexion distante qui demande `nodefony:syslog` alors
131
+ * qu'aucun module de sécurité n'est chargé ne l'est pas. Le hub ne peut pas
132
+ * deviner l'origine d'un appel — le contrôleur, lui, sait qu'il traite une
133
+ * frame venue du réseau, et passe donc par ici.
134
+ *
135
+ * Le plancher ne s'applique **que** sans verrou de frame. Dès qu'un module de
136
+ * sécurité en pose un, c'est lui qui décide (avec les rôles, ce que le hub ne
137
+ * sait pas faire) et le plancher s'efface.
138
+ *
139
+ * @returns `true` si abonné, `false` si canal inconnu ou fermé par le plancher.
140
+ */
141
+ subscribeClient(channel, sink, factory, serialize) {
142
+ if (channel.includes("*")) {
143
+ this.#notifyOnce("wildcard-channel", `abonnement REFUSÉ au canal "${channel}" : le caractère « * » est réservé aux MOTIFS de politique et n'a pas de sens dans un nom de canal servi. Un motif garde une famille, il ne se souscrit pas.`);
144
+ return false;
145
+ }
146
+ if (this.#frameAuthorizer === null && isReservedSystemChannel(channel)) {
147
+ this.#systemFloorDeniedTotal += 1;
148
+ this.#notifyOnce("system-floor", `canal de plateforme "${channel}" REFUSÉ : aucun module de sécurité n'est chargé, donc aucune identité ne peut être vérifiée. Les namespaces réservés (${RESERVED_SYSTEM_PREFIXES.join(" ")}) sont fermés à toutes les connexions. Deux causes possibles : la sécurité n'est pas installée (charger @nodefony/security et déclarer une zone protégée), ou ce canal applicatif porte par erreur un préfixe réservé — dans ce cas, le renommer suffit.`);
149
+ return false;
150
+ }
151
+ return this.subscribe(channel, sink, factory, serialize);
152
+ }
153
+ /**
154
+ * Abonne un consommateur qui **répond du canal** : il apporte la `factory`, donc
155
+ * la réponse à « ce canal existe-t-il, et qui le produit ? ». Le provider partagé
156
+ * est créé au 1ᵉʳ abonné de ce genre. Le sink est inscrit AVANT l'appel à
157
+ * `factory` → le 1ᵉʳ paquet immédiat éventuel du provider (ex. tick initial de
158
+ * `createBrokerTicker`) atteint bien cet abonné.
159
+ *
160
+ * Un canal en état **passif** (ouvert par des écouteurs seuls, cf {@link listen})
161
+ * n'est PAS considéré comme existant : la fabrique est rejouée et tranche. Un
162
+ * refus laisse les écouteurs en place — ils n'ont jamais prétendu le produire.
163
+ *
164
+ * @returns `true` si abonné, `false` si canal inconnu (`factory` a renvoyé `null`).
165
+ */
166
+ subscribe(channel, sink, factory, serialize) {
167
+ const channels = this.#channels ??= /* @__PURE__ */ new Map();
168
+ let st = channels.get(channel);
169
+ if (st) {
170
+ if (st.owned) {
171
+ st.sinks.add(sink);
172
+ if (st.serialize === null && serialize !== void 0) st.serialize = serialize;
173
+ return true;
174
+ }
175
+ st.sinks.add(sink);
176
+ const dispose = this.#createProvider(channel, factory);
177
+ if (dispose === null) {
178
+ st.sinks.delete(sink);
179
+ return false;
180
+ }
181
+ st.dispose = dispose;
182
+ st.owned = true;
183
+ if (serialize !== void 0) st.serialize = serialize;
184
+ return true;
185
+ }
186
+ st = {
187
+ dispose: null,
188
+ owned: false,
189
+ sinks: /* @__PURE__ */ new Set([sink]),
190
+ serialize: serialize ?? null,
191
+ messages: 0,
192
+ forward: this.#isBroadcast(channel)
193
+ };
194
+ channels.set(channel, st);
195
+ const dispose = this.#createProvider(channel, factory);
196
+ if (dispose === null) {
197
+ channels.delete(channel);
198
+ return false;
199
+ }
200
+ st.dispose = dispose;
201
+ st.owned = true;
202
+ return true;
203
+ }
204
+ /**
205
+ * **Écoute passive** — un service du serveur reçoit ce qui passe sur un canal
206
+ * sans prétendre le produire. C'est la porte de {@link ServerRealtimeSocket} :
207
+ * un service back qui `subscribe` sur sa façade écoute, il n'ouvre rien.
208
+ *
209
+ * Différence avec {@link subscribe} : aucune fabrique, donc aucun provider et
210
+ * **aucune revendication d'existence**. Si le canal n'a pas encore de
211
+ * propriétaire, l'état créé est marqué passif ; la première connexion cliente
212
+ * qui le demandera fera trancher la fabrique du controller. Un canal déjà
213
+ * ouvert est simplement rejoint (le provider partagé n'est pas touché).
214
+ *
215
+ * Un écouteur n'échoue jamais : il entendra si — et quand — quelqu'un publie.
216
+ * Cold path (abonnement d'un service, pas une frame réseau).
217
+ */
218
+ listen(channel, sink) {
219
+ const channels = this.#channels ??= /* @__PURE__ */ new Map();
220
+ const st = channels.get(channel);
221
+ if (st !== void 0) {
222
+ st.sinks.add(sink);
223
+ return;
224
+ }
225
+ channels.set(channel, {
226
+ dispose: null,
227
+ owned: false,
228
+ sinks: /* @__PURE__ */ new Set([sink]),
229
+ serialize: null,
230
+ messages: 0,
231
+ forward: this.#isBroadcast(channel)
232
+ });
233
+ }
234
+ /**
235
+ * Le canal a-t-il un propriétaire (une fabrique a fourni son provider) ?
236
+ * `false` = inconnu, ou tenu par de seuls écouteurs ({@link listen}).
237
+ */
238
+ isChannelOwned(channel) {
239
+ return this.#channels?.get(channel)?.owned ?? false;
240
+ }
241
+ /**
242
+ * Crée le provider d'un canal : fabrique de l'abonné d'abord, registre des
243
+ * canaux SYSTÈME en dernier recours. `null` = canal inconnu des deux.
244
+ */
245
+ #createProvider(channel, factory) {
246
+ const publishFn = (ch, payload) => this.publish(ch, payload);
247
+ const dispose = factory(channel, publishFn);
248
+ if (dispose !== null) return dispose;
249
+ const sys = this.#systemChannelFactories?.get(channel);
250
+ return sys ? sys(channel, publishFn) : null;
251
+ }
252
+ /**
253
+ * Désabonne une connexion d'un canal. Au **dernier** abonné, `dispose()` le provider
254
+ * et retire le canal (libère timers/listeners). No-op si non abonné.
255
+ */
256
+ unsubscribe(channel, sink) {
257
+ const st = this.#channels?.get(channel);
258
+ if (!st) return;
259
+ st.sinks.delete(sink);
260
+ if (st.sinks.size === 0) {
261
+ try {
262
+ st.dispose?.();
263
+ } catch {}
264
+ this.#channels.delete(channel);
265
+ }
266
+ }
267
+ /**
268
+ * Publie une charge sur un canal : **fan-out local** **+** propagation conditionnelle
269
+ * au {@link IBackplane} (autres workers/pods). C'est le point d'entrée des providers
270
+ * locaux (tickers, listeners) ET de tout code serveur.
271
+ *
272
+ * Forward **opt-in par canal** : la charge ne traverse le backplane que si le canal
273
+ * est déclaré **broadcast** ({@link markBroadcastChannel}). Par défaut un canal est
274
+ * **instance-local** (per-instance : observabilité, état du pod) → il reste dans le
275
+ * process. Le flag est mis en cache dans l'état du canal (lu en O(1)) ; pour un
276
+ * `publish` serveur sans abonné local (pas d'état), la politique est évaluée à la volée.
277
+ *
278
+ * Anti-boucle : un message **reçu** du backplane ne repasse PAS par ici (il appelle
279
+ * `publishLocal` directement) → aucune ré-émission cross-process, pas de tempête.
280
+ */
281
+ publish(channel, payload) {
282
+ const st = this.#channels?.get(channel);
283
+ if (st !== void 0) this.#fanout(st, payload);
284
+ if (this.#backplane === null) return;
285
+ if (st !== void 0 ? st.forward : this.#isBroadcast(channel)) this.#backplane.publish(channel, payload);
286
+ }
287
+ /**
288
+ * Fan-out d'une charge aux **seuls abonnés locaux** d'un canal (process courant).
289
+ * NE propage PAS au backplane → c'est la voie d'**ingress** d'un message venu d'un
290
+ * autre pair (le hub câble `backplane.onMessage` dessus dans {@link setBackplane}) :
291
+ * réinjection locale sans reboucler. Aussi appelable directement pour un push
292
+ * strictement local (jamais propagé).
293
+ */
294
+ publishLocal(channel, payload) {
295
+ const st = this.#channels?.get(channel);
296
+ if (st !== void 0) this.#fanout(st, payload);
297
+ }
298
+ /** Fan-out interne (sonde + livraison isolée). Partagé par `publish`/`publishLocal`. */
299
+ #fanout(st, payload) {
300
+ this.#publishTotal += 1;
301
+ st.messages += 1;
302
+ this.#fanoutTotal += st.sinks.size;
303
+ let raw;
304
+ if (st.serialize !== null && st.sinks.size > 1) try {
305
+ raw = st.serialize(payload);
306
+ } catch {
307
+ raw = void 0;
308
+ }
309
+ for (const sink of st.sinks) try {
310
+ sink(payload, raw);
311
+ } catch {}
312
+ }
313
+ /**
314
+ * Déclare un **préfixe** de canal **broadcast** : ses publications traversent le
315
+ * {@link IBackplane} (cross-process). Tout le reste demeure instance-local (défaut sûr).
316
+ * Le préfixe couvre la granularité de cadence (`chat:` matche `chat:room1:1000`).
317
+ *
318
+ * Idempotent ; cold-path (appelé au handshake/boot d'un endpoint realtime, jamais
319
+ * sur le chemin chaud). Réévalue les canaux DÉJÀ actifs → l'ordre déclaration/abonnement
320
+ * n'a pas d'importance.
321
+ *
322
+ * @param prefix - préfixe de canal à forwarder (ex. `"chat:"`, `"presence:"`).
323
+ */
324
+ markBroadcastChannel(prefix) {
325
+ if (isReservedSystemChannel(prefix)) {
326
+ this.#notifyOnce("reserved-broadcast", `déclaration de diffusion IGNORÉE pour "${prefix}" : ce namespace est réservé à la plateforme (${RESERVED_SYSTEM_PREFIXES.join(" ")}). Le diffuser entre pods ouvrirait l'entrée du bus à ce canal — un tiers pourrait y injecter du contenu. Utiliser un préfixe applicatif.`);
327
+ return;
328
+ }
329
+ const prefixes = this.#broadcastPrefixes ??= [];
330
+ if (prefixes.includes(prefix)) return;
331
+ prefixes.push(prefix);
332
+ if (this.#channels) {
333
+ for (const [channel, st] of this.#channels) if (!st.forward && channel.startsWith(prefix)) st.forward = true;
334
+ }
335
+ }
336
+ /** Un canal est-il broadcast (préfixe déclaré) ? `false` si aucune politique (défaut local). */
337
+ #isBroadcast(channel) {
338
+ const prefixes = this.#broadcastPrefixes;
339
+ if (prefixes === null) return false;
340
+ for (let i = 0; i < prefixes.length; i++) if (channel.startsWith(prefixes[i])) return true;
341
+ return false;
342
+ }
343
+ /**
344
+ * Branche le {@link IBackplane} cross-process (cluster IPC, Redis…) et câble son
345
+ * ingress ({@link #admitFromBackplane}) : un message reçu d'un autre pair est
346
+ * réinjecté en **fan-out local uniquement** ({@link publishLocal}) — barrière
347
+ * anti-boucle côté hub (le backplane filtre déjà son propre echo). Remplace un
348
+ * backplane précédent (l'appelant est responsable de `stop()` l'ancien). Démarre
349
+ * le transport.
350
+ *
351
+ * @returns le backplane branché (chaînage).
352
+ */
353
+ setBackplane(backplane) {
354
+ this.#backplane = backplane;
355
+ backplane.onMessage((msg) => this.#admitFromBackplane(msg));
356
+ backplane.start();
357
+ return backplane;
358
+ }
359
+ /**
360
+ * **Contrôle d'admission de l'ingress backplane** (F83) — un message venu d'un
361
+ * pair n'est réinjecté QUE si son canal est déclaré **broadcast**
362
+ * ({@link markBroadcastChannel}) ; sinon il est compté et jeté.
363
+ *
364
+ * POURQUOI : la politique de forward était asymétrique. En SORTIE, `publish` ne
365
+ * traverse le backplane que pour un canal broadcast ; en ENTRÉE, tout était
366
+ * accepté. Un pair (ou quiconque écrit dans un bus partagé, cf `envelope.ts`)
367
+ * pouvait donc pousser sur des canaux **instance-local** que la politique refuse
368
+ * précisément de faire voyager — `syslog:`, `nodefony:audit`, `nodefony:socket` —
369
+ * et injecter de faux évènements dans les écrans d'admin de TOUS les pods.
370
+ *
371
+ * Symétrie rétablie, et **quel que soit le driver** : un driver userland qui
372
+ * n'authentifierait rien ne peut pas contourner cette porte. Zéro régression
373
+ * nominale : un pair légitime n'émet que des canaux broadcast (`publish` filtre à
374
+ * la source), et un canal ne peut avoir d'abonné local que si l'endpoint qui le
375
+ * sert a été handshaké — donc ses préfixes déjà déclarés.
376
+ */
377
+ #admitFromBackplane(msg) {
378
+ if (!this.#isBroadcast(msg.channel)) {
379
+ this.#ingressRejectedTotal += 1;
380
+ return;
381
+ }
382
+ this.publishLocal(msg.channel, msg.payload);
383
+ }
384
+ /** Backplane cross-process branché, ou `null` en mono-process (lecture/tests/sonde). */
385
+ get backplane() {
386
+ return this.#backplane;
387
+ }
388
+ /** Nombre d'abonnés locaux d'un canal (observabilité / tests). */
389
+ subscriberCount(channel) {
390
+ return this.#channels?.get(channel)?.sinks.size ?? 0;
391
+ }
392
+ /**
393
+ * Inscrit une connexion au registre de la sonde (au handshake). Lazy : alloue le
394
+ * Set au 1ᵉʳ appel. Le hub ne lit ces connexions QUE dans {@link probe} (jamais
395
+ * sur le chemin chaud). À équilibrer par {@link unregisterConnection} au close.
396
+ */
397
+ registerConnection(conn) {
398
+ (this.#connections ??= /* @__PURE__ */ new Set()).add(conn);
399
+ }
400
+ /** Retire une connexion du registre de la sonde (au close). No-op si absente. */
401
+ unregisterConnection(conn) {
402
+ this.#connections?.delete(conn);
403
+ }
404
+ /**
405
+ * Inscrit une connexion à identité RÉVOCABLE au registre de
406
+ * re-validation (F4, revue 0.6). Démarre le tick au 1ᵉʳ inscrit (timer `unref`,
407
+ * 0 timer au repos). À équilibrer par {@link unregisterRevocable} au close.
408
+ * N'inscrire QUE les tokens portant `isValid` (le controller filtre) : une identité
409
+ * non révocable (anonyme/JWT sans revalidation) ne doit pas y entrer.
410
+ */
411
+ registerRevocable(entry) {
412
+ (this.#revocable ??= /* @__PURE__ */ new Set()).add(entry);
413
+ if (this.#revalidateTimer === null) {
414
+ this.#revalidateTimer = setInterval(() => {
415
+ this.revalidateRevocable();
416
+ }, REVOCATION_REVALIDATE_MS);
417
+ this.#revalidateTimer.unref?.();
418
+ }
419
+ }
420
+ /**
421
+ * Retire une connexion du registre de révocation (au close). Arrête le tick dès que
422
+ * le registre est vide (0 timer au repos). No-op si absente.
423
+ */
424
+ unregisterRevocable(entry) {
425
+ if (this.#revocable === null) return;
426
+ this.#revocable.delete(entry);
427
+ if (this.#revocable.size === 0 && this.#revalidateTimer !== null) {
428
+ clearInterval(this.#revalidateTimer);
429
+ this.#revalidateTimer = null;
430
+ }
431
+ }
432
+ /**
433
+ * Tick de re-validation (F4) : re-lit l'identité de chaque connexion révocable
434
+ * (`token.isValid()`) et FERME la socket (`4001`) si l'identité est morte : session
435
+ * détruite ou passée à un autre compte, jeton expiré ou révoqué.
436
+ * Fail-closed : une re-validation qui throw ferme aussi (parité `invokeApiRequest`).
437
+ * Snapshot du registre AVANT les `await` (le `close` mute le registre via le cleanup
438
+ * `onFinish`). Exposé (public) pour un test déterministe sans fake timers.
439
+ */
440
+ async revalidateRevocable() {
441
+ if (this.#revocable === null || this.#revocable.size === 0) return;
442
+ const snapshot = [...this.#revocable];
443
+ for (const entry of snapshot) {
444
+ let valid;
445
+ try {
446
+ valid = await entry.token.isValid?.();
447
+ } catch {
448
+ valid = false;
449
+ }
450
+ if (valid === false) {
451
+ this.unregisterRevocable(entry);
452
+ try {
453
+ entry.close(4001, "session revoked");
454
+ } catch {}
455
+ }
456
+ }
457
+ }
458
+ /**
459
+ * Compte une frame entrante full-duplex (canaux gated SIP/bridge). Appelé par le
460
+ * contrôleur quand un handler `realtimeInbound` traite un message client.
461
+ */
462
+ recordInbound() {
463
+ this.#inboundTotal += 1;
464
+ }
465
+ /**
466
+ * Snapshot d'auto-observabilité de la socket (per-instance). Lecture PURE (aucune
467
+ * alloc sur le chemin chaud, jamais throw) : agrège canaux + fan-out + connexions +
468
+ * **backpressure** (`bufferedAmount`, risque #1). Appelé à la demande (endpoint HTTP)
469
+ * ou par le ticker hub `nodefony:socket`. Les cumuls sont monotones → débit dérivé
470
+ * côté lecteur. Cf {@link IRealtimeProbe}.
471
+ */
472
+ probe() {
473
+ const channels = [];
474
+ if (this.#channels) for (const [channel, st] of this.#channels) channels.push({
475
+ channel,
476
+ subscribers: st.sinks.size,
477
+ messages: st.messages
478
+ });
479
+ let connectionCount = 0;
480
+ let bytesSentTotal = 0;
481
+ let messagesSentTotal = 0;
482
+ let maxBufferedAmount = 0;
483
+ let totalBufferedAmount = 0;
484
+ let slowConsumers = 0;
485
+ let drops = 0;
486
+ if (this.#connections) for (const c of this.#connections) {
487
+ connectionCount += 1;
488
+ bytesSentTotal += c.bytesSent;
489
+ messagesSentTotal += c.messagesSent;
490
+ drops += c.dropped;
491
+ const buf = c.bufferedAmount;
492
+ if (buf > maxBufferedAmount) maxBufferedAmount = buf;
493
+ totalBufferedAmount += buf;
494
+ if (buf >= this.#slowConsumerBytes) slowConsumers += 1;
495
+ }
496
+ return {
497
+ ts: Date.now(),
498
+ channels,
499
+ channelCount: channels.length,
500
+ publishTotal: this.#publishTotal,
501
+ fanoutTotal: this.#fanoutTotal,
502
+ inboundTotal: this.#inboundTotal,
503
+ ingressRejectedTotal: this.#ingressRejectedTotal,
504
+ systemFloorDeniedTotal: this.#systemFloorDeniedTotal,
505
+ connectionCount,
506
+ bytesSentTotal,
507
+ messagesSentTotal,
508
+ backpressure: {
509
+ maxBufferedAmount,
510
+ totalBufferedAmount,
511
+ slowConsumers,
512
+ drops
513
+ },
514
+ backplane: this.#backplane?.describe() ?? {
515
+ driver: LoopbackBackplane.driver,
516
+ kind: "local",
517
+ originId: String(process.pid),
518
+ crossPod: false
519
+ }
520
+ };
521
+ }
522
+ /** Canaux actifs (≥ 1 abonné). Lecture seule. */
523
+ get activeChannels() {
524
+ return this.#channels ? [...this.#channels.keys()] : [];
525
+ }
526
+ /**
527
+ * **Seam #2 (P13 → P6)** — enregistre un authenticator pour les handshakes
528
+ * WS dont l'URL matche `matcher`. Ordre d'enregistrement préservé : le
529
+ * **1ʳᵉ** matcher qui capture la connexion gagne. Convention : enregistrer
530
+ * les patterns les plus spécifiques en premier.
531
+ *
532
+ * Idempotent par couple `(matcher, authenticator)` — appel multiple = même
533
+ * effet (push si pas déjà présent). Cold-path (boot via P6/userland).
534
+ *
535
+ * @param matcher sélecteur (pattern URL + vhost optionnel)
536
+ * @param authenticator stratégie d'auth réseau (handshake → IRealtimeToken)
537
+ */
538
+ useAuthenticator(matcher, authenticator) {
539
+ const list = this.#authenticators ??= [];
540
+ const compiled = compileMatcher(matcher);
541
+ for (let i = 0; i < list.length; i++) if (list[i].authenticator === authenticator) return;
542
+ list.push({
543
+ matcher: compiled,
544
+ authenticator
545
+ });
546
+ }
547
+ /**
548
+ * Résout l'authenticator capturant ce handshake (1ʳᵉ matcher qui matche),
549
+ * ou `null` si aucun (le RealtimeController posera un token anonyme).
550
+ * Cold path (1× par upgrade) — lecture array.
551
+ */
552
+ resolveAuthenticator(handshake) {
553
+ const list = this.#authenticators;
554
+ if (list === null) return null;
555
+ for (let i = 0; i < list.length; i++) {
556
+ const entry = list[i];
557
+ if (entry.matcher.match(handshake)) return entry.authenticator;
558
+ }
559
+ return null;
560
+ }
561
+ /**
562
+ * **Seam #4 (P13 → P6)** — pose la politique Origin (RFC 6455 §10.2).
563
+ * `null` = aucune politique (toutes origines acceptées). Posée 1× par
564
+ * `RealtimeService.init()` depuis `defineRealtimeConfig().csrf.checkOrigin`.
565
+ */
566
+ setOriginGuard(guard) {
567
+ this.#originGuard = guard;
568
+ }
569
+ /**
570
+ * Vérifie qu'une origin est autorisée (politique CSRF). `true` si aucune
571
+ * politique posée (rétrocompat). Cold path (1× par upgrade).
572
+ */
573
+ checkOrigin(origin) {
574
+ return this.#originGuard === null ? true : this.#originGuard(origin);
575
+ }
576
+ /**
577
+ * F6a (revue 0.6) — plafond de canaux par connexion (anti-OOM). Posé 1× au
578
+ * boot par `RealtimeService.init()` depuis
579
+ * `defineRealtimeConfig().limits.maxChannelsPerConnection`. `null` = illimité.
580
+ */
581
+ setMaxChannelsPerConnection(max) {
582
+ this.#maxChannelsPerConnection = max;
583
+ }
584
+ /** Plafond courant de canaux par connexion (`null` = illimité). Lu par `startChannel`. */
585
+ get maxChannelsPerConnection() {
586
+ return this.#maxChannelsPerConnection;
587
+ }
588
+ /**
589
+ * F9 (revue 0.6) — pose le seuil de comptage des consommateurs lents (`probe()`).
590
+ * Posé 1× au boot par `RealtimeService.init()` depuis
591
+ * `defineRealtimeConfig().slowConsumer.bytes` (sinon la clé de config serait morte).
592
+ */
593
+ setSlowConsumerBytes(bytes) {
594
+ this.#slowConsumerBytes = bytes;
595
+ }
596
+ /**
597
+ * Associe un token au peer (posé par `RealtimeController.onHandshake()` après
598
+ * succès `authenticator.authenticate()` ou fallback anonyme). Lazy alloc de
599
+ * la `WeakMap` au 1ᵉʳ appel (0 coût si aucun authenticator enregistré).
600
+ */
601
+ setTokenForPeer(peer, token) {
602
+ (this.#peerTokens ??= /* @__PURE__ */ new WeakMap()).set(peer, token);
603
+ }
604
+ /**
605
+ * Renvoie le token associé au peer, ou `ANONYMOUS_REALTIME_TOKEN` si aucun
606
+ * (cas Zero Trust : la lecture ne renvoie JAMAIS `null` — slot #6 audit
607
+ * lookup garanti, voters P6 ne se posent jamais la question « actor ? »).
608
+ */
609
+ getTokenForPeer(peer) {
610
+ return this.#peerTokens?.get(peer) ?? ANONYMOUS_REALTIME_TOKEN;
611
+ }
612
+ /**
613
+ * **Seam #1 (P13 → P6)** — pose le verrou de frame (cf {@link FrameAuthorizer}).
614
+ * `null` retire la politique (bypass total). Posé 1× au boot par
615
+ * `@nodefony/security` depuis les zones `realtime: true`. Cold path.
616
+ */
617
+ setFrameAuthorizer(authorizer, options) {
618
+ this.#frameAuthorizer = authorizer;
619
+ this.#channelProbe = options?.silentProbe ?? null;
620
+ }
621
+ /**
622
+ * Un verrou de frame est-il posé ?
623
+ *
624
+ * Sert au DIAGNOSTIC (`hasUnenforcedChannelPolicies`, introspection, tests) —
625
+ * **plus** à décider si on arme le verrou sur une connexion : le contrôleur
626
+ * l'arme toujours, précisément pour qu'une politique posée après coup garde
627
+ * les sockets déjà ouvertes.
628
+ */
629
+ hasFrameAuthorizer() {
630
+ return this.#frameAuthorizer !== null;
631
+ }
632
+ /**
633
+ * Branche l'alerte du plancher système — tirée **une seule fois**, au premier
634
+ * canal de plateforme refusé faute de module de sécurité.
635
+ *
636
+ * Posée par le contrôleur : le hub est sans dépendance (aucun journal), et une
637
+ * fermeture silencieuse serait exactement la dégradation muette qu'on cherche
638
+ * à interdire.
639
+ *
640
+ * @param notify - reçoit le message à journaliser (niveau avertissement).
641
+ */
642
+ onPlatformNotice(notify) {
643
+ this.#notice = notify;
644
+ }
645
+ /**
646
+ * Émet un avertissement de plateforme, **une seule fois par motif**.
647
+ *
648
+ * @param key - motif (clé de déduplication), pas le texte.
649
+ * @param message - ce que lira l'exploitant.
650
+ * @param severity - niveau de journal.
651
+ */
652
+ #notifyOnce(key, message, severity = "WARNING") {
653
+ if (this.#notice === null) return;
654
+ const seen = this.#noticed ??= /* @__PURE__ */ new Set();
655
+ if (seen.has(key)) return;
656
+ seen.add(key);
657
+ this.#notice(message, severity);
658
+ }
659
+ /**
660
+ * Ce canal serait-il fermé par le plancher système ? Lecture PURE (ni compteur,
661
+ * ni alerte) — elle sert à expliquer un refus au client plutôt qu'à le décider.
662
+ *
663
+ * Sans elle, un abonnement refusé resterait sans réponse : le client attendrait
664
+ * indéfiniment des données qui ne viendront pas, ce qui est exactement la
665
+ * dégradation muette que ce plancher est censé faire disparaître.
666
+ */
667
+ isClosedBySystemFloor(channel) {
668
+ return this.#frameAuthorizer === null && isReservedSystemChannel(channel);
669
+ }
670
+ /**
671
+ * F1 (revue 0.6) — des politiques de canal sont-elles DÉCLARÉES (`@RealtimeChannel`
672
+ * avec `roles`/`scopes`/`authenticated`) sans `frameAuthorizer` pour les faire
673
+ * respecter ? `true` = **dégradation silencieuse** : un canal se croit gardé mais
674
+ * aucun décideur n'est câblé → un subscribe passe non gardé. Vrai uniquement sans
675
+ * `@nodefony/security` (ou toutes les zones en `realtime: false`). Lu par le
676
+ * RealtimeController pour émettre un WARNING au boot (fail-loud, jamais silencieux).
677
+ */
678
+ hasUnenforcedChannelPolicies() {
679
+ return this.hasChannelPolicies() && this.#frameAuthorizer === null;
680
+ }
681
+ /**
682
+ * **Seam #1b** — déclare la politique d'autorisation d'un canal/méthode
683
+ * (`@RealtimeChannel`/`@RealtimeInbound` avec opts). Appelé au handshake par
684
+ * le controller pour CHAQUE canal décoré (idempotent : même nom = écrase, les
685
+ * controllers d'un même endpoint déclarent la même policy). Cold path.
686
+ *
687
+ * Deux formes de nom, et la seconde garde une FAMILLE :
688
+ *
689
+ * - un nom **exact** (`chat:room`) — lecture O(1) au `subscribe` ;
690
+ * - un **motif** (`chat:room:*`) — il couvre tout canal dérivé servi par une
691
+ * fabrique dynamique (`chat:room:1000`), ce qu'un nom exact ne faisait pas.
692
+ * Le `*` remplace n'importe quelle suite de caractères ; tout le reste du
693
+ * motif est littéral (échappé), y compris `.` et `(`.
694
+ *
695
+ * Précédence : le nom **exact** l'emporte toujours — c'est ainsi qu'on ouvre
696
+ * une exception dans une famille gardée. Entre deux motifs, le plus
697
+ * **spécifique** gagne (littéral le plus long).
698
+ *
699
+ * @param name - nom exact du canal (subscribe), de la méthode inbound, ou
700
+ * MOTIF de famille s'il contient `*`.
701
+ * @param policy - exigences ({@link IChannelPolicy}) lues par security.
702
+ */
703
+ registerChannelPolicy(name, policy) {
704
+ if (isReservedSystemChannel(name)) this.#notifyOnce("reserved-policy", `le canal "${name}" est déclaré dans un namespace RÉSERVÉ à la plateforme (${RESERVED_SYSTEM_PREFIXES.join(" ")}) : le plancher système s'y applique et exigera un rôle d'administration, quelle que soit la politique déclarée ici. Renommer le canal avec un préfixe applicatif si ce n'est pas voulu.`);
705
+ if (name.includes("*")) {
706
+ this.#registerChannelPolicyPattern(name, policy);
707
+ return;
708
+ }
709
+ (this.#channelPolicies ??= /* @__PURE__ */ new Map()).set(name, policy);
710
+ }
711
+ /**
712
+ * Nombre maximum de `*` dans un motif.
713
+ *
714
+ * Ce n'est pas une limite de confort : chaque `*` devient un `.*` dans
715
+ * l'expression compilée, et plusieurs `.*` sur une chaîne qui NE matche pas
716
+ * font revenir le moteur sur ses pas — un coût qui explose avec leur nombre.
717
+ * Un motif de canal en demande un, deux au pire ; au-delà, c'est une erreur
718
+ * de déclaration, et on le DIT plutôt que d'exposer un chemin coûteux à ce
719
+ * qu'un client peut nommer.
720
+ */
721
+ static MAX_PATTERN_WILDCARDS = 3;
722
+ /**
723
+ * Compile un motif et l'insère à son rang de spécificité.
724
+ *
725
+ * Le motif est ÉCHAPPÉ intégralement avant que `*` ne devienne `.*` : rien de
726
+ * ce que l'auteur écrit n'est interprété comme une expression régulière. Un
727
+ * `.` reste un point, un `(` reste une parenthèse — sans quoi une déclaration
728
+ * anodine deviendrait une expression, avec ce que cela ouvre.
729
+ */
730
+ #registerChannelPolicyPattern(source, policy) {
731
+ const etoiles = source.split("*").length - 1;
732
+ if (etoiles > RealtimeHub.MAX_PATTERN_WILDCARDS) {
733
+ this.#notifyOnce(`pattern-too-broad:${source}`, `le motif de politique "${source}" porte ${etoiles} caractères « * » (maximum ${RealtimeHub.MAX_PATTERN_WILDCARDS}) : il est IGNORÉ. Un motif de canal en demande un, deux au pire — au-delà, la mise en correspondance coûte cher sur des noms que le client choisit.`);
734
+ return;
735
+ }
736
+ const echappe = source.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
737
+ const re = new RegExp(`^${echappe.split("\\*").join(".*")}$`, "u");
738
+ const weight = source.split("*").join("").length;
739
+ const policies = this.#channelPolicyPatterns ??= [];
740
+ const existing = policies.findIndex((p) => p.source === source);
741
+ const policyEntry = {
742
+ source,
743
+ re,
744
+ weight,
745
+ policy
746
+ };
747
+ if (existing >= 0) {
748
+ policies[existing] = policyEntry;
749
+ return;
750
+ }
751
+ const rang = policies.findIndex((p) => p.weight < weight);
752
+ if (rang < 0) policies.push(policyEntry);
753
+ else policies.splice(rang, 0, policyEntry);
754
+ }
755
+ /**
756
+ * Politique déclarée pour ce canal/méthode, ou `null` si aucune. Lu par
757
+ * `@nodefony/security` (`resolveChannelPolicy` de la surface DI) au moment de
758
+ * la frame `subscribe`/inbound. O(1), 0 alloc quand le registre est vide.
759
+ */
760
+ resolveChannelPolicy(name) {
761
+ const exact = this.#channelPolicies?.get(name);
762
+ if (exact !== void 0) return exact;
763
+ const patterns = this.#channelPolicyPatterns;
764
+ if (patterns === null) return null;
765
+ for (let i = 0; i < patterns.length; i++) if (patterns[i].re.test(name)) return patterns[i].policy;
766
+ return null;
767
+ }
768
+ /** Des politiques de canal sont-elles déclarées (au moins une) ? */
769
+ hasChannelPolicies() {
770
+ return (this.#channelPolicies?.size ?? 0) > 0 || (this.#channelPolicyPatterns?.length ?? 0) > 0;
771
+ }
772
+ /**
773
+ * Avertit — **une seule fois** — qu'un canal servi par une fabrique
774
+ * DYNAMIQUE n'est couvert par aucune politique déclarée, alors que d'autres
775
+ * canaux du module en portent une.
776
+ *
777
+ * Le registre des politiques est indexé par nom **EXACT** : `@RealtimeChannel`
778
+ * déclare `"chat:room"`, mais un canal dérivé (`chat:room:1000` — cadence,
779
+ * forage, suffixe) est servi par la fabrique dynamique et **ne matche pas**.
780
+ * L'auteur croit alors son canal gardé ; il ne l'est pas, et rien ne le lui
781
+ * dit. C'est ce silence qu'on ferme ici — et depuis qu'une politique peut se
782
+ * déclarer par MOTIF (`chat:room:*`), cet avertissement ne parle plus que des
783
+ * canaux qu'aucun motif ne couvre : il désigne un vrai trou, pas une limite
784
+ * de l'outil.
785
+ *
786
+ * Appelé par le contrôleur, seul à savoir par quelle voie le canal a été
787
+ * servi. Muet si aucune politique n'est déclarée (module aux canaux libres :
788
+ * il n'y a alors aucune promesse à trahir).
789
+ *
790
+ * @param channel - le canal servi dynamiquement, cité en exemple dans l'alerte.
791
+ */
792
+ noticeUnguardedDynamicChannel(channel) {
793
+ if (!this.hasChannelPolicies()) return;
794
+ if (this.resolveChannelPolicy(channel) !== null) return;
795
+ this.#notifyOnce("unguarded-dynamic-channel", `le canal "${channel}" est servi par une fabrique DYNAMIQUE et n'est couvert par AUCUNE politique déclarée, alors que ce module en déclare pour d'autres canaux. Un canal dérivé (suffixe de cadence, forage, identifiant) n'hérite pas de la politique de son parent : un nom exact ne garde que lui-même. Déclarer un MOTIF pour garder la famille — @RealtimeChannel("${channel.replace(/[^:]*$/u, "*")}", { … }) — ou porter la vérification dans la fabrique elle-même.`);
796
+ }
797
+ /**
798
+ * Enregistre la **factory d'un canal système** (plateforme) — un module bas
799
+ * niveau (ex. `@nodefony/security` : `nodefony:audit`) déclare au boot comment
800
+ * produire le provider d'un canal, SANS qu'aucun `RealtimeController` ne le
801
+ * connaisse. `subscribe` consulte ce registre quand la factory du controller
802
+ * renvoie `null` → le canal devient servable par TOUT endpoint (présent/futur),
803
+ * zéro couplage à Studio. La factory garde la sémantique lazy du hub (créée au
804
+ * 1ᵉʳ abonné, `dispose` au dernier).
805
+ *
806
+ * @param channel - nom EXACT du canal système (match exact, pas un préfixe).
807
+ * @param factory - {@link ChannelFactory} qui crée le provider partagé.
808
+ */
809
+ registerSystemChannel(channel, factory) {
810
+ (this.#systemChannelFactories ??= /* @__PURE__ */ new Map()).set(channel, factory);
811
+ }
812
+ /**
813
+ * Pont hot-path entre `JsonRpcPeer.beforeDispatch` et le verrou métier : lit
814
+ * le token déjà caché du peer (O(1), 0 lecture base) puis délègue à
815
+ * l'authorizer. `true` (autorisée) si aucun verrou posé (bypass 0-coût) —
816
+ * mais en pratique `beforeDispatch` n'est branché que si `hasFrameAuthorizer()`.
817
+ *
818
+ * @param frame - frame JSON-RPC entrante (brute) à autoriser.
819
+ * @param peer - peer émetteur (clé du mapping `peer → token`).
820
+ * @returns `true` si la frame peut être dispatchée, `false` si refusée.
821
+ */
822
+ /**
823
+ * Ouvre (ou referme) la réception des journaux du navigateur, avec ses bornes.
824
+ *
825
+ * @param limits - bornes de débit et de taille, ou `null` pour refermer le canal.
826
+ */
827
+ setClientLogsLimits(limits) {
828
+ this.#clientLogs = limits;
829
+ }
830
+ /** Bornes en vigueur, ou `null` si le canal montant est fermé. */
831
+ get clientLogsLimits() {
832
+ return this.#clientLogs;
833
+ }
834
+ runAuthorizer(frame, peer) {
835
+ if (this.#frameAuthorizer === null) return true;
836
+ return this.#frameAuthorizer(frame, this.getTokenForPeer(peer));
837
+ }
838
+ /**
839
+ * Ce peer pourrait-il s'abonner à ce canal ? — même décision que `subscribe`,
840
+ * sans trace ni effet de bord.
841
+ *
842
+ * Rend `true` quand aucune sonde n'est posée : sans sécurité chargée, aucun
843
+ * canal n'est refusé, et l'absence de sonde ne doit jamais faire DISPARAÎTRE
844
+ * des canaux d'un welcome. Un filtre qui échoue doit échouer ouvert ici —
845
+ * c'est le verrou, pas l'annonce, qui protège l'accès.
846
+ *
847
+ * @param channel - le canal interrogé.
848
+ * @param peer - la connexion, d'où l'on tire le jeton résolu au handshake.
849
+ * @returns `true` si un `subscribe` sur ce canal serait accepté.
850
+ */
851
+ probeChannel(channel, peer) {
852
+ if (this.#channelProbe === null) return true;
853
+ return this.#channelProbe({
854
+ method: "subscribe",
855
+ params: { channel }
856
+ }, this.getTokenForPeer(peer));
857
+ }
858
+ /** Authenticators enregistrés (debug / tests). Lecture seule. */
859
+ get registeredAuthenticators() {
860
+ if (this.#authenticators === null) return EMPTY_AUTH_LIST;
861
+ return this.#authenticators.map((e) => e.authenticator);
862
+ }
863
+ /** Dispose tous les providers et vide le hub (arrêt process / reset de test). */
864
+ clear() {
865
+ if (this.#channels) {
866
+ for (const st of this.#channels.values()) try {
867
+ st.dispose?.();
868
+ } catch {}
869
+ this.#channels.clear();
870
+ this.#channels = null;
871
+ }
872
+ this.#connections?.clear();
873
+ this.#connections = null;
874
+ this.#broadcastPrefixes = null;
875
+ this.#authenticators = null;
876
+ this.#peerTokens = null;
877
+ this.#frameAuthorizer = null;
878
+ this.#channelPolicies?.clear();
879
+ this.#channelPolicies = null;
880
+ this.#channelPolicyPatterns = null;
881
+ this.#originGuard = null;
882
+ this.#backplane = null;
883
+ this.#publishTotal = 0;
884
+ this.#fanoutTotal = 0;
885
+ this.#inboundTotal = 0;
886
+ this.#ingressRejectedTotal = 0;
887
+ this.#systemFloorDeniedTotal = 0;
888
+ this.#noticed = null;
889
+ this.#notice = null;
890
+ }
891
+ };
892
+ const EMPTY_AUTH_LIST = Object.freeze([]);
893
+ /**
894
+ * Compile un matcher utilisateur en forme efficace (RegExp précompilée + host
895
+ * lowercase). Cold path (1× par `useAuthenticator`).
896
+ *
897
+ * - `pattern` RegExp : utilisée telle quelle (l'utilisateur sait ce qu'il fait).
898
+ * - `pattern` string : compilée en RegExp **préfixe** ancrée (`^<escaped>`).
899
+ * Compromis sûr : `"/admin/"` matche `/admin/` ET `/admin/foo` SANS escape
900
+ * à la main (90 % des cas userland), tout en restant déterministe pour les
901
+ * tests.
902
+ */
903
+ function compileMatcher(m) {
904
+ const pattern = m.pattern instanceof RegExp ? m.pattern : new RegExp("^" + RegExp.escape(m.pattern));
905
+ const host = m.host?.toLowerCase();
906
+ return {
907
+ pattern,
908
+ host,
909
+ match(handshake) {
910
+ if (host !== void 0) {
911
+ const raw = handshake.headers["host"];
912
+ if (((Array.isArray(raw) ? raw[0] : raw) ?? "").toLowerCase() !== host) return false;
913
+ }
914
+ const qi = handshake.url.indexOf("?");
915
+ const path = qi === -1 ? handshake.url : handshake.url.slice(0, qi);
916
+ return pattern.test(path);
917
+ }
918
+ };
919
+ }
920
+ let _hub = null;
921
+ /** Renvoie le hub realtime du process (singleton lazy). */
922
+ function getRealtimeHub() {
923
+ return _hub ??= new RealtimeHub();
924
+ }
925
+ //#endregion
926
+ export { RESERVED_SYSTEM_PREFIXES, REVOCATION_REVALIDATE_MS, RealtimeHub, RealtimeHub as default, SLOW_CONSUMER_BYTES, getRealtimeHub, isReservedSystemChannel };