@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.
- package/LICENSE +544 -0
- package/README.md +207 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/index.js +218 -0
- package/dist/nodefony/config/config.js +63 -0
- package/dist/nodefony/config/defineModuleConfig.js +60 -0
- package/dist/nodefony/decorators/realtimeDecorators.js +240 -0
- package/dist/nodefony/interfaces/IBackplane.js +1 -0
- package/dist/nodefony/interfaces/IChannelPolicy.js +1 -0
- package/dist/nodefony/interfaces/IClientLogsLimits.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeAuthenticator.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeAuthenticatorMatcher.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeController.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeHandshake.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeProbe.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeToken.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/src/backplane/ClusterBackplane.js +105 -0
- package/dist/nodefony/src/backplane/LoopbackBackplane.js +40 -0
- package/dist/nodefony/src/backplane/RedisBackplane.js +176 -0
- package/dist/nodefony/src/backplane/backplaneRegistry.js +20 -0
- package/dist/nodefony/src/backplane/envelope.js +109 -0
- package/dist/nodefony/src/backplane/originId.js +35 -0
- package/dist/nodefony/src/backplane/publishQueue.js +96 -0
- package/dist/nodefony/src/cluster/ClusterProbeClient.js +284 -0
- package/dist/nodefony/src/server/AnonymousRealtimeToken.js +35 -0
- package/dist/nodefony/src/server/RealtimeAdminApi.js +87 -0
- package/dist/nodefony/src/server/RealtimeController.js +601 -0
- package/dist/nodefony/src/server/RealtimeHub.js +926 -0
- package/dist/nodefony/src/server/ServerRealtimeSocket.js +168 -0
- package/dist/nodefony/src/server/deniedDetail.js +29 -0
- package/dist/nodefony/src/server/syslogUplink.js +105 -0
- package/dist/nodefony/src/server/welcomeEnv.js +25 -0
- package/dist/nodefony/src/service/RealtimeService.js +240 -0
- package/dist/nodefony/src/transport/WsConnectionTransport.js +94 -0
- package/dist/nodefony/testing/index.js +163 -0
- package/dist/types/index.d.ts +99 -0
- package/dist/types/nodefony/config/config.d.ts +43 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +49 -0
- package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +190 -0
- package/dist/types/nodefony/interfaces/IBackplane.d.ts +128 -0
- package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +28 -0
- package/dist/types/nodefony/interfaces/IClientLogsLimits.d.ts +22 -0
- package/dist/types/nodefony/interfaces/IRealtimeAuthenticator.d.ts +53 -0
- package/dist/types/nodefony/interfaces/IRealtimeAuthenticatorMatcher.d.ts +46 -0
- package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +36 -0
- package/dist/types/nodefony/interfaces/IRealtimeHandshake.d.ts +27 -0
- package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +206 -0
- package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +48 -0
- package/dist/types/nodefony/interfaces/index.d.ts +18 -0
- package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +76 -0
- package/dist/types/nodefony/src/backplane/LoopbackBackplane.d.ts +29 -0
- package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +164 -0
- package/dist/types/nodefony/src/backplane/backplaneRegistry.d.ts +51 -0
- package/dist/types/nodefony/src/backplane/envelope.d.ts +27 -0
- package/dist/types/nodefony/src/backplane/originId.d.ts +22 -0
- package/dist/types/nodefony/src/backplane/publishQueue.d.ts +71 -0
- package/dist/types/nodefony/src/cluster/ClusterProbeClient.d.ts +90 -0
- package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +3 -0
- package/dist/types/nodefony/src/server/RealtimeAdminApi.d.ts +18 -0
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +165 -0
- package/dist/types/nodefony/src/server/RealtimeHub.d.ts +516 -0
- package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +63 -0
- package/dist/types/nodefony/src/server/deniedDetail.d.ts +25 -0
- package/dist/types/nodefony/src/server/syslogUplink.d.ts +58 -0
- package/dist/types/nodefony/src/server/welcomeEnv.d.ts +21 -0
- package/dist/types/nodefony/src/service/RealtimeService.d.ts +167 -0
- package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +78 -0
- package/dist/types/nodefony/testing/index.d.ts +169 -0
- package/docs/actions.md +573 -0
- package/docs/architecture.md +889 -0
- package/docs/configuration.md +803 -0
- package/docs/cookbook-chat.md +1619 -0
- package/docs/couverture-bancs.md +139 -0
- package/docs/index.md +372 -0
- package/docs/observabilite.md +592 -0
- package/docs/protocole.md +518 -0
- package/docs/securite.md +845 -0
- package/docs/vocabulaire.md +845 -0
- 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 };
|