@nodefony/realtime 10.0.0-beta.5 → 10.0.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
- package/dist/index.js +79 -12
- package/dist/nodefony/config/config.js +7 -0
- package/dist/nodefony/decorators/realtimeDecorators.js +125 -19
- package/dist/nodefony/interfaces/IRealtimeConnection.js +1 -0
- package/dist/nodefony/src/backplane/ClusterBackplane.js +32 -4
- package/dist/nodefony/src/backplane/RedisBackplane.js +143 -31
- package/dist/nodefony/src/backplane/controlOrder.js +57 -0
- package/dist/nodefony/src/backplane/envelope.js +113 -12
- package/dist/nodefony/src/cluster/ClusterProbeClient.js +2 -0
- package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
- package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
- package/dist/nodefony/src/server/RealtimeConnection.js +110 -0
- package/dist/nodefony/src/server/RealtimeController.js +340 -81
- package/dist/nodefony/src/server/RealtimeHub.js +604 -61
- package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
- package/dist/nodefony/src/server/admissionBudget.js +176 -0
- package/dist/nodefony/src/server/channelPolicyRegistry.js +73 -0
- package/dist/nodefony/src/server/connectionAdmission.js +242 -0
- package/dist/nodefony/src/server/contractRoutes.js +85 -0
- package/dist/nodefony/src/server/deniedDetail.js +10 -3
- package/dist/nodefony/src/server/frameAdmission.js +168 -0
- package/dist/nodefony/src/server/subscribedAck.js +10 -1
- package/dist/nodefony/src/server/syslogUplink.js +3 -4
- package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
- package/dist/nodefony/src/service/RealtimeService.js +42 -1
- package/dist/nodefony/src/service/publicOrigin.js +40 -0
- package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
- package/dist/nodefony/testing/backplaneConformance.js +271 -0
- package/dist/nodefony/testing/index.js +21 -4
- package/dist/types/index.d.ts +34 -7
- package/dist/types/nodefony/config/config.d.ts +9 -0
- package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +86 -6
- package/dist/types/nodefony/interfaces/IBackplane.d.ts +34 -50
- package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
- package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
- package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
- package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
- package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
- package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
- package/dist/types/nodefony/interfaces/index.d.ts +2 -1
- package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
- package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +79 -16
- package/dist/types/nodefony/src/backplane/controlOrder.d.ts +52 -0
- package/dist/types/nodefony/src/backplane/envelope.d.ts +34 -0
- package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +10 -0
- package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
- package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +17 -0
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +91 -11
- package/dist/types/nodefony/src/server/RealtimeHub.d.ts +321 -24
- package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
- package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
- package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +34 -0
- package/dist/types/nodefony/src/server/connectionAdmission.d.ts +68 -0
- package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
- package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
- package/dist/types/nodefony/src/server/frameAdmission.d.ts +70 -0
- package/dist/types/nodefony/src/server/subscribedAck.d.ts +7 -0
- package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
- package/dist/types/nodefony/src/service/RealtimeService.d.ts +10 -2
- package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
- package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
- package/dist/types/nodefony/testing/backplaneConformance.d.ts +66 -0
- package/dist/types/nodefony/testing/index.d.ts +12 -1
- package/docs/actions.md +419 -18
- package/docs/architecture.md +83 -8
- package/docs/cadence-et-contre-pression.md +87 -11
- package/docs/canaux-a-membres.md +267 -0
- package/docs/cas-d-usage.md +1702 -0
- package/docs/configuration.md +137 -17
- package/docs/cookbook-chat.md +314 -85
- package/docs/index.md +11 -1
- package/docs/observabilite.md +71 -14
- package/docs/protocole.md +133 -24
- package/docs/securite.md +284 -32
- package/docs/vocabulaire.md +13 -12
- package/package.json +14 -11
|
@@ -1,6 +1,12 @@
|
|
|
1
|
+
import { RealtimeConnection } from "./RealtimeConnection.js";
|
|
2
|
+
import { AdmissionBudget } from "./admissionBudget.js";
|
|
3
|
+
import { dropRetainedVerdicts } from "./frameAdmission.js";
|
|
4
|
+
import { isAdmissionClass } from "../../decorators/realtimeDecorators.js";
|
|
1
5
|
import { LoopbackBackplane } from "../backplane/LoopbackBackplane.js";
|
|
6
|
+
import { readControlOrder } from "../backplane/controlOrder.js";
|
|
2
7
|
import { ANONYMOUS_REALTIME_TOKEN } from "./AnonymousRealtimeToken.js";
|
|
3
|
-
import { IDENTITY_REFRESH_CLOSE_CODE, NODEFONY_CHANNEL_NAMESPACE, REVOCATION_REVALIDATE_MS, RevocationWatch, isPlatformChannel } from "nodefony";
|
|
8
|
+
import { BootConfigurationError, IDENTITY_DEAD_CLOSE_CODE, IDENTITY_REFRESH_CLOSE_CODE, MAX_CHANNEL_PATTERN_WILDCARDS, NODEFONY_CHANNEL_NAMESPACE, PLATFORM_BUS_CHANNELS, REVOCATION_REVALIDATE_MS, RevocationWatch, TOKEN_EXPIRED_CLOSE_CODE, USER_CHANNEL_PREFIX, compileChannelPattern, containsBytes, countWildcards, frameCodecSet, isOwnUserChannel, isPlatformChannel, isRealtimePayload, realtimePayloadError, userChannel } from "nodefony";
|
|
9
|
+
import { frameRateRejectedTotal } from "@nodefony/http";
|
|
4
10
|
// nodefony/src/server/RealtimeHub.ts
|
|
5
11
|
/**
|
|
6
12
|
* Seuil d'alerte slow-consumer (octets de `bufferedAmount`). Au-delà, la connexion
|
|
@@ -64,6 +70,132 @@ function channelFamily(channel) {
|
|
|
64
70
|
return i === -1 ? "*" : `${channel.slice(0, i + 1)}*`;
|
|
65
71
|
}
|
|
66
72
|
/**
|
|
73
|
+
* Frame d'UNE publication diffusée à plusieurs abonnés, encodée **au plus une fois
|
|
74
|
+
* par codec** (ADR-0022 §2) : un canal suivi par des connexions texte et CBOR paie
|
|
75
|
+
* deux encodages, jamais un par connexion. L'encodage est paresseux — un codec
|
|
76
|
+
* qu'aucun abonné n'utilise ne coûte rien.
|
|
77
|
+
*
|
|
78
|
+
* Deux places suffisent : un hub sert au plus son codec texte et son codec
|
|
79
|
+
* binaire. Un troisième codec reste servi, sans mise en cache.
|
|
80
|
+
*/
|
|
81
|
+
var ChannelFrame = class {
|
|
82
|
+
message;
|
|
83
|
+
codecA;
|
|
84
|
+
dataA;
|
|
85
|
+
codecB;
|
|
86
|
+
dataB;
|
|
87
|
+
claimedA;
|
|
88
|
+
claimedB;
|
|
89
|
+
/** @param message - la frame protocolaire, telle que rendue par le {@link ChannelFrameBuilder}. */
|
|
90
|
+
constructor(message) {
|
|
91
|
+
this.message = message;
|
|
92
|
+
this.codecA = null;
|
|
93
|
+
this.dataA = void 0;
|
|
94
|
+
this.codecB = null;
|
|
95
|
+
this.dataB = void 0;
|
|
96
|
+
this.claimedA = false;
|
|
97
|
+
this.claimedB = false;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Rend la frame encodée pour un codec, en l'encodant à la première demande.
|
|
101
|
+
*
|
|
102
|
+
* @param codec - le codec de la connexion qui va l'écrire.
|
|
103
|
+
* @returns la trame à écrire telle quelle, ou `undefined` si la frame n'est pas
|
|
104
|
+
* encodable : l'abonné qui en {@link ChannelFrame.claimFailure | réclame}
|
|
105
|
+
* l'échec reprend son chemin habituel, qui porte le filet (journal).
|
|
106
|
+
*/
|
|
107
|
+
encodeFor(codec) {
|
|
108
|
+
if (codec === this.codecA) return this.dataA;
|
|
109
|
+
if (codec === this.codecB) return this.dataB;
|
|
110
|
+
let data;
|
|
111
|
+
try {
|
|
112
|
+
data = codec.encode(this.message);
|
|
113
|
+
} catch {
|
|
114
|
+
data = void 0;
|
|
115
|
+
}
|
|
116
|
+
if (this.codecA === null) {
|
|
117
|
+
this.codecA = codec;
|
|
118
|
+
this.dataA = data;
|
|
119
|
+
} else if (this.codecB === null) {
|
|
120
|
+
this.codecB = codec;
|
|
121
|
+
this.dataB = data;
|
|
122
|
+
}
|
|
123
|
+
return data;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Réclame l'échec d'encodage de cette frame pour un codec : `true` au PREMIER
|
|
127
|
+
* abonné qui le demande, `false` ensuite.
|
|
128
|
+
*
|
|
129
|
+
* Un seul abonné reprend alors son chemin habituel, dont le filet journalise
|
|
130
|
+
* l'échec ; les autres sautent la trame, qu'ils ne pourraient pas encoder non
|
|
131
|
+
* plus. Sans cela, une charge non encodable — un `$bytes` forgé par un client,
|
|
132
|
+
* une valeur cyclique — coûtait un encodage et une ligne de journal PAR abonné.
|
|
133
|
+
* Un codec hors des deux places réclame à chaque fois : il n'est pas retenu.
|
|
134
|
+
*
|
|
135
|
+
* @param codec - le codec dont {@link ChannelFrame.encodeFor} a rendu `undefined`.
|
|
136
|
+
* @returns `true` si l'appelant doit dire l'échec.
|
|
137
|
+
*/
|
|
138
|
+
claimFailure(codec) {
|
|
139
|
+
if (codec === this.codecA) {
|
|
140
|
+
if (this.claimedA) return false;
|
|
141
|
+
this.claimedA = true;
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
if (codec === this.codecB) {
|
|
145
|
+
if (this.claimedB) return false;
|
|
146
|
+
this.claimedB = true;
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
/**
|
|
153
|
+
* Règle d'admission prête à appeler d'une politique, ou `null` sans `allow`.
|
|
154
|
+
*
|
|
155
|
+
* Une flèche se retient telle quelle. Une classe exige son instance — résolue au
|
|
156
|
+
* démarrage, jamais ici : le hub n'a pas d'injecteur, et résoudre à la première
|
|
157
|
+
* poignée de main laisserait sans politique un exemplaire qui n'émet que des
|
|
158
|
+
* webhooks. La méthode est liée une fois, et la règle se construit une fois :
|
|
159
|
+
* le `subscribe` n'alloue rien et n'appelle qu'une fonction.
|
|
160
|
+
*/
|
|
161
|
+
function admissionRuleOf(name, policy, admission) {
|
|
162
|
+
const allow = policy.allow;
|
|
163
|
+
if (allow === void 0) return null;
|
|
164
|
+
const anonymous = policy.anonymous === true;
|
|
165
|
+
if (!isAdmissionClass(allow)) return {
|
|
166
|
+
allow,
|
|
167
|
+
anonymous
|
|
168
|
+
};
|
|
169
|
+
if (!(admission instanceof allow)) throw new BootConfigurationError(`canal temps réel « ${name} » : la classe d'admission ${allow.name} n'a pas été résolue au démarrage du noyau. Son contrôleur doit être déclaré par @controllers dans un module : c'est là que le framework l'inscrit et résout ses dépendances.`);
|
|
170
|
+
return {
|
|
171
|
+
allow: admission.allow.bind(admission),
|
|
172
|
+
anonymous
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Politique INTÉGRÉE du canal personnel `user:<id>` (ADR-0022 §6, D1) — connexion authentifiée, admise sur SON canal seulement.
|
|
177
|
+
*
|
|
178
|
+
* Constante de module : elle ne s'inscrit nulle part, donc aucun `clear()`,
|
|
179
|
+
* aucun ordre de démarrage, aucun oubli ne peut la retirer. Elle sert de REPLI :
|
|
180
|
+
* une politique que l'application écrit sur `user:…` (nom exact ou motif)
|
|
181
|
+
* l'emporte, comme toute déclaration explicite.
|
|
182
|
+
*/
|
|
183
|
+
const USER_CHANNEL_POLICY = Object.freeze({
|
|
184
|
+
policy: Object.freeze({
|
|
185
|
+
authenticated: true,
|
|
186
|
+
allow: isOwnUserChannel
|
|
187
|
+
}),
|
|
188
|
+
admission: Object.freeze({
|
|
189
|
+
allow: isOwnUserChannel,
|
|
190
|
+
anonymous: false
|
|
191
|
+
})
|
|
192
|
+
});
|
|
193
|
+
/**
|
|
194
|
+
* Fabrique intégrée du canal personnel : il n'a pas de source, seul `publish`
|
|
195
|
+
* l'alimente. Partagée par tous les canaux `user:` — aucune allocation par canal.
|
|
196
|
+
*/
|
|
197
|
+
const USER_CHANNEL_DISPOSE = () => {};
|
|
198
|
+
/**
|
|
67
199
|
* RealtimeHub — broker temps réel **PAR INSTANCE** (1 pod = 1 process = 1 hub local).
|
|
68
200
|
*
|
|
69
201
|
* Élève le realtime serveur du modèle **per-connexion** (chaque connexion son propre
|
|
@@ -94,15 +226,22 @@ var RealtimeHub = class RealtimeHub {
|
|
|
94
226
|
#channels = null;
|
|
95
227
|
#publishTotal = 0;
|
|
96
228
|
#fanoutTotal = 0;
|
|
229
|
+
#closedBytesSent = 0;
|
|
230
|
+
#closedMessagesSent = 0;
|
|
231
|
+
#closedDropped = 0;
|
|
232
|
+
#closedBinaryDiscarded = 0;
|
|
233
|
+
#closedMalformedDiscarded = 0;
|
|
97
234
|
#inboundTotal = 0;
|
|
98
235
|
#ingressRejectedTotal = 0;
|
|
236
|
+
#localOnlyTotal = 0;
|
|
99
237
|
#systemFloorDeniedTotal = 0;
|
|
100
238
|
#notice = null;
|
|
101
239
|
#noticed = null;
|
|
102
240
|
#connections = null;
|
|
103
241
|
#revocation = new RevocationWatch({
|
|
104
242
|
isValid: (entry) => entry.token.isValid?.(),
|
|
105
|
-
|
|
243
|
+
status: (entry) => entry.token.status?.(),
|
|
244
|
+
revoke: (entry, status) => status === "expired" ? entry.close(TOKEN_EXPIRED_CLOSE_CODE, "token expired") : entry.close(IDENTITY_DEAD_CLOSE_CODE, "session revoked"),
|
|
106
245
|
identityOf: (entry) => entry.token.getUserIdentifier(),
|
|
107
246
|
refresh: (entry) => entry.close(IDENTITY_REFRESH_CLOSE_CODE, "identity changed")
|
|
108
247
|
});
|
|
@@ -111,8 +250,15 @@ var RealtimeHub = class RealtimeHub {
|
|
|
111
250
|
#peerTokens = null;
|
|
112
251
|
#frameAuthorizer = null;
|
|
113
252
|
#channelProbe = null;
|
|
253
|
+
#retainers = null;
|
|
114
254
|
#channelPolicies = null;
|
|
115
255
|
/**
|
|
256
|
+
* Classes de contrôleur dont les politiques sont déjà inscrites — `null`
|
|
257
|
+
* tant qu'aucune ne l'est. Une poignée de main n'en paie qu'un `has` :
|
|
258
|
+
* l'inscription elle-même n'a lieu qu'une fois par classe.
|
|
259
|
+
*/
|
|
260
|
+
#policyOwners = null;
|
|
261
|
+
/**
|
|
116
262
|
* Politiques déclarées par MOTIF de nom (`chat:room:*`), triées du plus
|
|
117
263
|
* spécifique au plus général, et `null` tant qu'aucune n'est déclarée.
|
|
118
264
|
*
|
|
@@ -133,6 +279,14 @@ var RealtimeHub = class RealtimeHub {
|
|
|
133
279
|
#originGuard = null;
|
|
134
280
|
#maxChannelsPerConnection = 256;
|
|
135
281
|
#slowConsumerBytes = SLOW_CONSUMER_BYTES;
|
|
282
|
+
#frameCodecs = frameCodecSet();
|
|
283
|
+
#busBytes = false;
|
|
284
|
+
/**
|
|
285
|
+
* Budget des admissions de l'exemplaire (appels d'`allow` en vol, coupe-circuit,
|
|
286
|
+
* `allowTimedOutTotal`/`allowSaturatedTotal`) — partagé par les files de toutes
|
|
287
|
+
* les connexions. Bornes posées par {@link setAdmissionLimits} au boot.
|
|
288
|
+
*/
|
|
289
|
+
admissionBudget = new AdmissionBudget();
|
|
136
290
|
#broadcastPrefixes = null;
|
|
137
291
|
/**
|
|
138
292
|
* Abonnement demandé par une **connexion cliente** — même chose que
|
|
@@ -148,9 +302,11 @@ var RealtimeHub = class RealtimeHub {
|
|
|
148
302
|
* sécurité en pose un, c'est lui qui décide (avec les rôles, ce que le hub ne
|
|
149
303
|
* sait pas faire) et le plancher s'efface.
|
|
150
304
|
*
|
|
305
|
+
* @param member - la connexion qui s'abonne : inscrite au registre du canal,
|
|
306
|
+
* elle devient un membre identifiable ({@link channelMembers}).
|
|
151
307
|
* @returns `true` si abonné, `false` si canal inconnu ou fermé par le plancher.
|
|
152
308
|
*/
|
|
153
|
-
subscribeClient(channel, sink, factory,
|
|
309
|
+
subscribeClient(channel, sink, factory, buildFrame, member) {
|
|
154
310
|
if (channel.includes("*")) {
|
|
155
311
|
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.`);
|
|
156
312
|
return false;
|
|
@@ -160,7 +316,12 @@ var RealtimeHub = class RealtimeHub {
|
|
|
160
316
|
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.`);
|
|
161
317
|
return false;
|
|
162
318
|
}
|
|
163
|
-
|
|
319
|
+
if (!this.subscribe(channel, sink, factory, buildFrame)) return false;
|
|
320
|
+
if (member !== void 0) {
|
|
321
|
+
const st = this.#channels?.get(channel);
|
|
322
|
+
if (st !== void 0) (st.members ??= /* @__PURE__ */ new Map()).set(sink, member);
|
|
323
|
+
}
|
|
324
|
+
return true;
|
|
164
325
|
}
|
|
165
326
|
/**
|
|
166
327
|
* Abonne un consommateur qui **répond du canal** : il apporte la `factory`, donc
|
|
@@ -175,13 +336,13 @@ var RealtimeHub = class RealtimeHub {
|
|
|
175
336
|
*
|
|
176
337
|
* @returns `true` si abonné, `false` si canal inconnu (`factory` a renvoyé `null`).
|
|
177
338
|
*/
|
|
178
|
-
subscribe(channel, sink, factory,
|
|
339
|
+
subscribe(channel, sink, factory, buildFrame) {
|
|
179
340
|
const channels = this.#channels ??= /* @__PURE__ */ new Map();
|
|
180
341
|
let st = channels.get(channel);
|
|
181
342
|
if (st) {
|
|
182
343
|
if (st.owned) {
|
|
183
344
|
st.sinks.add(sink);
|
|
184
|
-
if (st.
|
|
345
|
+
if (st.buildFrame === null && buildFrame !== void 0) st.buildFrame = buildFrame;
|
|
185
346
|
return true;
|
|
186
347
|
}
|
|
187
348
|
st.sinks.add(sink);
|
|
@@ -192,14 +353,15 @@ var RealtimeHub = class RealtimeHub {
|
|
|
192
353
|
}
|
|
193
354
|
st.dispose = dispose;
|
|
194
355
|
st.owned = true;
|
|
195
|
-
if (
|
|
356
|
+
if (buildFrame !== void 0) st.buildFrame = buildFrame;
|
|
196
357
|
return true;
|
|
197
358
|
}
|
|
198
359
|
st = {
|
|
199
360
|
dispose: null,
|
|
200
361
|
owned: false,
|
|
201
362
|
sinks: /* @__PURE__ */ new Set([sink]),
|
|
202
|
-
|
|
363
|
+
members: null,
|
|
364
|
+
buildFrame: buildFrame ?? null,
|
|
203
365
|
messages: 0,
|
|
204
366
|
forward: this.#isBroadcast(channel)
|
|
205
367
|
};
|
|
@@ -238,7 +400,8 @@ var RealtimeHub = class RealtimeHub {
|
|
|
238
400
|
dispose: null,
|
|
239
401
|
owned: false,
|
|
240
402
|
sinks: /* @__PURE__ */ new Set([sink]),
|
|
241
|
-
|
|
403
|
+
members: null,
|
|
404
|
+
buildFrame: null,
|
|
242
405
|
messages: 0,
|
|
243
406
|
forward: this.#isBroadcast(channel)
|
|
244
407
|
});
|
|
@@ -259,7 +422,8 @@ var RealtimeHub = class RealtimeHub {
|
|
|
259
422
|
const dispose = factory(channel, publishFn);
|
|
260
423
|
if (dispose !== null) return dispose;
|
|
261
424
|
const sys = this.#systemChannelFactories?.get(channel);
|
|
262
|
-
|
|
425
|
+
if (sys) return sys(channel, publishFn);
|
|
426
|
+
return channel.startsWith(USER_CHANNEL_PREFIX) ? USER_CHANNEL_DISPOSE : null;
|
|
263
427
|
}
|
|
264
428
|
/**
|
|
265
429
|
* Désabonne une connexion d'un canal. Au **dernier** abonné, `dispose()` le provider
|
|
@@ -270,6 +434,7 @@ var RealtimeHub = class RealtimeHub {
|
|
|
270
434
|
const st = channels?.get(channel);
|
|
271
435
|
if (!channels || !st) return;
|
|
272
436
|
st.sinks.delete(sink);
|
|
437
|
+
st.members?.delete(sink);
|
|
273
438
|
if (st.sinks.size === 0) {
|
|
274
439
|
try {
|
|
275
440
|
st.dispose?.();
|
|
@@ -278,6 +443,167 @@ var RealtimeHub = class RealtimeHub {
|
|
|
278
443
|
}
|
|
279
444
|
}
|
|
280
445
|
/**
|
|
446
|
+
* Les connexions abonnées à un canal par un client — **local à ce pod** : les membres servis par un autre exemplaire n'y figurent pas.
|
|
447
|
+
*
|
|
448
|
+
* Les écouteurs et services du serveur (`listen`, `subscribe`) ne sont pas
|
|
449
|
+
* des membres. Chemin froid : une copie à chaque appel.
|
|
450
|
+
*
|
|
451
|
+
* @param channel - nom exact du canal.
|
|
452
|
+
* @returns les membres de ce pod, vide si le canal n'en a aucun ici.
|
|
453
|
+
*/
|
|
454
|
+
channelMembers(channel) {
|
|
455
|
+
const members = this.#channels?.get(channel)?.members;
|
|
456
|
+
return members == null ? [] : Array.from(members.values());
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Abonne d'office une connexion à un canal — **local à ce pod** : seule une connexion acceptée par ce processus peut l'être.
|
|
460
|
+
*
|
|
461
|
+
* Même porte qu'un `subscribe` du client (ADR-0022 §6 décision 2) : la
|
|
462
|
+
* politique fixe du canal (rôles, scopes, plancher de plateforme), puis sa
|
|
463
|
+
* règle `allow` s'il en déclare une. Un refus n'abonne pas, et seul
|
|
464
|
+
* l'appelant l'apprend ; jamais un abonnement qui contourne `allow` — le
|
|
465
|
+
* client rejoue ses abonnements à chaque reconnexion, et ce rejeu passe par
|
|
466
|
+
* `allow` : l'application écrit donc l'appartenance là où `allow` la lit.
|
|
467
|
+
*
|
|
468
|
+
* Le client est notifié par `realtime:subscribed { channel, imposed: true }`
|
|
469
|
+
* et inscrit le canal dans ses abonnements ; il peut le quitter.
|
|
470
|
+
*
|
|
471
|
+
* Plusieurs exemplaires : la requête qui décide doit atteindre le processus
|
|
472
|
+
* qui tient la socket — la passer par la socket elle-même (`@RealtimeInbound`,
|
|
473
|
+
* qui reçoit la connexion), ou faire autoriser l'entrée par `allow` (registre
|
|
474
|
+
* partagé) et laisser le navigateur s'abonner.
|
|
475
|
+
*
|
|
476
|
+
* @param channel - nom exact du canal.
|
|
477
|
+
* @param connection - la connexion à abonner (reçue par un gestionnaire `@RealtimeInbound`, ou lue dans {@link channelMembers}).
|
|
478
|
+
* @returns `true` une fois l'abonnement acquitté ; `false` si refusé, si la
|
|
479
|
+
* connexion est fermée ou inconnue de ce processus, ou si le client s'en
|
|
480
|
+
* désabonne avant le verdict.
|
|
481
|
+
*/
|
|
482
|
+
subscribeConnection(channel, connection) {
|
|
483
|
+
if (!(connection instanceof RealtimeConnection)) return Promise.resolve(false);
|
|
484
|
+
return connection.impose(channel);
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Retire une connexion d'un canal — **local à ce pod** ; elle reçoit `realtime:closed { reason: "evicted" }`.
|
|
488
|
+
*
|
|
489
|
+
* ⚠️ **Éjecter coupe la connexion du canal, seul `allow` exclut**
|
|
490
|
+
* (ADR-0022 §6 décision 3). Le client ne rejoue pas un canal dont il a été
|
|
491
|
+
* éjecté, mais c'est une politesse : une page rechargée refait `subscribe`.
|
|
492
|
+
* Sur un canal à `allow`, l'éjecté revient si l'application n'a pas retiré
|
|
493
|
+
* l'appartenance là où `allow` la lit ; sur un canal sans `allow`, sa
|
|
494
|
+
* politique fixe le laisse rentrer.
|
|
495
|
+
*
|
|
496
|
+
* @param channel - nom exact du canal.
|
|
497
|
+
* @param connection - la connexion à retirer.
|
|
498
|
+
* @returns `false` si la connexion n'est pas membre du canal, est fermée, ou
|
|
499
|
+
* inconnue de ce processus — jamais un geste muet.
|
|
500
|
+
*/
|
|
501
|
+
evictConnection(channel, connection) {
|
|
502
|
+
return connection instanceof RealtimeConnection && connection.end(channel, "evicted");
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Termine un canal pour tous ses membres, **sur tous les serveurs** : chacun reçoit `realtime:closed { reason: "ended" }`, et le producteur est libéré.
|
|
506
|
+
*
|
|
507
|
+
* **Traverse les pods** : avec un backplane branché, l'ordre part sur le bus
|
|
508
|
+
* (`PLATFORM_BUS_CHANNELS.control`, scellé comme toute publication) et chaque
|
|
509
|
+
* serveur l'applique à SES membres. Livraison « au plus une fois » du bus :
|
|
510
|
+
* un pod qui manque l'ordre, ou qui démarre après, est rattrapé par la
|
|
511
|
+
* fabrique, qui rend `null` pour une instance terminée. Un canal de la
|
|
512
|
+
* plateforme (`nodefony:`) reste local — il décrit un pod, pas une partie.
|
|
513
|
+
*
|
|
514
|
+
* Les écouteurs du serveur (`listen`) restent inscrits, sur un canal redevenu
|
|
515
|
+
* PASSIF : le prochain `subscribe` d'un client rejoue la fabrique, et c'est
|
|
516
|
+
* elle qui tranche. **Seule la fabrique clôt un canal** (ADR-0021 S4) — le
|
|
517
|
+
* hub ne garde aucune marque « fermé » : une fabrique qui rend encore un
|
|
518
|
+
* producteur pour ce nom le rouvre. D'où la règle d'écriture d'un canal à
|
|
519
|
+
* durée de vie : son nom porte l'identifiant de l'instance
|
|
520
|
+
* (`room:42:<partie>`), et la fabrique rend `null` pour une instance terminée.
|
|
521
|
+
*
|
|
522
|
+
* @param channel - nom exact du canal.
|
|
523
|
+
* @returns le nombre de membres de CE pod à qui la fin a été notifiée — les
|
|
524
|
+
* autres serveurs l'appliquent sans compte rendu.
|
|
525
|
+
*/
|
|
526
|
+
closeChannel(channel) {
|
|
527
|
+
this.#sendControl({
|
|
528
|
+
op: "close",
|
|
529
|
+
channel
|
|
530
|
+
});
|
|
531
|
+
return this.#closeLocal(channel);
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Éjecte un compte d'un canal, **sur tous les serveurs** : chacune de ses connexions membres reçoit `realtime:closed { reason: "evicted" }`.
|
|
535
|
+
*
|
|
536
|
+
* Tous les onglets du compte sont visés, où qu'ils soient servis ; l'ordre
|
|
537
|
+
* traverse les pods comme {@link closeChannel}. ⚠️ **Éjecter coupe, seul
|
|
538
|
+
* `allow` exclut** (ADR-0022 §6 décision 3) : pour que le compte ne revienne
|
|
539
|
+
* pas, l'application retire l'appartenance là où `allow` la lit.
|
|
540
|
+
*
|
|
541
|
+
* ⚠️ Sur un bus PARTAGÉ, un tiers qui y écrit peut rejouer un ordre capté :
|
|
542
|
+
* sur un canal durable, il recouperait le compte à chaque réadmission (risque
|
|
543
|
+
* accepté et borné, ADR-0022 §6 d4) — bus privé exigé ; changer le secret du
|
|
544
|
+
* bus rend caducs les ordres déjà captés.
|
|
545
|
+
*
|
|
546
|
+
* @param channel - nom exact du canal.
|
|
547
|
+
* @param user - identifiant du compte (`IRealtimeConnection.userId`).
|
|
548
|
+
* @returns le nombre de connexions de CE pod éjectées. Un nom vide ou de plus
|
|
549
|
+
* de 1 024 caractères n'éjecte rien et ne part pas sur le bus.
|
|
550
|
+
*/
|
|
551
|
+
evictUser(channel, user) {
|
|
552
|
+
if (!this.#sendControl({
|
|
553
|
+
op: "evictUser",
|
|
554
|
+
channel,
|
|
555
|
+
user
|
|
556
|
+
})) return 0;
|
|
557
|
+
return this.#evictUserLocal(channel, user);
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Publie un ordre de contrôle aux autres serveurs — s'il y a un bus et que l'ordre a une forme que ses pairs acceptent.
|
|
561
|
+
*
|
|
562
|
+
* @returns `false` si l'ordre est mal formé (rien n'est appliqué nulle part).
|
|
563
|
+
*/
|
|
564
|
+
#sendControl(order) {
|
|
565
|
+
if (readControlOrder(order) === null) return false;
|
|
566
|
+
if (this.#backplane !== null && !isReservedSystemChannel(order.channel)) this.#backplane.publish(PLATFORM_BUS_CHANNELS.control, order);
|
|
567
|
+
return true;
|
|
568
|
+
}
|
|
569
|
+
/** Applique un ordre reçu du bus : forme vérifiée, canal de la plateforme refusé, sinon compté en rejet. */
|
|
570
|
+
#applyControl(payload) {
|
|
571
|
+
const order = readControlOrder(payload);
|
|
572
|
+
if (order === null || isReservedSystemChannel(order.channel)) {
|
|
573
|
+
this.#ingressRejectedTotal += 1;
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (order.op === "close") this.#closeLocal(order.channel);
|
|
577
|
+
else this.#evictUserLocal(order.channel, order.user);
|
|
578
|
+
}
|
|
579
|
+
/** Éjecte de `channel` les connexions de CE pod dont le compte est `user`. */
|
|
580
|
+
#evictUserLocal(channel, user) {
|
|
581
|
+
const members = this.#channels?.get(channel)?.members;
|
|
582
|
+
if (members == null) return 0;
|
|
583
|
+
let evicted = 0;
|
|
584
|
+
for (const member of members.values()) if (member.userId === user && member.end(channel, "evicted")) evicted += 1;
|
|
585
|
+
return evicted;
|
|
586
|
+
}
|
|
587
|
+
/** Termine `channel` pour les membres de CE pod et libère son producteur. */
|
|
588
|
+
#closeLocal(channel) {
|
|
589
|
+
const st = this.#channels?.get(channel);
|
|
590
|
+
if (st === void 0) return 0;
|
|
591
|
+
let ended = 0;
|
|
592
|
+
const members = st.members;
|
|
593
|
+
if (members !== null) {
|
|
594
|
+
for (const member of members.values()) if (member.end(channel, "ended")) ended += 1;
|
|
595
|
+
}
|
|
596
|
+
const rest = this.#channels?.get(channel);
|
|
597
|
+
if (rest?.owned === true) {
|
|
598
|
+
try {
|
|
599
|
+
rest.dispose?.();
|
|
600
|
+
} catch {}
|
|
601
|
+
rest.dispose = null;
|
|
602
|
+
rest.owned = false;
|
|
603
|
+
}
|
|
604
|
+
return ended;
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
281
607
|
* Publie une charge sur un canal : **fan-out local** **+** propagation conditionnelle
|
|
282
608
|
* au {@link IBackplane} (autres workers/pods). C'est le point d'entrée des providers
|
|
283
609
|
* locaux (tickers, listeners) ET de tout code serveur.
|
|
@@ -290,12 +616,65 @@ var RealtimeHub = class RealtimeHub {
|
|
|
290
616
|
*
|
|
291
617
|
* Anti-boucle : un message **reçu** du backplane ne repasse PAS par ici (il appelle
|
|
292
618
|
* `publishLocal` directement) → aucune ré-émission cross-process, pas de tempête.
|
|
619
|
+
*
|
|
620
|
+
* @param payload - objet, tableau ou octets ; omise = signal sans charge.
|
|
621
|
+
* @throws TypeError si la charge est un scalaire ou `null` ({@link isRealtimePayload},
|
|
622
|
+
* ADR-0021 S1) — un scalaire se publie dans un objet (`{ value }`).
|
|
293
623
|
*/
|
|
294
624
|
publish(channel, payload) {
|
|
625
|
+
if (!isRealtimePayload(payload)) throw realtimePayloadError(channel, payload);
|
|
295
626
|
const st = this.#channels?.get(channel);
|
|
296
627
|
if (st !== void 0) this.#fanout(st, payload);
|
|
297
628
|
if (this.#backplane === null) return;
|
|
298
|
-
if (st !== void 0 ? st.forward : this.#isBroadcast(channel))
|
|
629
|
+
if (!(st !== void 0 ? st.forward : this.#isBroadcast(channel))) {
|
|
630
|
+
this.#localOnlyTotal += 1;
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
if (this.#busRefuses(payload)) {
|
|
634
|
+
this.#notifyOnce("backplane:binary", this.#busBytes ? `realtime: le canal « ${channel} » publie des octets hors \`Uint8Array\` — le bus entre serveurs ne porte que des \`Uint8Array\` : seuls les abonnés de ce serveur les reçoivent` : `realtime: le canal « ${channel} » publie des octets — sans codec binaire côté serveur (\`codec: "cbor"\`) ou sans backplane qui les porte, ils ne traversent pas le bus entre serveurs (ils y seraient déformés) : seuls les abonnés de ce serveur les reçoivent`);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
this.#backplane.publish(channel, payload);
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Le bus refuse-t-il cette charge ? Un `Uint8Array` voyage quand le bus porte
|
|
641
|
+
* les octets ({@link #busBytes}) ; toute autre vue binaire, jamais.
|
|
642
|
+
*
|
|
643
|
+
* Sans bus binaire, le parcours en profondeur n'est payé que si un codec
|
|
644
|
+
* binaire est posé — seul cas où une charge à octets est légitime ; sans codec,
|
|
645
|
+
* seule la charge de premier niveau est examinée, comme avant le codec.
|
|
646
|
+
*/
|
|
647
|
+
#busRefuses(payload) {
|
|
648
|
+
const topLevel = ArrayBuffer.isView(payload) || payload instanceof ArrayBuffer;
|
|
649
|
+
if (this.#busBytes) return topLevel && !(payload instanceof Uint8Array);
|
|
650
|
+
if (this.#frameCodecs.binary !== null) return topLevel || containsBytes(payload);
|
|
651
|
+
return topLevel;
|
|
652
|
+
}
|
|
653
|
+
/** Arme les octets sur le bus quand codec binaire ET backplane sont posés, dans n'importe quel ordre. */
|
|
654
|
+
#armBusBytes() {
|
|
655
|
+
const codec = this.#frameCodecs.binary;
|
|
656
|
+
this.#busBytes = codec !== null && this.#backplane !== null && this.#backplane.enableBytes?.(codec) === true;
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Livre une charge à UNE identité — tous ses onglets, tous les serveurs, et personne d'autre (ADR-0022 §6, D1).
|
|
660
|
+
*
|
|
661
|
+
* Publie sur son canal personnel `user:<userId>` : seuls les onglets de cette
|
|
662
|
+
* identité ont pu s'y abonner (politique intégrée, admission par égalité de
|
|
663
|
+
* chaîne), et le préfixe traverse le bus dès qu'un bus est branché. Rien à
|
|
664
|
+
* déclarer côté application. Personne n'écoute ? La charge est perdue, sans
|
|
665
|
+
* erreur : c'est une diffusion, pas une boîte aux lettres.
|
|
666
|
+
*
|
|
667
|
+
* @param userId - l'`id` STABLE du compte (`RealtimeIdentity.id`), jamais le
|
|
668
|
+
* login.
|
|
669
|
+
* @param payload - objet, tableau ou octets ; omise = signal sans charge.
|
|
670
|
+
* @throws TypeError si la charge est un scalaire ou `null` (cf {@link publish}).
|
|
671
|
+
*/
|
|
672
|
+
publishToUser(userId, payload) {
|
|
673
|
+
this.publish(userChannel(userId), payload);
|
|
674
|
+
}
|
|
675
|
+
/** Préfixes déclarés diffusables entre serveurs, dans l'ordre de déclaration — lu par la ligne de démarrage. */
|
|
676
|
+
broadcastPrefixes() {
|
|
677
|
+
return this.#broadcastPrefixes ?? [];
|
|
299
678
|
}
|
|
300
679
|
/**
|
|
301
680
|
* Fan-out d'une charge aux **seuls abonnés locaux** d'un canal (process courant).
|
|
@@ -313,14 +692,14 @@ var RealtimeHub = class RealtimeHub {
|
|
|
313
692
|
this.#publishTotal += 1;
|
|
314
693
|
st.messages += 1;
|
|
315
694
|
this.#fanoutTotal += st.sinks.size;
|
|
316
|
-
let
|
|
317
|
-
if (st.
|
|
318
|
-
|
|
695
|
+
let frame;
|
|
696
|
+
if (st.buildFrame !== null && st.sinks.size > 1) try {
|
|
697
|
+
frame = new ChannelFrame(st.buildFrame(payload));
|
|
319
698
|
} catch {
|
|
320
|
-
|
|
699
|
+
frame = void 0;
|
|
321
700
|
}
|
|
322
701
|
for (const sink of st.sinks) try {
|
|
323
|
-
sink(payload,
|
|
702
|
+
sink(payload, frame);
|
|
324
703
|
} catch {}
|
|
325
704
|
}
|
|
326
705
|
/**
|
|
@@ -365,7 +744,9 @@ var RealtimeHub = class RealtimeHub {
|
|
|
365
744
|
*/
|
|
366
745
|
setBackplane(backplane) {
|
|
367
746
|
this.#backplane = backplane;
|
|
747
|
+
this.markBroadcastChannel(USER_CHANNEL_PREFIX);
|
|
368
748
|
backplane.onMessage((msg) => this.#admitFromBackplane(msg));
|
|
749
|
+
this.#armBusBytes();
|
|
369
750
|
const started = backplane.start();
|
|
370
751
|
if (started instanceof Promise) started.catch((err) => {
|
|
371
752
|
if (this.#notice === null) throw err instanceof Error ? err : new Error(String(err));
|
|
@@ -390,8 +771,16 @@ var RealtimeHub = class RealtimeHub {
|
|
|
390
771
|
* nominale : un pair légitime n'émet que des canaux broadcast (`publish` filtre à
|
|
391
772
|
* la source), et un canal ne peut avoir d'abonné local que si l'endpoint qui le
|
|
392
773
|
* sert a été handshaké — donc ses préfixes déjà déclarés.
|
|
774
|
+
*
|
|
775
|
+
* Seule exception, AVANT ce contrôle : le canal de contrôle
|
|
776
|
+
* (`PLATFORM_BUS_CHANNELS.control`), jamais diffusé aux abonnés — ses ordres,
|
|
777
|
+
* en liste fermée, sont appliqués aux connexions de ce pod ou comptés en rejet.
|
|
393
778
|
*/
|
|
394
779
|
#admitFromBackplane(msg) {
|
|
780
|
+
if (msg.channel === PLATFORM_BUS_CHANNELS.control) {
|
|
781
|
+
this.#applyControl(msg.payload);
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
395
784
|
if (!this.#isBroadcast(msg.channel)) {
|
|
396
785
|
this.#ingressRejectedTotal += 1;
|
|
397
786
|
return;
|
|
@@ -414,9 +803,18 @@ var RealtimeHub = class RealtimeHub {
|
|
|
414
803
|
registerConnection(conn) {
|
|
415
804
|
(this.#connections ??= /* @__PURE__ */ new Set()).add(conn);
|
|
416
805
|
}
|
|
417
|
-
/**
|
|
806
|
+
/**
|
|
807
|
+
* Retire une connexion du registre de la sonde (au close) et verse ses cumuls à
|
|
808
|
+
* ceux des connexions fermées : les totaux de {@link probe} restent monotones.
|
|
809
|
+
* No-op si absente — une double sortie ne compte pas deux fois.
|
|
810
|
+
*/
|
|
418
811
|
unregisterConnection(conn) {
|
|
419
|
-
this.#connections?.delete(conn);
|
|
812
|
+
if (this.#connections?.delete(conn) !== true) return;
|
|
813
|
+
this.#closedBytesSent += conn.bytesSent;
|
|
814
|
+
this.#closedMessagesSent += conn.messagesSent;
|
|
815
|
+
this.#closedDropped += conn.dropped;
|
|
816
|
+
this.#closedBinaryDiscarded += conn.binaryDiscarded ?? 0;
|
|
817
|
+
this.#closedMalformedDiscarded += conn.malformedDiscarded ?? 0;
|
|
420
818
|
}
|
|
421
819
|
/**
|
|
422
820
|
* Inscrit une connexion à identité RÉVOCABLE au registre de
|
|
@@ -479,22 +877,32 @@ var RealtimeHub = class RealtimeHub {
|
|
|
479
877
|
messages: st.messages
|
|
480
878
|
});
|
|
481
879
|
let connectionCount = 0;
|
|
482
|
-
let bytesSentTotal =
|
|
483
|
-
let messagesSentTotal =
|
|
880
|
+
let bytesSentTotal = this.#closedBytesSent;
|
|
881
|
+
let messagesSentTotal = this.#closedMessagesSent;
|
|
484
882
|
let maxBufferedAmount = 0;
|
|
485
883
|
let totalBufferedAmount = 0;
|
|
486
884
|
let slowConsumers = 0;
|
|
487
|
-
let drops =
|
|
885
|
+
let drops = this.#closedDropped;
|
|
886
|
+
let binaryDiscardedTotal = this.#closedBinaryDiscarded;
|
|
887
|
+
let malformedDiscardedTotal = this.#closedMalformedDiscarded;
|
|
488
888
|
if (this.#connections) for (const c of this.#connections) {
|
|
489
889
|
connectionCount += 1;
|
|
490
890
|
bytesSentTotal += c.bytesSent;
|
|
491
891
|
messagesSentTotal += c.messagesSent;
|
|
492
892
|
drops += c.dropped;
|
|
893
|
+
binaryDiscardedTotal += c.binaryDiscarded ?? 0;
|
|
894
|
+
malformedDiscardedTotal += c.malformedDiscarded ?? 0;
|
|
493
895
|
const buf = c.bufferedAmount;
|
|
494
896
|
if (buf > maxBufferedAmount) maxBufferedAmount = buf;
|
|
495
897
|
totalBufferedAmount += buf;
|
|
496
898
|
if (buf >= this.#slowConsumerBytes) slowConsumers += 1;
|
|
497
899
|
}
|
|
900
|
+
const backplane = this.#backplane?.describe() ?? {
|
|
901
|
+
driver: LoopbackBackplane.driver,
|
|
902
|
+
kind: "local",
|
|
903
|
+
originId: String(process.pid),
|
|
904
|
+
crossPod: false
|
|
905
|
+
};
|
|
498
906
|
return {
|
|
499
907
|
ts: Date.now(),
|
|
500
908
|
channels,
|
|
@@ -503,7 +911,15 @@ var RealtimeHub = class RealtimeHub {
|
|
|
503
911
|
fanoutTotal: this.#fanoutTotal,
|
|
504
912
|
inboundTotal: this.#inboundTotal,
|
|
505
913
|
ingressRejectedTotal: this.#ingressRejectedTotal,
|
|
914
|
+
localOnlyTotal: this.#localOnlyTotal,
|
|
506
915
|
systemFloorDeniedTotal: this.#systemFloorDeniedTotal,
|
|
916
|
+
sealRejectedTotal: backplane.sealRejectedTotal ?? 0,
|
|
917
|
+
frameRateRejectedTotal: frameRateRejectedTotal(),
|
|
918
|
+
allowTimedOutTotal: this.admissionBudget.timedOutTotal,
|
|
919
|
+
allowSaturatedTotal: this.admissionBudget.saturatedTotal,
|
|
920
|
+
authenticateTimedOutTotal: this.admissionBudget.authenticateTimedOutTotal,
|
|
921
|
+
binaryDiscardedTotal,
|
|
922
|
+
malformedDiscardedTotal,
|
|
507
923
|
connectionCount,
|
|
508
924
|
bytesSentTotal,
|
|
509
925
|
messagesSentTotal,
|
|
@@ -513,12 +929,7 @@ var RealtimeHub = class RealtimeHub {
|
|
|
513
929
|
slowConsumers,
|
|
514
930
|
drops
|
|
515
931
|
},
|
|
516
|
-
backplane
|
|
517
|
-
driver: LoopbackBackplane.driver,
|
|
518
|
-
kind: "local",
|
|
519
|
-
originId: String(process.pid),
|
|
520
|
-
crossPod: false
|
|
521
|
-
}
|
|
932
|
+
backplane
|
|
522
933
|
};
|
|
523
934
|
}
|
|
524
935
|
/** Canaux actifs (≥ 1 abonné). Lecture seule. */
|
|
@@ -593,6 +1004,39 @@ var RealtimeHub = class RealtimeHub {
|
|
|
593
1004
|
this.#slowConsumerBytes = bytes;
|
|
594
1005
|
}
|
|
595
1006
|
/**
|
|
1007
|
+
* Les codecs de frame du serveur (ADR-0022 §2) : le texte, et le codec binaire
|
|
1008
|
+
* accordé aux clients qui l'offrent — `null` tant que la configuration n'en
|
|
1009
|
+
* active aucun. Lu une fois par connexion, au handshake, jamais par trame.
|
|
1010
|
+
*/
|
|
1011
|
+
get frameCodecs() {
|
|
1012
|
+
return this.#frameCodecs;
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* Pose le codec binaire du serveur — 1× au boot par `RealtimeService.init()`,
|
|
1016
|
+
* depuis la clé `codec` de la configuration. Sans argument : texte seul.
|
|
1017
|
+
*
|
|
1018
|
+
* @param codec - le codec activé (`CBOR_FRAME_CODEC` de `nodefony/cbor`).
|
|
1019
|
+
*/
|
|
1020
|
+
setFrameCodec(codec) {
|
|
1021
|
+
this.#frameCodecs = frameCodecSet(codec);
|
|
1022
|
+
this.#armBusBytes();
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* Le codec des connexions texte — celui qu'emprunte aussi le REST du plan
|
|
1026
|
+
* d'administration, pour qu'une valeur lue par `GET` soit celle que la socket
|
|
1027
|
+
* livre (#618 D9). Avec un codec binaire activé, c'est le JSON à convention
|
|
1028
|
+
* d'octets ; sinon le JSON natif.
|
|
1029
|
+
*/
|
|
1030
|
+
get textFrameCodec() {
|
|
1031
|
+
return this.#frameCodecs.text;
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
1034
|
+
* Pose les bornes des admissions (`authenticateTimeoutMs`, `allowTimeoutMs`, `maxPendingAllowsPerConnection`, `maxPendingAllowsTotal`) — 1× au boot par `RealtimeService.init()`.
|
|
1035
|
+
*/
|
|
1036
|
+
setAdmissionLimits(limits) {
|
|
1037
|
+
this.admissionBudget.configure(limits);
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
596
1040
|
* Associe un token au peer (posé par `RealtimeController.onHandshake()` après
|
|
597
1041
|
* succès `authenticator.authenticate()` ou fallback anonyme). Lazy alloc de
|
|
598
1042
|
* la `WeakMap` au 1ᵉʳ appel (0 coût si aucun authenticator enregistré).
|
|
@@ -615,6 +1059,7 @@ var RealtimeHub = class RealtimeHub {
|
|
|
615
1059
|
*/
|
|
616
1060
|
setFrameAuthorizer(authorizer, options) {
|
|
617
1061
|
this.#frameAuthorizer = authorizer;
|
|
1062
|
+
this.#dropRetainedVerdicts();
|
|
618
1063
|
this.#channelProbe = options?.silentProbe ?? null;
|
|
619
1064
|
}
|
|
620
1065
|
/**
|
|
@@ -697,15 +1142,29 @@ var RealtimeHub = class RealtimeHub {
|
|
|
697
1142
|
*
|
|
698
1143
|
* @param name - nom exact du canal (subscribe), de la méthode inbound, ou
|
|
699
1144
|
* MOTIF de famille s'il contient `*`.
|
|
1145
|
+
* Une politique à `allow` porte sa règle d'admission : une flèche est
|
|
1146
|
+
* retenue telle quelle ; une classe exige son instance, résolue au démarrage
|
|
1147
|
+
* par l'injecteur, dont la méthode est liée ICI, une fois — le `subscribe`
|
|
1148
|
+
* n'appelle qu'une fonction prête.
|
|
1149
|
+
*
|
|
700
1150
|
* @param policy - exigences ({@link IChannelPolicy}) lues par security.
|
|
1151
|
+
* @param admission - instance de la classe d'admission quand `policy.allow`
|
|
1152
|
+
* en est une ; ignorée sinon.
|
|
1153
|
+
* @throws BootConfigurationError La politique désigne une classe d'admission
|
|
1154
|
+
* et aucune instance de CETTE classe n'est fournie.
|
|
701
1155
|
*/
|
|
702
|
-
registerChannelPolicy(name, policy) {
|
|
1156
|
+
registerChannelPolicy(name, policy, admission) {
|
|
1157
|
+
const entry = {
|
|
1158
|
+
policy,
|
|
1159
|
+
admission: admissionRuleOf(name, policy, admission)
|
|
1160
|
+
};
|
|
1161
|
+
this.#dropRetainedVerdicts();
|
|
703
1162
|
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.`);
|
|
704
1163
|
if (name.includes("*")) {
|
|
705
|
-
this.#registerChannelPolicyPattern(name,
|
|
1164
|
+
this.#registerChannelPolicyPattern(name, entry);
|
|
706
1165
|
return;
|
|
707
1166
|
}
|
|
708
|
-
(this.#channelPolicies ??= /* @__PURE__ */ new Map()).set(name,
|
|
1167
|
+
(this.#channelPolicies ??= /* @__PURE__ */ new Map()).set(name, entry);
|
|
709
1168
|
}
|
|
710
1169
|
/**
|
|
711
1170
|
* Nombre maximum de `*` dans un motif.
|
|
@@ -717,7 +1176,7 @@ var RealtimeHub = class RealtimeHub {
|
|
|
717
1176
|
* de déclaration, et on le DIT plutôt que d'exposer un chemin coûteux à ce
|
|
718
1177
|
* qu'un client peut nommer.
|
|
719
1178
|
*/
|
|
720
|
-
static MAX_PATTERN_WILDCARDS =
|
|
1179
|
+
static MAX_PATTERN_WILDCARDS = MAX_CHANNEL_PATTERN_WILDCARDS;
|
|
721
1180
|
/**
|
|
722
1181
|
* Compile un motif et l'insère à son rang de spécificité.
|
|
723
1182
|
*
|
|
@@ -726,14 +1185,12 @@ var RealtimeHub = class RealtimeHub {
|
|
|
726
1185
|
* `.` reste un point, un `(` reste une parenthèse — sans quoi une déclaration
|
|
727
1186
|
* anodine deviendrait une expression, avec ce que cela ouvre.
|
|
728
1187
|
*/
|
|
729
|
-
#registerChannelPolicyPattern(source,
|
|
730
|
-
const
|
|
731
|
-
if (
|
|
732
|
-
this.#notifyOnce(`pattern-too-broad:${source}`, `le motif de politique "${source}" porte ${
|
|
1188
|
+
#registerChannelPolicyPattern(source, entry) {
|
|
1189
|
+
const re = compileChannelPattern(source);
|
|
1190
|
+
if (re === null) {
|
|
1191
|
+
this.#notifyOnce(`pattern-too-broad:${source}`, `le motif de politique "${source}" porte ${countWildcards(source)} 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.`);
|
|
733
1192
|
return;
|
|
734
1193
|
}
|
|
735
|
-
const echappe = source.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
736
|
-
const re = new RegExp(`^${echappe.split("\\*").join(".*")}$`, "u");
|
|
737
1194
|
const weight = source.split("*").join("").length;
|
|
738
1195
|
const policies = this.#channelPolicyPatterns ??= [];
|
|
739
1196
|
const existing = policies.findIndex((p) => p.source === source);
|
|
@@ -741,15 +1198,15 @@ var RealtimeHub = class RealtimeHub {
|
|
|
741
1198
|
source,
|
|
742
1199
|
re,
|
|
743
1200
|
weight,
|
|
744
|
-
|
|
1201
|
+
...entry
|
|
745
1202
|
};
|
|
746
1203
|
if (existing >= 0) {
|
|
747
1204
|
policies[existing] = policyEntry;
|
|
748
1205
|
return;
|
|
749
1206
|
}
|
|
750
|
-
const
|
|
751
|
-
if (
|
|
752
|
-
else policies.splice(
|
|
1207
|
+
const rank = policies.findIndex((p) => p.weight < weight);
|
|
1208
|
+
if (rank < 0) policies.push(policyEntry);
|
|
1209
|
+
else policies.splice(rank, 0, policyEntry);
|
|
753
1210
|
}
|
|
754
1211
|
/**
|
|
755
1212
|
* Politique déclarée pour ce canal/méthode, ou `null` si aucune. Lu par
|
|
@@ -757,12 +1214,50 @@ var RealtimeHub = class RealtimeHub {
|
|
|
757
1214
|
* la frame `subscribe`/inbound. O(1), 0 alloc quand le registre est vide.
|
|
758
1215
|
*/
|
|
759
1216
|
resolveChannelPolicy(name) {
|
|
1217
|
+
return this.#resolvePolicyEntry(name)?.policy ?? null;
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
1220
|
+
* Règle d'admission prête à appeler pour ce canal — `null` s'il ne déclare pas `allow`. Lue au `subscribe`, jamais par trame.
|
|
1221
|
+
*
|
|
1222
|
+
* Même résolution que {@link resolveChannelPolicy} (nom exact, puis motif le
|
|
1223
|
+
* plus spécifique) : la règle suit TOUJOURS la politique qui s'applique.
|
|
1224
|
+
*/
|
|
1225
|
+
resolveChannelAdmission(name) {
|
|
1226
|
+
return this.#resolvePolicyEntry(name)?.admission ?? null;
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* Les politiques de cette classe de contrôleur sont-elles déjà inscrites ? Une poignée de main n'en paie que ce `has`.
|
|
1230
|
+
*
|
|
1231
|
+
* @param owner - le constructeur du contrôleur.
|
|
1232
|
+
*/
|
|
1233
|
+
hasPolicyOwner(owner) {
|
|
1234
|
+
return this.#policyOwners?.has(owner) === true;
|
|
1235
|
+
}
|
|
1236
|
+
/**
|
|
1237
|
+
* Marque les politiques de cette classe comme inscrites — APRÈS l'inscription réussie, jamais avant : une politique refusée doit l'être de nouveau à la poignée de main suivante, pas laisser le canal ouvert.
|
|
1238
|
+
*
|
|
1239
|
+
* @param owner - le constructeur du contrôleur.
|
|
1240
|
+
*/
|
|
1241
|
+
addPolicyOwner(owner) {
|
|
1242
|
+
(this.#policyOwners ??= /* @__PURE__ */ new WeakSet()).add(owner);
|
|
1243
|
+
}
|
|
1244
|
+
/** Entrée du registre qui s'applique à ce nom — exact d'abord, puis motif. */
|
|
1245
|
+
#resolvePolicyEntry(name) {
|
|
760
1246
|
const exact = this.#channelPolicies?.get(name);
|
|
761
1247
|
if (exact !== void 0) return exact;
|
|
762
1248
|
const patterns = this.#channelPolicyPatterns;
|
|
763
|
-
if (patterns
|
|
764
|
-
|
|
765
|
-
|
|
1249
|
+
if (patterns !== null) {
|
|
1250
|
+
for (const pattern of patterns) if (pattern.re.test(name)) return pattern;
|
|
1251
|
+
}
|
|
1252
|
+
return name.startsWith(USER_CHANNEL_PREFIX) ? USER_CHANNEL_POLICY : null;
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* Une politique est-elle déclarée sous ce nom EXACT — motifs ignorés ? C'est la question du défaut fermé des actions.
|
|
1256
|
+
*
|
|
1257
|
+
* @param name - nom d'action ou de canal.
|
|
1258
|
+
*/
|
|
1259
|
+
hasExactChannelPolicy(name) {
|
|
1260
|
+
return this.#channelPolicies?.has(name) === true;
|
|
766
1261
|
}
|
|
767
1262
|
/** Des politiques de canal sont-elles déclarées (au moins une) ? */
|
|
768
1263
|
hasChannelPolicies() {
|
|
@@ -773,14 +1268,14 @@ var RealtimeHub = class RealtimeHub {
|
|
|
773
1268
|
* DYNAMIQUE n'est couvert par aucune politique déclarée, alors que d'autres
|
|
774
1269
|
* canaux du module en portent une.
|
|
775
1270
|
*
|
|
776
|
-
* Le registre des politiques
|
|
777
|
-
*
|
|
778
|
-
*
|
|
779
|
-
*
|
|
780
|
-
*
|
|
781
|
-
*
|
|
782
|
-
* canaux qu'aucun motif ne couvre
|
|
783
|
-
* de l'outil.
|
|
1271
|
+
* Le registre des politiques lit le nom **EXACT**, puis les **MOTIFS**
|
|
1272
|
+
* déclarés ({@link resolveChannelPolicy}) : `@RealtimeChannel` déclare
|
|
1273
|
+
* `"chat:room"`, mais un canal dérivé (`chat:room:1000` — cadence, forage,
|
|
1274
|
+
* suffixe) servi par la fabrique dynamique ne matche ce nom que si un motif
|
|
1275
|
+
* (`chat:room:*`) le couvre. Sans motif, l'auteur croit son canal gardé ; il
|
|
1276
|
+
* ne l'est pas, et rien ne le lui dit. C'est ce silence qu'on ferme ici :
|
|
1277
|
+
* l'avertissement ne parle que des canaux qu'aucun motif ne couvre — un vrai
|
|
1278
|
+
* trou, pas une limite de l'outil.
|
|
784
1279
|
*
|
|
785
1280
|
* Appelé par le contrôleur, seul à savoir par quelle voie le canal a été
|
|
786
1281
|
* servi. Muet si aucune politique n'est déclarée (module aux canaux libres :
|
|
@@ -832,8 +1327,39 @@ var RealtimeHub = class RealtimeHub {
|
|
|
832
1327
|
return this.#clientLogs;
|
|
833
1328
|
}
|
|
834
1329
|
runAuthorizer(frame, peer) {
|
|
835
|
-
|
|
836
|
-
|
|
1330
|
+
const authorizer = this.#frameAuthorizer;
|
|
1331
|
+
if (authorizer === null) return true;
|
|
1332
|
+
try {
|
|
1333
|
+
return authorizer(frame, this.getTokenForPeer(peer)) === true;
|
|
1334
|
+
} catch (e) {
|
|
1335
|
+
this.#notifyOnce("frame-authorizer-threw", "le verrou des trames a levé une exception — trame refusée : " + (e instanceof Error ? e.message : String(e)));
|
|
1336
|
+
return false;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
/**
|
|
1340
|
+
* Inscrit une connexion qui vient de retenir son premier « oui » du verrou.
|
|
1341
|
+
*
|
|
1342
|
+
* Appelé par `gateFrame`, jamais par trame chaude ; à équilibrer par
|
|
1343
|
+
* {@link untrackRetainedVerdicts} à la fermeture.
|
|
1344
|
+
*
|
|
1345
|
+
* @param state - état de la connexion.
|
|
1346
|
+
*/
|
|
1347
|
+
trackRetainedVerdicts(state) {
|
|
1348
|
+
(this.#retainers ??= /* @__PURE__ */ new Set()).add(state);
|
|
1349
|
+
}
|
|
1350
|
+
/**
|
|
1351
|
+
* Retire une connexion fermée du registre des verdicts retenus.
|
|
1352
|
+
*
|
|
1353
|
+
* @param state - état de la connexion.
|
|
1354
|
+
*/
|
|
1355
|
+
untrackRetainedVerdicts(state) {
|
|
1356
|
+
this.#retainers?.delete(state);
|
|
1357
|
+
}
|
|
1358
|
+
#dropRetainedVerdicts() {
|
|
1359
|
+
const retainers = this.#retainers;
|
|
1360
|
+
if (retainers === null) return;
|
|
1361
|
+
for (const state of retainers) dropRetainedVerdicts(state);
|
|
1362
|
+
this.#retainers = null;
|
|
837
1363
|
}
|
|
838
1364
|
/**
|
|
839
1365
|
* Ce peer pourrait-il s'abonner à ce canal ? — même décision que `subscribe`,
|
|
@@ -849,11 +1375,17 @@ var RealtimeHub = class RealtimeHub {
|
|
|
849
1375
|
* @returns `true` si un `subscribe` sur ce canal serait accepté.
|
|
850
1376
|
*/
|
|
851
1377
|
probeChannel(channel, peer) {
|
|
852
|
-
|
|
853
|
-
return
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
1378
|
+
const probe = this.#channelProbe;
|
|
1379
|
+
if (probe === null) return true;
|
|
1380
|
+
try {
|
|
1381
|
+
return probe({
|
|
1382
|
+
method: "subscribe",
|
|
1383
|
+
params: { channel }
|
|
1384
|
+
}, this.getTokenForPeer(peer));
|
|
1385
|
+
} catch (e) {
|
|
1386
|
+
this.#notifyOnce("channel-probe-threw", "la sonde des canaux annoncés a levé une exception — canal annoncé : " + (e instanceof Error ? e.message : String(e)));
|
|
1387
|
+
return true;
|
|
1388
|
+
}
|
|
857
1389
|
}
|
|
858
1390
|
/** Authenticators enregistrés (debug / tests). Lecture seule. */
|
|
859
1391
|
get registeredAuthenticators() {
|
|
@@ -875,16 +1407,28 @@ var RealtimeHub = class RealtimeHub {
|
|
|
875
1407
|
this.#authenticators = null;
|
|
876
1408
|
this.#peerTokens = null;
|
|
877
1409
|
this.#frameAuthorizer = null;
|
|
1410
|
+
this.#channelProbe = null;
|
|
1411
|
+
this.#dropRetainedVerdicts();
|
|
878
1412
|
this.#channelPolicies?.clear();
|
|
879
1413
|
this.#channelPolicies = null;
|
|
880
1414
|
this.#channelPolicyPatterns = null;
|
|
1415
|
+
this.#policyOwners = null;
|
|
881
1416
|
this.#originGuard = null;
|
|
882
1417
|
this.#backplane = null;
|
|
1418
|
+
this.#busBytes = false;
|
|
883
1419
|
this.#publishTotal = 0;
|
|
884
1420
|
this.#fanoutTotal = 0;
|
|
1421
|
+
this.#closedBytesSent = 0;
|
|
1422
|
+
this.#closedMessagesSent = 0;
|
|
1423
|
+
this.#closedDropped = 0;
|
|
1424
|
+
this.#closedBinaryDiscarded = 0;
|
|
1425
|
+
this.#closedMalformedDiscarded = 0;
|
|
885
1426
|
this.#inboundTotal = 0;
|
|
886
1427
|
this.#ingressRejectedTotal = 0;
|
|
1428
|
+
this.#localOnlyTotal = 0;
|
|
887
1429
|
this.#systemFloorDeniedTotal = 0;
|
|
1430
|
+
this.admissionBudget.reset();
|
|
1431
|
+
this.#frameCodecs = frameCodecSet();
|
|
888
1432
|
this.#noticed = null;
|
|
889
1433
|
this.#notice = null;
|
|
890
1434
|
}
|
|
@@ -918,8 +1462,7 @@ function compileMatcher(m) {
|
|
|
918
1462
|
};
|
|
919
1463
|
}
|
|
920
1464
|
let _hub = null;
|
|
921
|
-
/** Renvoie le hub realtime du process (singleton lazy). */
|
|
922
1465
|
function getRealtimeHub() {
|
|
923
1466
|
return _hub ??= new RealtimeHub();
|
|
924
1467
|
}
|
|
925
|
-
export { RESERVED_SYSTEM_PREFIXES, REVOCATION_REVALIDATE_MS, RealtimeHub, RealtimeHub as default, SLOW_CONSUMER_BYTES, channelFamily, getRealtimeHub, isReservedSystemChannel };
|
|
1468
|
+
export { ChannelFrame, RESERVED_SYSTEM_PREFIXES, REVOCATION_REVALIDATE_MS, RealtimeHub, RealtimeHub as default, SLOW_CONSUMER_BYTES, channelFamily, getRealtimeHub, isReservedSystemChannel };
|