@nodefony/realtime 10.0.0-beta.5 → 10.0.0-beta.7
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 +55 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
- package/dist/index.js +91 -12
- package/dist/nodefony/config/config.js +7 -0
- package/dist/nodefony/decorators/realtimeDecorators.js +341 -43
- package/dist/nodefony/interfaces/IRealtimeConnection.js +1 -0
- package/dist/nodefony/src/backplane/ClusterBackplane.js +32 -4
- package/dist/nodefony/src/backplane/RedisBackplane.js +146 -41
- package/dist/nodefony/src/backplane/controlOrder.js +86 -0
- package/dist/nodefony/src/backplane/envelope.js +505 -27
- package/dist/nodefony/src/backplane/ingress.js +93 -0
- package/dist/nodefony/src/backplane/presenceMessage.js +205 -0
- package/dist/nodefony/src/backplane/publishQueue.js +97 -3
- 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 +135 -0
- package/dist/nodefony/src/server/RealtimeController.js +409 -107
- package/dist/nodefony/src/server/RealtimeHub.js +1158 -111
- 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 +95 -0
- package/dist/nodefony/src/server/channelPolicyResolution.js +191 -0
- package/dist/nodefony/src/server/channelPresence.js +488 -0
- package/dist/nodefony/src/server/connectionAdmission.js +242 -0
- package/dist/nodefony/src/server/contractRoutes.js +75 -0
- package/dist/nodefony/src/server/deniedDetail.js +10 -3
- package/dist/nodefony/src/server/frameAdmission.js +160 -0
- package/dist/nodefony/src/server/presenceBus.js +486 -0
- package/dist/nodefony/src/server/realtimeShutdown.js +31 -0
- package/dist/nodefony/src/server/remotePresence.js +541 -0
- package/dist/nodefony/src/server/subscribedAck.js +13 -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 +51 -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 +347 -0
- package/dist/nodefony/testing/index.js +43 -12
- package/dist/nodefony/testing/presenceClient.js +54 -0
- package/dist/types/index.d.ts +39 -7
- package/dist/types/nodefony/config/config.d.ts +9 -0
- package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +198 -17
- package/dist/types/nodefony/interfaces/IBackplane.d.ts +46 -51
- 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 +89 -18
- package/dist/types/nodefony/src/backplane/controlOrder.d.ts +75 -0
- package/dist/types/nodefony/src/backplane/envelope.d.ts +77 -0
- package/dist/types/nodefony/src/backplane/ingress.d.ts +48 -0
- package/dist/types/nodefony/src/backplane/presenceMessage.d.ts +150 -0
- package/dist/types/nodefony/src/backplane/publishQueue.d.ts +25 -1
- 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 +20 -0
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +110 -12
- package/dist/types/nodefony/src/server/RealtimeHub.d.ts +448 -42
- 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 +41 -0
- package/dist/types/nodefony/src/server/channelPolicyResolution.d.ts +112 -0
- package/dist/types/nodefony/src/server/channelPresence.d.ts +155 -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 +66 -0
- package/dist/types/nodefony/src/server/presenceBus.d.ts +112 -0
- package/dist/types/nodefony/src/server/realtimeShutdown.d.ts +21 -0
- package/dist/types/nodefony/src/server/remotePresence.d.ts +151 -0
- package/dist/types/nodefony/src/server/subscribedAck.d.ts +10 -0
- package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
- package/dist/types/nodefony/src/service/RealtimeService.d.ts +17 -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 +78 -0
- package/dist/types/nodefony/testing/index.d.ts +13 -2
- package/dist/types/nodefony/testing/presenceClient.d.ts +20 -0
- package/docs/actions.md +420 -18
- package/docs/architecture.md +101 -11
- package/docs/cadence-et-contre-pression.md +87 -11
- package/docs/canaux-a-membres.md +277 -0
- package/docs/cas-d-usage.md +1703 -0
- package/docs/configuration.md +175 -19
- package/docs/cookbook-chat.md +701 -224
- package/docs/index.md +11 -1
- package/docs/observabilite.md +97 -21
- package/docs/protocole.md +142 -33
- package/docs/securite.md +347 -41
- package/docs/vocabulaire.md +13 -12
- package/package.json +16 -13
|
@@ -1,6 +1,15 @@
|
|
|
1
|
+
import { RealtimeConnection } from "./RealtimeConnection.js";
|
|
2
|
+
import { ChannelPresence, presenceDefect, presenceSettingsOf } from "./channelPresence.js";
|
|
3
|
+
import { PresenceBus } from "./presenceBus.js";
|
|
4
|
+
import { AdmissionBudget } from "./admissionBudget.js";
|
|
5
|
+
import { dropRetainedVerdicts } from "./frameAdmission.js";
|
|
6
|
+
import { DEFAULT_ACTION_POLICY } from "../../decorators/realtimeDecorators.js";
|
|
7
|
+
import { USER_CHANNEL_DECLARATION, declarationOf, policyDeclarationsDefect, resolveDeclaredPolicies } from "./channelPolicyResolution.js";
|
|
1
8
|
import { LoopbackBackplane } from "../backplane/LoopbackBackplane.js";
|
|
9
|
+
import { isUnknownControlOrder, readControlOrder } from "../backplane/controlOrder.js";
|
|
2
10
|
import { ANONYMOUS_REALTIME_TOKEN } from "./AnonymousRealtimeToken.js";
|
|
3
|
-
import { IDENTITY_REFRESH_CLOSE_CODE, NODEFONY_CHANNEL_NAMESPACE, REVOCATION_REVALIDATE_MS, RevocationWatch, isPlatformChannel } from "nodefony";
|
|
11
|
+
import { BootConfigurationError, IDENTITY_DEAD_CLOSE_CODE, IDENTITY_REFRESH_CLOSE_CODE, JsonRpcPeer, MAX_CHANNEL_PATTERN_WILDCARDS, NODEFONY_CHANNEL_NAMESPACE, PLATFORM_BUS_CHANNELS, REALTIME_PRESENCE_METHOD, REVOCATION_REVALIDATE_MS, RevocationWatch, TOKEN_EXPIRED_CLOSE_CODE, USER_CHANNEL_PREFIX, channelPatternWeight, compileChannelPattern, containsBytes, countWildcards, frameCodecSet, isPlatformBusChannel, isPlatformChannel, isRealtimePayload, realtimePayloadError, userChannel } from "nodefony";
|
|
12
|
+
import { frameRateRejectedTotal } from "@nodefony/http";
|
|
4
13
|
// nodefony/src/server/RealtimeHub.ts
|
|
5
14
|
/**
|
|
6
15
|
* Seuil d'alerte slow-consumer (octets de `bufferedAmount`). Au-delà, la connexion
|
|
@@ -64,6 +73,110 @@ function channelFamily(channel) {
|
|
|
64
73
|
return i === -1 ? "*" : `${channel.slice(0, i + 1)}*`;
|
|
65
74
|
}
|
|
66
75
|
/**
|
|
76
|
+
* Frame d'UNE publication diffusée à plusieurs abonnés, encodée **au plus une fois
|
|
77
|
+
* par codec** (ADR-0022 §2) : un canal suivi par des connexions texte et CBOR paie
|
|
78
|
+
* deux encodages, jamais un par connexion. L'encodage est paresseux — un codec
|
|
79
|
+
* qu'aucun abonné n'utilise ne coûte rien.
|
|
80
|
+
*
|
|
81
|
+
* Deux places suffisent : un hub sert au plus son codec texte et son codec
|
|
82
|
+
* binaire. Un troisième codec reste servi, sans mise en cache.
|
|
83
|
+
*/
|
|
84
|
+
var ChannelFrame = class {
|
|
85
|
+
message;
|
|
86
|
+
codecA;
|
|
87
|
+
dataA;
|
|
88
|
+
codecB;
|
|
89
|
+
dataB;
|
|
90
|
+
claimedA;
|
|
91
|
+
claimedB;
|
|
92
|
+
/** @param message - la frame protocolaire, telle que rendue par le {@link ChannelFrameBuilder}. */
|
|
93
|
+
constructor(message) {
|
|
94
|
+
this.message = message;
|
|
95
|
+
this.codecA = null;
|
|
96
|
+
this.dataA = void 0;
|
|
97
|
+
this.codecB = null;
|
|
98
|
+
this.dataB = void 0;
|
|
99
|
+
this.claimedA = false;
|
|
100
|
+
this.claimedB = false;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Rend la frame encodée pour un codec, en l'encodant à la première demande.
|
|
104
|
+
*
|
|
105
|
+
* @param codec - le codec de la connexion qui va l'écrire.
|
|
106
|
+
* @returns la trame à écrire telle quelle, ou `undefined` si la frame n'est pas
|
|
107
|
+
* encodable : l'abonné qui en {@link ChannelFrame.claimFailure | réclame}
|
|
108
|
+
* l'échec reprend son chemin habituel, qui porte le filet (journal).
|
|
109
|
+
*/
|
|
110
|
+
encodeFor(codec) {
|
|
111
|
+
if (codec === this.codecA) return this.dataA;
|
|
112
|
+
if (codec === this.codecB) return this.dataB;
|
|
113
|
+
let data;
|
|
114
|
+
try {
|
|
115
|
+
data = codec.encode(this.message);
|
|
116
|
+
} catch {
|
|
117
|
+
data = void 0;
|
|
118
|
+
}
|
|
119
|
+
if (this.codecA === null) {
|
|
120
|
+
this.codecA = codec;
|
|
121
|
+
this.dataA = data;
|
|
122
|
+
} else if (this.codecB === null) {
|
|
123
|
+
this.codecB = codec;
|
|
124
|
+
this.dataB = data;
|
|
125
|
+
}
|
|
126
|
+
return data;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Réclame l'échec d'encodage de cette frame pour un codec : `true` au PREMIER
|
|
130
|
+
* abonné qui le demande, `false` ensuite.
|
|
131
|
+
*
|
|
132
|
+
* Un seul abonné reprend alors son chemin habituel, dont le filet journalise
|
|
133
|
+
* l'échec ; les autres sautent la trame, qu'ils ne pourraient pas encoder non
|
|
134
|
+
* plus. Sans cela, une charge non encodable — un `$bytes` forgé par un client,
|
|
135
|
+
* une valeur cyclique — coûtait un encodage et une ligne de journal PAR abonné.
|
|
136
|
+
* Un codec hors des deux places réclame à chaque fois : il n'est pas retenu.
|
|
137
|
+
*
|
|
138
|
+
* @param codec - le codec dont {@link ChannelFrame.encodeFor} a rendu `undefined`.
|
|
139
|
+
* @returns `true` si l'appelant doit dire l'échec.
|
|
140
|
+
*/
|
|
141
|
+
claimFailure(codec) {
|
|
142
|
+
if (codec === this.codecA) {
|
|
143
|
+
if (this.claimedA) return false;
|
|
144
|
+
this.claimedA = true;
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
if (codec === this.codecB) {
|
|
148
|
+
if (this.claimedB) return false;
|
|
149
|
+
this.claimedB = true;
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
/**
|
|
156
|
+
* Fabrique intégrée du canal personnel : il n'a pas de source, seul `publish`
|
|
157
|
+
* l'alimente. Partagée par tous les canaux `user:` — aucune allocation par canal.
|
|
158
|
+
*/
|
|
159
|
+
const USER_CHANNEL_DISPOSE = () => {};
|
|
160
|
+
/** Identité sous laquelle une connexion figure dans la présence : nommée ⇔ compte local (ADR-0025 P3). */
|
|
161
|
+
function presenceKeyOf(member) {
|
|
162
|
+
return member.account ? member.userId : null;
|
|
163
|
+
}
|
|
164
|
+
/** Ensemble vide partagé : la présence répartie sans canal servi n'alloue rien. */
|
|
165
|
+
const EMPTY_PRESENCES = /* @__PURE__ */ new Set();
|
|
166
|
+
/** Un ensemble d'abonnés à un seul membre, sans le tableau temporaire de `new Set([sink])`. */
|
|
167
|
+
function singleSink(sink) {
|
|
168
|
+
const sinks = /* @__PURE__ */ new Set();
|
|
169
|
+
sinks.add(sink);
|
|
170
|
+
return sinks;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Prochain instant où la présence d'un canal a quelque chose à faire : la fin de la fenêtre si des changements attendent, la prochaine fin de grâce — `Infinity` sinon. O(1).
|
|
174
|
+
*/
|
|
175
|
+
function presenceDue(presence) {
|
|
176
|
+
const deadline = presence.nextDeadline();
|
|
177
|
+
return presence.dirty && presence.windowEnd < deadline ? presence.windowEnd : deadline;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
67
180
|
* RealtimeHub — broker temps réel **PAR INSTANCE** (1 pod = 1 process = 1 hub local).
|
|
68
181
|
*
|
|
69
182
|
* Élève le realtime serveur du modèle **per-connexion** (chaque connexion son propre
|
|
@@ -92,17 +205,49 @@ function channelFamily(channel) {
|
|
|
92
205
|
*/
|
|
93
206
|
var RealtimeHub = class RealtimeHub {
|
|
94
207
|
#channels = null;
|
|
208
|
+
#presenceActive = null;
|
|
209
|
+
#presenceTimer = null;
|
|
210
|
+
#presenceTimerAt = Number.POSITIVE_INFINITY;
|
|
211
|
+
#presenceBus = null;
|
|
212
|
+
#presenceServed = null;
|
|
213
|
+
/**
|
|
214
|
+
* Présences RÉPARTIES dont le dernier client local est parti pendant une grâce :
|
|
215
|
+
* encore servies au bus jusqu'à l'échéance, reprises si le canal se rouvre ici
|
|
216
|
+
* (ADR-0025 P5). `null` tant qu'aucune n'attend — le cas de presque tous les pods.
|
|
217
|
+
*/
|
|
218
|
+
#presenceLingering = null;
|
|
219
|
+
/** Publication remise aux fabriques de canal — UNE fermeture par hub, pas une par canal ouvert. */
|
|
220
|
+
#publishFn = (ch, payload) => {
|
|
221
|
+
this.publish(ch, payload);
|
|
222
|
+
};
|
|
223
|
+
/** Rappel du tic de présence — UNE fermeture par hub, pas une par armement. */
|
|
224
|
+
#presenceTickFn = () => {
|
|
225
|
+
this.#presenceTick();
|
|
226
|
+
};
|
|
227
|
+
#presenceDeclared = false;
|
|
228
|
+
#presenceRejectedTotal = 0;
|
|
229
|
+
#presenceListTotal = 0;
|
|
230
|
+
#presenceFrameTotal = 0;
|
|
231
|
+
#presenceListBuiltTotal = 0;
|
|
95
232
|
#publishTotal = 0;
|
|
96
233
|
#fanoutTotal = 0;
|
|
234
|
+
#closedBytesSent = 0;
|
|
235
|
+
#closedMessagesSent = 0;
|
|
236
|
+
#closedDropped = 0;
|
|
237
|
+
#closedBinaryDiscarded = 0;
|
|
238
|
+
#closedMalformedDiscarded = 0;
|
|
97
239
|
#inboundTotal = 0;
|
|
98
240
|
#ingressRejectedTotal = 0;
|
|
241
|
+
#ingressUnknownDiscardedTotal = 0;
|
|
242
|
+
#localOnlyTotal = 0;
|
|
99
243
|
#systemFloorDeniedTotal = 0;
|
|
100
244
|
#notice = null;
|
|
101
245
|
#noticed = null;
|
|
102
246
|
#connections = null;
|
|
103
247
|
#revocation = new RevocationWatch({
|
|
104
248
|
isValid: (entry) => entry.token.isValid?.(),
|
|
105
|
-
|
|
249
|
+
status: (entry) => entry.token.status?.(),
|
|
250
|
+
revoke: (entry, status) => status === "expired" ? entry.close(TOKEN_EXPIRED_CLOSE_CODE, "token expired") : entry.close(IDENTITY_DEAD_CLOSE_CODE, "session revoked"),
|
|
106
251
|
identityOf: (entry) => entry.token.getUserIdentifier(),
|
|
107
252
|
refresh: (entry) => entry.close(IDENTITY_REFRESH_CLOSE_CODE, "identity changed")
|
|
108
253
|
});
|
|
@@ -111,7 +256,16 @@ var RealtimeHub = class RealtimeHub {
|
|
|
111
256
|
#peerTokens = null;
|
|
112
257
|
#frameAuthorizer = null;
|
|
113
258
|
#channelProbe = null;
|
|
259
|
+
#retainers = null;
|
|
114
260
|
#channelPolicies = null;
|
|
261
|
+
#policyDeclarations = null;
|
|
262
|
+
/**
|
|
263
|
+
* Classes de contrôleur dont les politiques sont déjà inscrites — `null`
|
|
264
|
+
* tant qu'aucune ne l'est. Une poignée de main n'en paie qu'un `has` :
|
|
265
|
+
* l'inscription elle-même n'a lieu qu'une fois par classe.
|
|
266
|
+
*/
|
|
267
|
+
#policyOwners = null;
|
|
268
|
+
#policyClaims = null;
|
|
115
269
|
/**
|
|
116
270
|
* Politiques déclarées par MOTIF de nom (`chat:room:*`), triées du plus
|
|
117
271
|
* spécifique au plus général, et `null` tant qu'aucune n'est déclarée.
|
|
@@ -133,6 +287,14 @@ var RealtimeHub = class RealtimeHub {
|
|
|
133
287
|
#originGuard = null;
|
|
134
288
|
#maxChannelsPerConnection = 256;
|
|
135
289
|
#slowConsumerBytes = SLOW_CONSUMER_BYTES;
|
|
290
|
+
#frameCodecs = frameCodecSet();
|
|
291
|
+
#busBytes = false;
|
|
292
|
+
/**
|
|
293
|
+
* Budget des admissions de l'exemplaire (appels d'`allow` en vol, coupe-circuit,
|
|
294
|
+
* `allowTimedOutTotal`/`allowSaturatedTotal`) — partagé par les files de toutes
|
|
295
|
+
* les connexions. Bornes posées par {@link setAdmissionLimits} au boot.
|
|
296
|
+
*/
|
|
297
|
+
admissionBudget = new AdmissionBudget();
|
|
136
298
|
#broadcastPrefixes = null;
|
|
137
299
|
/**
|
|
138
300
|
* Abonnement demandé par une **connexion cliente** — même chose que
|
|
@@ -148,9 +310,11 @@ var RealtimeHub = class RealtimeHub {
|
|
|
148
310
|
* sécurité en pose un, c'est lui qui décide (avec les rôles, ce que le hub ne
|
|
149
311
|
* sait pas faire) et le plancher s'efface.
|
|
150
312
|
*
|
|
313
|
+
* @param member - la connexion qui s'abonne : inscrite au registre du canal,
|
|
314
|
+
* elle devient un membre identifiable ({@link channelMembers}).
|
|
151
315
|
* @returns `true` si abonné, `false` si canal inconnu ou fermé par le plancher.
|
|
152
316
|
*/
|
|
153
|
-
subscribeClient(channel, sink, factory,
|
|
317
|
+
subscribeClient(channel, sink, factory, buildFrame, member) {
|
|
154
318
|
if (channel.includes("*")) {
|
|
155
319
|
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
320
|
return false;
|
|
@@ -160,7 +324,117 @@ var RealtimeHub = class RealtimeHub {
|
|
|
160
324
|
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
325
|
return false;
|
|
162
326
|
}
|
|
163
|
-
|
|
327
|
+
if (!this.subscribe(channel, sink, factory, buildFrame)) return false;
|
|
328
|
+
if (member !== void 0) {
|
|
329
|
+
const st = this.#channels?.get(channel);
|
|
330
|
+
if (st !== void 0) {
|
|
331
|
+
if (st.members === null) {
|
|
332
|
+
st.members = /* @__PURE__ */ new Map();
|
|
333
|
+
const settings = presenceSettingsOf(this.resolveChannelPolicy(channel));
|
|
334
|
+
if (settings !== null) this.#openPresence(st, channel, settings);
|
|
335
|
+
}
|
|
336
|
+
st.members.set(sink, member);
|
|
337
|
+
const presence = st.presence;
|
|
338
|
+
if (presence !== null) {
|
|
339
|
+
presence.join(presenceKeyOf(member));
|
|
340
|
+
if (presence.distributed) this.#presenceBusChanged(presence);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Ouvre la présence d'un canal à son premier client : répartie s'il est diffusé et qu'un bus est branché, locale sinon (avis unique : un bus est là, ce canal n'en profite pas).
|
|
348
|
+
*/
|
|
349
|
+
#openPresence(st, channel, settings) {
|
|
350
|
+
const backplane = this.#backplane;
|
|
351
|
+
const distributed = backplane !== null && st.forward;
|
|
352
|
+
const lingering = this.#presenceLingering?.get(channel);
|
|
353
|
+
if (lingering !== void 0 && distributed) {
|
|
354
|
+
this.#presenceLingering?.delete(channel);
|
|
355
|
+
if (this.#presenceLingering?.size === 0) this.#presenceLingering = null;
|
|
356
|
+
lingering.live = true;
|
|
357
|
+
st.presence = lingering;
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
const presence = new ChannelPresence(channel, settings, distributed);
|
|
361
|
+
st.presence = presence;
|
|
362
|
+
if (!distributed) {
|
|
363
|
+
if (backplane !== null) this.#notifyOnce("presence-local", `realtime: le canal « ${channel} » déclare la présence sans être diffusé entre serveurs (@RealtimeBroadcast) : sa présence ne compte que les connexions de CE serveur.`, "INFO");
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const bus = this.#presenceBusFor(backplane);
|
|
367
|
+
(this.#presenceServed ??= /* @__PURE__ */ new Set()).add(presence);
|
|
368
|
+
const now = performance.now();
|
|
369
|
+
bus.served(presence, now);
|
|
370
|
+
this.#armPresenceAt(bus.due(), now);
|
|
371
|
+
}
|
|
372
|
+
/** La présence répartie de ce hub, créée au premier besoin. */
|
|
373
|
+
#presenceBusFor(backplane) {
|
|
374
|
+
const existing = this.#presenceBus;
|
|
375
|
+
if (existing !== null) return existing;
|
|
376
|
+
const bus = new PresenceBus({
|
|
377
|
+
originId: backplane.originId,
|
|
378
|
+
publish: (channel, payload) => backplane.publish(channel, payload),
|
|
379
|
+
lookup: (channel) => this.#presenceLookup(channel),
|
|
380
|
+
served: (channel) => {
|
|
381
|
+
const presence = this.#channels?.get(channel)?.presence;
|
|
382
|
+
return presence != null && presence.distributed && presence.live ? presence : null;
|
|
383
|
+
},
|
|
384
|
+
servedPresences: () => this.#presenceServed ?? EMPTY_PRESENCES,
|
|
385
|
+
servedCount: () => this.#presenceServed?.size ?? 0,
|
|
386
|
+
echoTotal: () => backplane.describe().echoTotal
|
|
387
|
+
});
|
|
388
|
+
this.#presenceBus = bus;
|
|
389
|
+
return bus;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Résout un canal nommé par un pair : sa présence s'il est servi et diffusé ici ; `null` s'il déclare la présence et se diffuse sans être servi (lu puis jeté) ; `false` sinon (refusé, compté).
|
|
393
|
+
*/
|
|
394
|
+
#presenceLookup(channel) {
|
|
395
|
+
const presence = this.#channels?.get(channel)?.presence;
|
|
396
|
+
if (presence != null && presence.distributed && presence.live) return presence;
|
|
397
|
+
if (!this.#isBroadcast(channel) || presenceSettingsOf(this.resolveChannelPolicy(channel)) === null) return false;
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
/** La part locale d'une présence répartie a changé : prochain lot armé. */
|
|
401
|
+
#presenceBusChanged(presence) {
|
|
402
|
+
const bus = this.#presenceBus;
|
|
403
|
+
if (bus === null) return;
|
|
404
|
+
const now = performance.now();
|
|
405
|
+
bus.changed(presence, now);
|
|
406
|
+
this.#armPresenceAt(bus.due(), now);
|
|
407
|
+
}
|
|
408
|
+
/** Rafraîchit les canaux que le bus a touchés (vue des pairs, état incomplet). */
|
|
409
|
+
#presenceTouchFromBus(bus, now) {
|
|
410
|
+
const touched = bus.takeTouched();
|
|
411
|
+
if (touched === null) return;
|
|
412
|
+
const channels = this.#channels;
|
|
413
|
+
for (const presence of touched) {
|
|
414
|
+
const st = channels?.get(presence.channel);
|
|
415
|
+
if (st === void 0 || st.presence !== presence) continue;
|
|
416
|
+
if (presence.busDirty) bus.changed(presence, now);
|
|
417
|
+
this.#presenceTouch(st, presence, now);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Entrée des deux canaux de présence du bus (ADR-0025 P2) — lots, demandes, réponses, preuve de vie.
|
|
422
|
+
*/
|
|
423
|
+
#admitPresence(msg, alive) {
|
|
424
|
+
let bus = this.#presenceBus;
|
|
425
|
+
if (bus === null) {
|
|
426
|
+
const backplane = this.#backplane;
|
|
427
|
+
if (!this.#presenceDeclared || backplane === null) {
|
|
428
|
+
this.#presenceRejectedTotal += 1;
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
bus = this.#presenceBusFor(backplane);
|
|
432
|
+
}
|
|
433
|
+
const now = performance.now();
|
|
434
|
+
if (alive) bus.receiveAlive(msg.originId, msg.payload, now);
|
|
435
|
+
else if (bus.receive(msg.originId, msg.payload, now)) this.#discardUnknownFromBus("presence");
|
|
436
|
+
this.#presenceTouchFromBus(bus, now);
|
|
437
|
+
this.#armPresenceAt(bus.due(), now);
|
|
164
438
|
}
|
|
165
439
|
/**
|
|
166
440
|
* Abonne un consommateur qui **répond du canal** : il apporte la `factory`, donc
|
|
@@ -175,13 +449,13 @@ var RealtimeHub = class RealtimeHub {
|
|
|
175
449
|
*
|
|
176
450
|
* @returns `true` si abonné, `false` si canal inconnu (`factory` a renvoyé `null`).
|
|
177
451
|
*/
|
|
178
|
-
subscribe(channel, sink, factory,
|
|
452
|
+
subscribe(channel, sink, factory, buildFrame) {
|
|
179
453
|
const channels = this.#channels ??= /* @__PURE__ */ new Map();
|
|
180
454
|
let st = channels.get(channel);
|
|
181
455
|
if (st) {
|
|
182
456
|
if (st.owned) {
|
|
183
457
|
st.sinks.add(sink);
|
|
184
|
-
if (st.
|
|
458
|
+
if (st.buildFrame === null && buildFrame !== void 0) st.buildFrame = buildFrame;
|
|
185
459
|
return true;
|
|
186
460
|
}
|
|
187
461
|
st.sinks.add(sink);
|
|
@@ -192,14 +466,16 @@ var RealtimeHub = class RealtimeHub {
|
|
|
192
466
|
}
|
|
193
467
|
st.dispose = dispose;
|
|
194
468
|
st.owned = true;
|
|
195
|
-
if (
|
|
469
|
+
if (buildFrame !== void 0) st.buildFrame = buildFrame;
|
|
196
470
|
return true;
|
|
197
471
|
}
|
|
198
472
|
st = {
|
|
199
473
|
dispose: null,
|
|
200
474
|
owned: false,
|
|
201
|
-
sinks:
|
|
202
|
-
|
|
475
|
+
sinks: singleSink(sink),
|
|
476
|
+
members: null,
|
|
477
|
+
presence: null,
|
|
478
|
+
buildFrame: buildFrame ?? null,
|
|
203
479
|
messages: 0,
|
|
204
480
|
forward: this.#isBroadcast(channel)
|
|
205
481
|
};
|
|
@@ -237,8 +513,10 @@ var RealtimeHub = class RealtimeHub {
|
|
|
237
513
|
channels.set(channel, {
|
|
238
514
|
dispose: null,
|
|
239
515
|
owned: false,
|
|
240
|
-
sinks:
|
|
241
|
-
|
|
516
|
+
sinks: singleSink(sink),
|
|
517
|
+
members: null,
|
|
518
|
+
presence: null,
|
|
519
|
+
buildFrame: null,
|
|
242
520
|
messages: 0,
|
|
243
521
|
forward: this.#isBroadcast(channel)
|
|
244
522
|
});
|
|
@@ -255,11 +533,12 @@ var RealtimeHub = class RealtimeHub {
|
|
|
255
533
|
* canaux SYSTÈME en dernier recours. `null` = canal inconnu des deux.
|
|
256
534
|
*/
|
|
257
535
|
#createProvider(channel, factory) {
|
|
258
|
-
const publishFn =
|
|
536
|
+
const publishFn = this.#publishFn;
|
|
259
537
|
const dispose = factory(channel, publishFn);
|
|
260
538
|
if (dispose !== null) return dispose;
|
|
261
539
|
const sys = this.#systemChannelFactories?.get(channel);
|
|
262
|
-
|
|
540
|
+
if (sys) return sys(channel, publishFn);
|
|
541
|
+
return channel.startsWith(USER_CHANNEL_PREFIX) ? USER_CHANNEL_DISPOSE : null;
|
|
263
542
|
}
|
|
264
543
|
/**
|
|
265
544
|
* Désabonne une connexion d'un canal. Au **dernier** abonné, `dispose()` le provider
|
|
@@ -270,14 +549,356 @@ var RealtimeHub = class RealtimeHub {
|
|
|
270
549
|
const st = channels?.get(channel);
|
|
271
550
|
if (!channels || !st) return;
|
|
272
551
|
st.sinks.delete(sink);
|
|
552
|
+
const presence = st.presence;
|
|
553
|
+
if (presence !== null) {
|
|
554
|
+
const member = st.members?.get(sink);
|
|
555
|
+
if (member !== void 0) {
|
|
556
|
+
const now = performance.now();
|
|
557
|
+
presence.leave(presenceKeyOf(member), now);
|
|
558
|
+
this.#presenceTouch(st, presence, now);
|
|
559
|
+
if (presence.distributed) this.#presenceBusChanged(presence);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
st.members?.delete(sink);
|
|
273
563
|
if (st.sinks.size === 0) {
|
|
274
564
|
try {
|
|
275
565
|
st.dispose?.();
|
|
276
566
|
} catch {}
|
|
277
567
|
channels.delete(channel);
|
|
568
|
+
if (presence !== null) {
|
|
569
|
+
presence.live = false;
|
|
570
|
+
if (!presence.distributed) {} else if (presence.hasLocalGrace) this.#lingerPresence(presence);
|
|
571
|
+
else this.#releasePresence(presence);
|
|
572
|
+
}
|
|
278
573
|
}
|
|
279
574
|
}
|
|
280
575
|
/**
|
|
576
|
+
* Le dernier client local d'une présence répartie est parti pendant une grâce : elle reste servie au bus jusqu'à l'échéance (ADR-0025 P5) — le départ n'est publié qu'à ce moment, et un retour sur ce pod l'annule.
|
|
577
|
+
*/
|
|
578
|
+
#lingerPresence(presence) {
|
|
579
|
+
(this.#presenceLingering ??= /* @__PURE__ */ new Map()).set(presence.channel, presence);
|
|
580
|
+
this.#armPresenceAt(presence.nextDeadline(), performance.now());
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Un canal à présence répartie n'est plus servi ici : sa part locale part à zéro au prochain lot (aucun fantôme chez les pairs), sa vue des pairs est jetée.
|
|
584
|
+
*/
|
|
585
|
+
#releasePresence(presence) {
|
|
586
|
+
this.#presenceServed?.delete(presence);
|
|
587
|
+
const bus = this.#presenceBus;
|
|
588
|
+
if (bus === null) return;
|
|
589
|
+
bus.remote.dropChannel(presence.channel);
|
|
590
|
+
presence.release();
|
|
591
|
+
this.#presenceBusChanged(presence);
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Présence d'un canal vue de CE pod (ADR-0025 P3) — `count`, `unnamed`, `members` (toujours, quel que soit le seuil de la trame), `partial`.
|
|
595
|
+
*
|
|
596
|
+
* La voie de l'observateur qui lit sans figurer (modérateur, tableau de
|
|
597
|
+
* bord). Chemin froid : une copie à chaque appel.
|
|
598
|
+
*
|
|
599
|
+
* @param channel - nom exact du canal.
|
|
600
|
+
* @returns la vue (vide si personne n'est là), ou `null` si la politique du
|
|
601
|
+
* canal ne déclare pas `presence`.
|
|
602
|
+
*/
|
|
603
|
+
presence(channel) {
|
|
604
|
+
const live = this.#channels?.get(channel)?.presence;
|
|
605
|
+
if (live != null) return live.view();
|
|
606
|
+
if (presenceSettingsOf(this.resolveChannelPolicy(channel)) === null) return null;
|
|
607
|
+
return {
|
|
608
|
+
count: 0,
|
|
609
|
+
unnamed: 0,
|
|
610
|
+
members: /* @__PURE__ */ new Map(),
|
|
611
|
+
partial: false
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Arrêt propre de la présence répartie (ADR-0025 P5) — le dernier lot part, puis une preuve de vie FINALE : les pairs retirent aussitôt les présents de ce serveur, sans attendre le délai d'oubli. Plus rien ne part ensuite ; idempotent, sans effet sans bus.
|
|
616
|
+
*
|
|
617
|
+
* Appelé par le module à l'arrêt du noyau (`onTerminate`), AVANT que le bus ne
|
|
618
|
+
* se ferme. Les connexions qui se ferment ensuite ne publient plus rien : la
|
|
619
|
+
* preuve de vie finale les a déjà retirées chez les pairs.
|
|
620
|
+
*/
|
|
621
|
+
endPresence() {
|
|
622
|
+
const bus = this.#presenceBus;
|
|
623
|
+
if (bus === null) return;
|
|
624
|
+
bus.end();
|
|
625
|
+
if (this.#presenceTimer !== null) clearTimeout(this.#presenceTimer);
|
|
626
|
+
this.#presenceTimer = null;
|
|
627
|
+
this.#presenceTimerAt = Number.POSITIVE_INFINITY;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Un abonnement vient d'être acquitté : sur un canal à présence, la liste complète part vers CETTE connexion, puis les changements en attente partent si la fenêtre le permet.
|
|
631
|
+
*
|
|
632
|
+
* Appelé par le contrôleur APRÈS `realtime:subscribed`, à chaque (re-)`subscribe`
|
|
633
|
+
* — aucune borne : la contre-pression peut avoir jeté la liste d'avant sans
|
|
634
|
+
* le dire, et c'est le re-`subscribe` qui répare (ADR-0025 P2 bis). La liste
|
|
635
|
+
* est encodée une fois par envoi de changements et par codec, puis servie
|
|
636
|
+
* telle quelle.
|
|
637
|
+
*
|
|
638
|
+
* @param channel - nom exact du canal acquitté.
|
|
639
|
+
* @param connection - la connexion abonnée.
|
|
640
|
+
*/
|
|
641
|
+
presenceSubscribed(channel, connection) {
|
|
642
|
+
const st = this.#channels?.get(channel);
|
|
643
|
+
const presence = st?.presence;
|
|
644
|
+
if (st === void 0 || presence == null) return;
|
|
645
|
+
if (presence.listFrame === null) {
|
|
646
|
+
presence.listFrame = new ChannelFrame(JsonRpcPeer.buildNotification(REALTIME_PRESENCE_METHOD, presence.listParams()));
|
|
647
|
+
this.#presenceListBuiltTotal += 1;
|
|
648
|
+
}
|
|
649
|
+
this.#presenceListTotal += 1;
|
|
650
|
+
connection.deliver(presence.listFrame);
|
|
651
|
+
this.#presenceTouch(st, presence, performance.now());
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Un changement de présence vient d'avoir lieu : part tout de suite sur un canal calme (front montant), sinon au tic qui ferme la fenêtre.
|
|
655
|
+
*/
|
|
656
|
+
#presenceTouch(st, presence, now) {
|
|
657
|
+
if (presence.dirty && now >= presence.windowEnd) this.#presenceFlush(st, presence, now);
|
|
658
|
+
const due = presenceDue(presence);
|
|
659
|
+
if (due === 1e400) return;
|
|
660
|
+
(this.#presenceActive ??= /* @__PURE__ */ new Set()).add(st);
|
|
661
|
+
this.#armPresenceAt(due, now);
|
|
662
|
+
}
|
|
663
|
+
/** Envoie les changements en attente à tous les membres, et ouvre une fenêtre. */
|
|
664
|
+
#presenceFlush(st, presence, now) {
|
|
665
|
+
const params = presence.takeChanges();
|
|
666
|
+
if (params === null) return;
|
|
667
|
+
presence.windowEnd = now + 250;
|
|
668
|
+
const members = st.members;
|
|
669
|
+
if (members === null || members.size === 0) return;
|
|
670
|
+
const frame = new ChannelFrame(JsonRpcPeer.buildNotification(REALTIME_PRESENCE_METHOD, params));
|
|
671
|
+
this.#presenceFrameTotal += 1;
|
|
672
|
+
for (const member of members.values()) try {
|
|
673
|
+
member.deliver(frame);
|
|
674
|
+
} catch {}
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Avance le minuteur UNIQUE de la présence à `at` s'il est plus proche que l'échéance déjà armée — O(1) par changement, aucun parcours des canaux, aucun minuteur par canal ni par présent.
|
|
678
|
+
*/
|
|
679
|
+
#armPresenceAt(next, now) {
|
|
680
|
+
if (this.#presenceTimer !== null && this.#presenceTimerAt <= next) return;
|
|
681
|
+
if (this.#presenceTimer !== null) clearTimeout(this.#presenceTimer);
|
|
682
|
+
this.#presenceTimerAt = next;
|
|
683
|
+
this.#presenceTimer = setTimeout(this.#presenceTickFn, Math.max(0, Math.ceil(next - now)));
|
|
684
|
+
this.#presenceTimer.unref();
|
|
685
|
+
}
|
|
686
|
+
/** Tic unique : grâces écoulées, fenêtres échues ; se retire quand plus rien n'attend. */
|
|
687
|
+
#presenceTick() {
|
|
688
|
+
this.#presenceTimer = null;
|
|
689
|
+
this.#presenceTimerAt = Number.POSITIVE_INFINITY;
|
|
690
|
+
const now = performance.now();
|
|
691
|
+
const bus = this.#presenceBus;
|
|
692
|
+
if (bus !== null) {
|
|
693
|
+
bus.tick(now);
|
|
694
|
+
this.#presenceTouchFromBus(bus, now);
|
|
695
|
+
}
|
|
696
|
+
let next = Number.POSITIVE_INFINITY;
|
|
697
|
+
const active = this.#presenceActive;
|
|
698
|
+
if (active !== null) {
|
|
699
|
+
for (const st of active) {
|
|
700
|
+
const presence = st.presence;
|
|
701
|
+
if (presence === null || !presence.live) {
|
|
702
|
+
active.delete(st);
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
presence.expire(now);
|
|
706
|
+
if (bus !== null && presence.busDirty) bus.changed(presence, now);
|
|
707
|
+
if (presence.dirty && now >= presence.windowEnd) this.#presenceFlush(st, presence, now);
|
|
708
|
+
const due = presenceDue(presence);
|
|
709
|
+
if (due === 1e400) active.delete(st);
|
|
710
|
+
else if (due < next) next = due;
|
|
711
|
+
}
|
|
712
|
+
if (active.size === 0) this.#presenceActive = null;
|
|
713
|
+
}
|
|
714
|
+
const lingering = this.#presenceLingering;
|
|
715
|
+
if (lingering !== null) {
|
|
716
|
+
for (const presence of lingering.values()) {
|
|
717
|
+
presence.expire(now);
|
|
718
|
+
if (bus !== null && presence.busDirty) bus.changed(presence, now);
|
|
719
|
+
if (presence.hasLocalGrace) {
|
|
720
|
+
const due = presence.nextDeadline();
|
|
721
|
+
if (due < next) next = due;
|
|
722
|
+
} else {
|
|
723
|
+
lingering.delete(presence.channel);
|
|
724
|
+
this.#releasePresence(presence);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
if (lingering.size === 0) this.#presenceLingering = null;
|
|
728
|
+
}
|
|
729
|
+
if (bus !== null) {
|
|
730
|
+
const due = bus.due();
|
|
731
|
+
if (due < next) next = due;
|
|
732
|
+
}
|
|
733
|
+
if (next !== Number.POSITIVE_INFINITY) this.#armPresenceAt(next, now);
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Les connexions abonnées à un canal par un client — **local à ce pod** : les membres servis par un autre exemplaire n'y figurent pas.
|
|
737
|
+
*
|
|
738
|
+
* Les écouteurs et services du serveur (`listen`, `subscribe`) ne sont pas
|
|
739
|
+
* des membres. Chemin froid : une copie à chaque appel.
|
|
740
|
+
*
|
|
741
|
+
* @param channel - nom exact du canal.
|
|
742
|
+
* @returns les membres de ce pod, vide si le canal n'en a aucun ici.
|
|
743
|
+
*/
|
|
744
|
+
channelMembers(channel) {
|
|
745
|
+
const members = this.#channels?.get(channel)?.members;
|
|
746
|
+
return members == null ? [] : Array.from(members.values());
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Abonne d'office une connexion à un canal — **local à ce pod** : seule une connexion acceptée par ce processus peut l'être.
|
|
750
|
+
*
|
|
751
|
+
* Même porte qu'un `subscribe` du client (ADR-0022 §6 décision 2) : la
|
|
752
|
+
* politique fixe du canal (rôles, scopes, plancher de plateforme), puis sa
|
|
753
|
+
* règle `allow` s'il en déclare une. Un refus n'abonne pas, et seul
|
|
754
|
+
* l'appelant l'apprend ; jamais un abonnement qui contourne `allow` — le
|
|
755
|
+
* client rejoue ses abonnements à chaque reconnexion, et ce rejeu passe par
|
|
756
|
+
* `allow` : l'application écrit donc l'appartenance là où `allow` la lit.
|
|
757
|
+
*
|
|
758
|
+
* Le client est notifié par `realtime:subscribed { channel, imposed: true }`
|
|
759
|
+
* et inscrit le canal dans ses abonnements ; il peut le quitter.
|
|
760
|
+
*
|
|
761
|
+
* Plusieurs exemplaires : la requête qui décide doit atteindre le processus
|
|
762
|
+
* qui tient la socket — la passer par la socket elle-même (`@RealtimeInbound`,
|
|
763
|
+
* qui reçoit la connexion), ou faire autoriser l'entrée par `allow` (registre
|
|
764
|
+
* partagé) et laisser le navigateur s'abonner.
|
|
765
|
+
*
|
|
766
|
+
* @param channel - nom exact du canal.
|
|
767
|
+
* @param connection - la connexion à abonner (reçue par un gestionnaire `@RealtimeInbound`, ou lue dans {@link channelMembers}).
|
|
768
|
+
* @returns `true` une fois l'abonnement acquitté ; `false` si refusé, si la
|
|
769
|
+
* connexion est fermée ou inconnue de ce processus, ou si le client s'en
|
|
770
|
+
* désabonne avant le verdict.
|
|
771
|
+
*/
|
|
772
|
+
subscribeConnection(channel, connection) {
|
|
773
|
+
if (!(connection instanceof RealtimeConnection)) return Promise.resolve(false);
|
|
774
|
+
return connection.impose(channel);
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Retire une connexion d'un canal — **local à ce pod** ; elle reçoit `realtime:closed { reason: "evicted" }`.
|
|
778
|
+
*
|
|
779
|
+
* ⚠️ **Éjecter coupe la connexion du canal, seul `allow` exclut**
|
|
780
|
+
* (ADR-0022 §6 décision 3). Le client ne rejoue pas un canal dont il a été
|
|
781
|
+
* éjecté, mais c'est une politesse : une page rechargée refait `subscribe`.
|
|
782
|
+
* Sur un canal à `allow`, l'éjecté revient si l'application n'a pas retiré
|
|
783
|
+
* l'appartenance là où `allow` la lit ; sur un canal sans `allow`, sa
|
|
784
|
+
* politique fixe le laisse rentrer.
|
|
785
|
+
*
|
|
786
|
+
* @param channel - nom exact du canal.
|
|
787
|
+
* @param connection - la connexion à retirer.
|
|
788
|
+
* @returns `false` si la connexion n'est pas membre du canal, est fermée, ou
|
|
789
|
+
* inconnue de ce processus — jamais un geste muet.
|
|
790
|
+
*/
|
|
791
|
+
evictConnection(channel, connection) {
|
|
792
|
+
return connection instanceof RealtimeConnection && connection.end(channel, "evicted");
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Termine un canal pour tous ses membres, **sur tous les serveurs** : chacun reçoit `realtime:closed { reason: "ended" }`, et le producteur est libéré.
|
|
796
|
+
*
|
|
797
|
+
* **Traverse les pods** : avec un backplane branché, l'ordre part sur le bus
|
|
798
|
+
* (`PLATFORM_BUS_CHANNELS.control`, scellé comme toute publication) et chaque
|
|
799
|
+
* serveur l'applique à SES membres. Livraison « au plus une fois » du bus :
|
|
800
|
+
* un pod qui manque l'ordre, ou qui démarre après, est rattrapé par la
|
|
801
|
+
* fabrique, qui rend `null` pour une instance terminée. Un canal de la
|
|
802
|
+
* plateforme (`nodefony:`) reste local — il décrit un pod, pas une partie.
|
|
803
|
+
*
|
|
804
|
+
* Les écouteurs du serveur (`listen`) restent inscrits, sur un canal redevenu
|
|
805
|
+
* PASSIF : le prochain `subscribe` d'un client rejoue la fabrique, et c'est
|
|
806
|
+
* elle qui tranche. **Seule la fabrique clôt un canal** (ADR-0021 S4) — le
|
|
807
|
+
* hub ne garde aucune marque « fermé » : une fabrique qui rend encore un
|
|
808
|
+
* producteur pour ce nom le rouvre. D'où la règle d'écriture d'un canal à
|
|
809
|
+
* durée de vie : son nom porte l'identifiant de l'instance
|
|
810
|
+
* (`room:42:<partie>`), et la fabrique rend `null` pour une instance terminée.
|
|
811
|
+
*
|
|
812
|
+
* @param channel - nom exact du canal.
|
|
813
|
+
* @returns le nombre de membres de CE pod à qui la fin a été notifiée — les
|
|
814
|
+
* autres serveurs l'appliquent sans compte rendu.
|
|
815
|
+
*/
|
|
816
|
+
closeChannel(channel) {
|
|
817
|
+
this.#sendControl({
|
|
818
|
+
op: "close",
|
|
819
|
+
channel
|
|
820
|
+
});
|
|
821
|
+
return this.#closeLocal(channel);
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Éjecte un compte d'un canal, **sur tous les serveurs** : chacune de ses connexions membres reçoit `realtime:closed { reason: "evicted" }`.
|
|
825
|
+
*
|
|
826
|
+
* Tous les onglets du compte sont visés, où qu'ils soient servis ; l'ordre
|
|
827
|
+
* traverse les pods comme {@link closeChannel}. ⚠️ **Éjecter coupe, seul
|
|
828
|
+
* `allow` exclut** (ADR-0022 §6 décision 3) : pour que le compte ne revienne
|
|
829
|
+
* pas, l'application retire l'appartenance là où `allow` la lit.
|
|
830
|
+
*
|
|
831
|
+
* ⚠️ Sur un bus PARTAGÉ, un tiers qui y écrit peut rejouer un ordre capté :
|
|
832
|
+
* sur un canal durable, il recouperait le compte à chaque réadmission (risque
|
|
833
|
+
* accepté et borné, ADR-0022 §6 d4) — bus privé exigé ; changer le secret du
|
|
834
|
+
* bus rend caducs les ordres déjà captés.
|
|
835
|
+
*
|
|
836
|
+
* @param channel - nom exact du canal.
|
|
837
|
+
* @param user - identifiant du compte (`IRealtimeConnection.userId`).
|
|
838
|
+
* @returns le nombre de connexions de CE pod éjectées. Un nom vide ou de plus
|
|
839
|
+
* de 1 024 caractères n'éjecte rien et ne part pas sur le bus.
|
|
840
|
+
*/
|
|
841
|
+
evictUser(channel, user) {
|
|
842
|
+
if (!this.#sendControl({
|
|
843
|
+
op: "evictUser",
|
|
844
|
+
channel,
|
|
845
|
+
user
|
|
846
|
+
})) return 0;
|
|
847
|
+
return this.#evictUserLocal(channel, user);
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* 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.
|
|
851
|
+
*
|
|
852
|
+
* @returns `false` si l'ordre est mal formé (rien n'est appliqué nulle part).
|
|
853
|
+
*/
|
|
854
|
+
#sendControl(order) {
|
|
855
|
+
if (readControlOrder(order) === null) return false;
|
|
856
|
+
if (this.#backplane !== null && !isReservedSystemChannel(order.channel)) this.#backplane.publish(PLATFORM_BUS_CHANNELS.control, order);
|
|
857
|
+
return true;
|
|
858
|
+
}
|
|
859
|
+
/** Applique un ordre reçu du bus : forme vérifiée, canal de la plateforme refusé, sinon compté en rejet. */
|
|
860
|
+
#applyControl(payload) {
|
|
861
|
+
const order = readControlOrder(payload);
|
|
862
|
+
if (order === null) {
|
|
863
|
+
if (isUnknownControlOrder(payload)) this.#discardUnknownFromBus("order");
|
|
864
|
+
else this.#ingressRejectedTotal += 1;
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
if (isReservedSystemChannel(order.channel)) {
|
|
868
|
+
this.#ingressRejectedTotal += 1;
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
if (order.op === "close") this.#closeLocal(order.channel);
|
|
872
|
+
else this.#evictUserLocal(order.channel, order.user);
|
|
873
|
+
}
|
|
874
|
+
/** Éjecte de `channel` les connexions de CE pod dont le compte est `user`. */
|
|
875
|
+
#evictUserLocal(channel, user) {
|
|
876
|
+
const members = this.#channels?.get(channel)?.members;
|
|
877
|
+
if (members == null) return 0;
|
|
878
|
+
let evicted = 0;
|
|
879
|
+
for (const member of members.values()) if (member.userId === user && member.end(channel, "evicted")) evicted += 1;
|
|
880
|
+
return evicted;
|
|
881
|
+
}
|
|
882
|
+
/** Termine `channel` pour les membres de CE pod et libère son producteur. */
|
|
883
|
+
#closeLocal(channel) {
|
|
884
|
+
const st = this.#channels?.get(channel);
|
|
885
|
+
if (st === void 0) return 0;
|
|
886
|
+
let ended = 0;
|
|
887
|
+
const members = st.members;
|
|
888
|
+
if (members !== null) {
|
|
889
|
+
for (const member of members.values()) if (member.end(channel, "ended")) ended += 1;
|
|
890
|
+
}
|
|
891
|
+
const rest = this.#channels?.get(channel);
|
|
892
|
+
if (rest?.owned === true) {
|
|
893
|
+
try {
|
|
894
|
+
rest.dispose?.();
|
|
895
|
+
} catch {}
|
|
896
|
+
rest.dispose = null;
|
|
897
|
+
rest.owned = false;
|
|
898
|
+
}
|
|
899
|
+
return ended;
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
281
902
|
* Publie une charge sur un canal : **fan-out local** **+** propagation conditionnelle
|
|
282
903
|
* au {@link IBackplane} (autres workers/pods). C'est le point d'entrée des providers
|
|
283
904
|
* locaux (tickers, listeners) ET de tout code serveur.
|
|
@@ -290,12 +911,65 @@ var RealtimeHub = class RealtimeHub {
|
|
|
290
911
|
*
|
|
291
912
|
* Anti-boucle : un message **reçu** du backplane ne repasse PAS par ici (il appelle
|
|
292
913
|
* `publishLocal` directement) → aucune ré-émission cross-process, pas de tempête.
|
|
914
|
+
*
|
|
915
|
+
* @param payload - objet, tableau ou octets ; omise = signal sans charge.
|
|
916
|
+
* @throws TypeError si la charge est un scalaire ou `null` ({@link isRealtimePayload},
|
|
917
|
+
* ADR-0021 S1) — un scalaire se publie dans un objet (`{ value }`).
|
|
293
918
|
*/
|
|
294
919
|
publish(channel, payload) {
|
|
920
|
+
if (!isRealtimePayload(payload)) throw realtimePayloadError(channel, payload);
|
|
295
921
|
const st = this.#channels?.get(channel);
|
|
296
922
|
if (st !== void 0) this.#fanout(st, payload);
|
|
297
923
|
if (this.#backplane === null) return;
|
|
298
|
-
if (st !== void 0 ? st.forward : this.#isBroadcast(channel))
|
|
924
|
+
if (!(st !== void 0 ? st.forward : this.#isBroadcast(channel))) {
|
|
925
|
+
this.#localOnlyTotal += 1;
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
if (this.#busRefuses(payload)) {
|
|
929
|
+
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`);
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
this.#backplane.publish(channel, payload);
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Le bus refuse-t-il cette charge ? Un `Uint8Array` voyage quand le bus porte
|
|
936
|
+
* les octets ({@link #busBytes}) ; toute autre vue binaire, jamais.
|
|
937
|
+
*
|
|
938
|
+
* Sans bus binaire, le parcours en profondeur n'est payé que si un codec
|
|
939
|
+
* binaire est posé — seul cas où une charge à octets est légitime ; sans codec,
|
|
940
|
+
* seule la charge de premier niveau est examinée, comme avant le codec.
|
|
941
|
+
*/
|
|
942
|
+
#busRefuses(payload) {
|
|
943
|
+
const topLevel = ArrayBuffer.isView(payload) || payload instanceof ArrayBuffer;
|
|
944
|
+
if (this.#busBytes) return topLevel && !(payload instanceof Uint8Array);
|
|
945
|
+
if (this.#frameCodecs.binary !== null) return topLevel || containsBytes(payload);
|
|
946
|
+
return topLevel;
|
|
947
|
+
}
|
|
948
|
+
/** Arme les octets sur le bus quand codec binaire ET backplane sont posés, dans n'importe quel ordre. */
|
|
949
|
+
#armBusBytes() {
|
|
950
|
+
const codec = this.#frameCodecs.binary;
|
|
951
|
+
this.#busBytes = codec !== null && this.#backplane !== null && this.#backplane.enableBytes?.(codec) === true;
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Livre une charge à UNE identité — tous ses onglets, tous les serveurs, et personne d'autre (ADR-0022 §6, D1).
|
|
955
|
+
*
|
|
956
|
+
* Publie sur son canal personnel `user:<userId>` : seuls les onglets de cette
|
|
957
|
+
* identité ont pu s'y abonner (politique intégrée, admission par égalité de
|
|
958
|
+
* chaîne), et le préfixe traverse le bus dès qu'un bus est branché. Rien à
|
|
959
|
+
* déclarer côté application. Personne n'écoute ? La charge est perdue, sans
|
|
960
|
+
* erreur : c'est une diffusion, pas une boîte aux lettres.
|
|
961
|
+
*
|
|
962
|
+
* @param userId - l'`id` STABLE du compte (`RealtimeIdentity.id`), jamais le
|
|
963
|
+
* login.
|
|
964
|
+
* @param payload - objet, tableau ou octets ; omise = signal sans charge.
|
|
965
|
+
* @throws TypeError si la charge est un scalaire ou `null` (cf {@link publish}).
|
|
966
|
+
*/
|
|
967
|
+
publishToUser(userId, payload) {
|
|
968
|
+
this.publish(userChannel(userId), payload);
|
|
969
|
+
}
|
|
970
|
+
/** Préfixes déclarés diffusables entre serveurs, dans l'ordre de déclaration — lu par la ligne de démarrage. */
|
|
971
|
+
broadcastPrefixes() {
|
|
972
|
+
return this.#broadcastPrefixes ?? [];
|
|
299
973
|
}
|
|
300
974
|
/**
|
|
301
975
|
* Fan-out d'une charge aux **seuls abonnés locaux** d'un canal (process courant).
|
|
@@ -313,14 +987,14 @@ var RealtimeHub = class RealtimeHub {
|
|
|
313
987
|
this.#publishTotal += 1;
|
|
314
988
|
st.messages += 1;
|
|
315
989
|
this.#fanoutTotal += st.sinks.size;
|
|
316
|
-
let
|
|
317
|
-
if (st.
|
|
318
|
-
|
|
990
|
+
let frame;
|
|
991
|
+
if (st.buildFrame !== null && st.sinks.size > 1) try {
|
|
992
|
+
frame = new ChannelFrame(st.buildFrame(payload));
|
|
319
993
|
} catch {
|
|
320
|
-
|
|
994
|
+
frame = void 0;
|
|
321
995
|
}
|
|
322
996
|
for (const sink of st.sinks) try {
|
|
323
|
-
sink(payload,
|
|
997
|
+
sink(payload, frame);
|
|
324
998
|
} catch {}
|
|
325
999
|
}
|
|
326
1000
|
/**
|
|
@@ -343,14 +1017,23 @@ var RealtimeHub = class RealtimeHub {
|
|
|
343
1017
|
if (prefixes.includes(prefix)) return;
|
|
344
1018
|
prefixes.push(prefix);
|
|
345
1019
|
if (this.#channels) {
|
|
346
|
-
for (const [channel, st] of this.#channels) if (!st.forward && channel.startsWith(prefix)) st.forward = true;
|
|
1020
|
+
for (const [channel, st] of this.#channels) if (!st.forward && channel.startsWith(prefix) && !isPlatformChannel(channel)) st.forward = true;
|
|
347
1021
|
}
|
|
348
1022
|
}
|
|
349
|
-
/**
|
|
1023
|
+
/**
|
|
1024
|
+
* Un canal est-il broadcast (préfixe déclaré) ? `false` si aucune politique (défaut local).
|
|
1025
|
+
*
|
|
1026
|
+
* Un canal de PLATEFORME ne l'est jamais, quel que soit le préfixe : un
|
|
1027
|
+
* préfixe large (`""`, `"n"`) le couvrirait, et {@link markBroadcastChannel}
|
|
1028
|
+
* ne refuse que le préfixe réservé lui-même. Sans cette exclusion, ses
|
|
1029
|
+
* publications sortaient sur le bus et l'entrée acceptait de faux évènements
|
|
1030
|
+
* d'admin venus d'un pair (F83). Le test n'est payé qu'après une
|
|
1031
|
+
* correspondance de préfixe.
|
|
1032
|
+
*/
|
|
350
1033
|
#isBroadcast(channel) {
|
|
351
1034
|
const prefixes = this.#broadcastPrefixes;
|
|
352
1035
|
if (prefixes === null) return false;
|
|
353
|
-
for (const prefix of prefixes) if (channel.startsWith(prefix)) return
|
|
1036
|
+
for (const prefix of prefixes) if (channel.startsWith(prefix)) return !isPlatformChannel(channel);
|
|
354
1037
|
return false;
|
|
355
1038
|
}
|
|
356
1039
|
/**
|
|
@@ -365,7 +1048,12 @@ var RealtimeHub = class RealtimeHub {
|
|
|
365
1048
|
*/
|
|
366
1049
|
setBackplane(backplane) {
|
|
367
1050
|
this.#backplane = backplane;
|
|
1051
|
+
this.#presenceBus = null;
|
|
1052
|
+
this.#presenceServed = null;
|
|
1053
|
+
this.#presenceLingering = null;
|
|
1054
|
+
this.markBroadcastChannel(USER_CHANNEL_PREFIX);
|
|
368
1055
|
backplane.onMessage((msg) => this.#admitFromBackplane(msg));
|
|
1056
|
+
this.#armBusBytes();
|
|
369
1057
|
const started = backplane.start();
|
|
370
1058
|
if (started instanceof Promise) started.catch((err) => {
|
|
371
1059
|
if (this.#notice === null) throw err instanceof Error ? err : new Error(String(err));
|
|
@@ -390,14 +1078,54 @@ var RealtimeHub = class RealtimeHub {
|
|
|
390
1078
|
* nominale : un pair légitime n'émet que des canaux broadcast (`publish` filtre à
|
|
391
1079
|
* la source), et un canal ne peut avoir d'abonné local que si l'endpoint qui le
|
|
392
1080
|
* sert a été handshaké — donc ses préfixes déjà déclarés.
|
|
1081
|
+
*
|
|
1082
|
+
* Seule exception, AVANT ce contrôle : le canal de contrôle
|
|
1083
|
+
* (`PLATFORM_BUS_CHANNELS.control`), jamais diffusé aux abonnés — ses ordres,
|
|
1084
|
+
* en liste fermée, sont appliqués aux connexions de ce pod ou comptés en rejet.
|
|
1085
|
+
* Puis, avant le contrôle aussi : un AUTRE canal du territoire `nodefony:bus:`
|
|
1086
|
+
* vient d'une version plus récente — jeté et compté à part
|
|
1087
|
+
* ({@link #discardUnknownFromBus}), jamais comme une injection.
|
|
393
1088
|
*/
|
|
394
1089
|
#admitFromBackplane(msg) {
|
|
1090
|
+
const st = this.#channels?.get(msg.channel);
|
|
1091
|
+
if (st?.forward === true) {
|
|
1092
|
+
this.#fanout(st, msg.payload);
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
if (msg.channel === PLATFORM_BUS_CHANNELS.control) {
|
|
1096
|
+
this.#applyControl(msg.payload);
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
if (isPlatformBusChannel(msg.channel)) {
|
|
1100
|
+
if (msg.channel === PLATFORM_BUS_CHANNELS.presence) this.#admitPresence(msg, false);
|
|
1101
|
+
else if (msg.channel === PLATFORM_BUS_CHANNELS.presenceAlive) this.#admitPresence(msg, true);
|
|
1102
|
+
else this.#discardUnknownFromBus("channel");
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
395
1105
|
if (!this.#isBroadcast(msg.channel)) {
|
|
396
1106
|
this.#ingressRejectedTotal += 1;
|
|
397
1107
|
return;
|
|
398
1108
|
}
|
|
399
1109
|
this.publishLocal(msg.channel, msg.payload);
|
|
400
1110
|
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Jette un message du bus venu d'une version plus RÉCENTE — canal `nodefony:bus:` ou type d'ordre inconnus — et le compte à part.
|
|
1113
|
+
*
|
|
1114
|
+
* Règle de lecture figée par l'ADR-0025 (S-rt-92) : pendant un déploiement
|
|
1115
|
+
* progressif, un serveur ancien reçoit à la cadence du message ce qu'une
|
|
1116
|
+
* version suivante ajoute au bus. Le compter dans `#ingressRejectedTotal`
|
|
1117
|
+
* déclencherait de fausses alertes d'injection et noierait la vraie. Rien
|
|
1118
|
+
* n'atteint un abonné ; le journal ne parle qu'une fois par motif (pas une
|
|
1119
|
+
* ligne par message), sans recopier le nom reçu — une donnée venue du bus
|
|
1120
|
+
* n'entre pas dans le journal.
|
|
1121
|
+
*
|
|
1122
|
+
* @param kind - `channel` (canal inconnu) ou `order` (type d'ordre inconnu).
|
|
1123
|
+
*/
|
|
1124
|
+
#discardUnknownFromBus(kind) {
|
|
1125
|
+
this.#ingressUnknownDiscardedTotal += 1;
|
|
1126
|
+
const what = kind === "channel" ? "un canal de la plateforme inconnu" : kind === "order" ? "un ordre de contrôle d'un type inconnu" : "un message de présence d'un type inconnu";
|
|
1127
|
+
this.#notifyOnce(`backplane:unknown-${kind}`, `realtime: ${what} de cette version est arrivé par le bus — ignoré (un serveur plus récent pendant un déploiement ?), compté dans ingressUnknownDiscardedTotal`, "INFO");
|
|
1128
|
+
}
|
|
401
1129
|
/** Backplane cross-process branché, ou `null` en mono-process (lecture/tests/sonde). */
|
|
402
1130
|
get backplane() {
|
|
403
1131
|
return this.#backplane;
|
|
@@ -414,9 +1142,18 @@ var RealtimeHub = class RealtimeHub {
|
|
|
414
1142
|
registerConnection(conn) {
|
|
415
1143
|
(this.#connections ??= /* @__PURE__ */ new Set()).add(conn);
|
|
416
1144
|
}
|
|
417
|
-
/**
|
|
1145
|
+
/**
|
|
1146
|
+
* Retire une connexion du registre de la sonde (au close) et verse ses cumuls à
|
|
1147
|
+
* ceux des connexions fermées : les totaux de {@link probe} restent monotones.
|
|
1148
|
+
* No-op si absente — une double sortie ne compte pas deux fois.
|
|
1149
|
+
*/
|
|
418
1150
|
unregisterConnection(conn) {
|
|
419
|
-
this.#connections?.delete(conn);
|
|
1151
|
+
if (this.#connections?.delete(conn) !== true) return;
|
|
1152
|
+
this.#closedBytesSent += conn.bytesSent;
|
|
1153
|
+
this.#closedMessagesSent += conn.messagesSent;
|
|
1154
|
+
this.#closedDropped += conn.dropped;
|
|
1155
|
+
this.#closedBinaryDiscarded += conn.binaryDiscarded ?? 0;
|
|
1156
|
+
this.#closedMalformedDiscarded += conn.malformedDiscarded ?? 0;
|
|
420
1157
|
}
|
|
421
1158
|
/**
|
|
422
1159
|
* Inscrit une connexion à identité RÉVOCABLE au registre de
|
|
@@ -479,22 +1216,32 @@ var RealtimeHub = class RealtimeHub {
|
|
|
479
1216
|
messages: st.messages
|
|
480
1217
|
});
|
|
481
1218
|
let connectionCount = 0;
|
|
482
|
-
let bytesSentTotal =
|
|
483
|
-
let messagesSentTotal =
|
|
1219
|
+
let bytesSentTotal = this.#closedBytesSent;
|
|
1220
|
+
let messagesSentTotal = this.#closedMessagesSent;
|
|
484
1221
|
let maxBufferedAmount = 0;
|
|
485
1222
|
let totalBufferedAmount = 0;
|
|
486
1223
|
let slowConsumers = 0;
|
|
487
|
-
let drops =
|
|
1224
|
+
let drops = this.#closedDropped;
|
|
1225
|
+
let binaryDiscardedTotal = this.#closedBinaryDiscarded;
|
|
1226
|
+
let malformedDiscardedTotal = this.#closedMalformedDiscarded;
|
|
488
1227
|
if (this.#connections) for (const c of this.#connections) {
|
|
489
1228
|
connectionCount += 1;
|
|
490
1229
|
bytesSentTotal += c.bytesSent;
|
|
491
1230
|
messagesSentTotal += c.messagesSent;
|
|
492
1231
|
drops += c.dropped;
|
|
1232
|
+
binaryDiscardedTotal += c.binaryDiscarded ?? 0;
|
|
1233
|
+
malformedDiscardedTotal += c.malformedDiscarded ?? 0;
|
|
493
1234
|
const buf = c.bufferedAmount;
|
|
494
1235
|
if (buf > maxBufferedAmount) maxBufferedAmount = buf;
|
|
495
1236
|
totalBufferedAmount += buf;
|
|
496
1237
|
if (buf >= this.#slowConsumerBytes) slowConsumers += 1;
|
|
497
1238
|
}
|
|
1239
|
+
const backplane = this.#backplane?.describe() ?? {
|
|
1240
|
+
driver: LoopbackBackplane.driver,
|
|
1241
|
+
kind: "local",
|
|
1242
|
+
originId: String(process.pid),
|
|
1243
|
+
crossPod: false
|
|
1244
|
+
};
|
|
498
1245
|
return {
|
|
499
1246
|
ts: Date.now(),
|
|
500
1247
|
channels,
|
|
@@ -503,7 +1250,16 @@ var RealtimeHub = class RealtimeHub {
|
|
|
503
1250
|
fanoutTotal: this.#fanoutTotal,
|
|
504
1251
|
inboundTotal: this.#inboundTotal,
|
|
505
1252
|
ingressRejectedTotal: this.#ingressRejectedTotal,
|
|
1253
|
+
ingressUnknownDiscardedTotal: this.#ingressUnknownDiscardedTotal,
|
|
1254
|
+
localOnlyTotal: this.#localOnlyTotal,
|
|
506
1255
|
systemFloorDeniedTotal: this.#systemFloorDeniedTotal,
|
|
1256
|
+
sealRejectedTotal: backplane.sealRejectedTotal ?? 0,
|
|
1257
|
+
frameRateRejectedTotal: frameRateRejectedTotal(),
|
|
1258
|
+
allowTimedOutTotal: this.admissionBudget.timedOutTotal,
|
|
1259
|
+
allowSaturatedTotal: this.admissionBudget.saturatedTotal,
|
|
1260
|
+
authenticateTimedOutTotal: this.admissionBudget.authenticateTimedOutTotal,
|
|
1261
|
+
binaryDiscardedTotal,
|
|
1262
|
+
malformedDiscardedTotal,
|
|
507
1263
|
connectionCount,
|
|
508
1264
|
bytesSentTotal,
|
|
509
1265
|
messagesSentTotal,
|
|
@@ -513,12 +1269,38 @@ var RealtimeHub = class RealtimeHub {
|
|
|
513
1269
|
slowConsumers,
|
|
514
1270
|
drops
|
|
515
1271
|
},
|
|
516
|
-
backplane
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
1272
|
+
backplane,
|
|
1273
|
+
presence: this.#presenceProbe()
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1276
|
+
/** Compteurs de la présence (chemin froid : un parcours des canaux ouverts). */
|
|
1277
|
+
#presenceProbe() {
|
|
1278
|
+
let channels = 0;
|
|
1279
|
+
if (this.#channels !== null) {
|
|
1280
|
+
for (const st of this.#channels.values()) if (st.presence !== null) channels += 1;
|
|
1281
|
+
}
|
|
1282
|
+
const bus = this.#presenceBus;
|
|
1283
|
+
const remote = bus?.remote;
|
|
1284
|
+
return {
|
|
1285
|
+
channels,
|
|
1286
|
+
listTotal: this.#presenceListTotal,
|
|
1287
|
+
listBuiltTotal: this.#presenceListBuiltTotal,
|
|
1288
|
+
frameTotal: this.#presenceFrameTotal,
|
|
1289
|
+
distributedChannels: this.#presenceServed?.size ?? 0,
|
|
1290
|
+
pods: remote?.size ?? 0,
|
|
1291
|
+
gaps: remote?.gaps ?? 0,
|
|
1292
|
+
gapTotal: remote?.gapTotal ?? 0,
|
|
1293
|
+
forgottenTotal: remote?.forgottenTotal ?? 0,
|
|
1294
|
+
presenceRejectedTotal: this.#presenceRejectedTotal + (remote?.rejectedTotal ?? 0),
|
|
1295
|
+
presenceStaleDiscardedTotal: remote?.staleTotal ?? 0,
|
|
1296
|
+
batchTotal: bus?.batchTotal ?? 0,
|
|
1297
|
+
requestTotal: bus?.requestTotal ?? 0,
|
|
1298
|
+
answerTotal: bus?.answerTotal ?? 0,
|
|
1299
|
+
aliveTotal: bus?.aliveTotal ?? 0,
|
|
1300
|
+
endedTotal: remote?.endedTotal ?? 0,
|
|
1301
|
+
busLost: bus?.busLost ?? false,
|
|
1302
|
+
busLostTotal: bus?.busLostTotal ?? 0,
|
|
1303
|
+
rearmTotal: bus?.rearmTotal ?? 0
|
|
522
1304
|
};
|
|
523
1305
|
}
|
|
524
1306
|
/** Canaux actifs (≥ 1 abonné). Lecture seule. */
|
|
@@ -593,6 +1375,39 @@ var RealtimeHub = class RealtimeHub {
|
|
|
593
1375
|
this.#slowConsumerBytes = bytes;
|
|
594
1376
|
}
|
|
595
1377
|
/**
|
|
1378
|
+
* Les codecs de frame du serveur (ADR-0022 §2) : le texte, et le codec binaire
|
|
1379
|
+
* accordé aux clients qui l'offrent — `null` tant que la configuration n'en
|
|
1380
|
+
* active aucun. Lu une fois par connexion, au handshake, jamais par trame.
|
|
1381
|
+
*/
|
|
1382
|
+
get frameCodecs() {
|
|
1383
|
+
return this.#frameCodecs;
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* Pose le codec binaire du serveur — 1× au boot par `RealtimeService.init()`,
|
|
1387
|
+
* depuis la clé `codec` de la configuration. Sans argument : texte seul.
|
|
1388
|
+
*
|
|
1389
|
+
* @param codec - le codec activé (`CBOR_FRAME_CODEC` de `nodefony/cbor`).
|
|
1390
|
+
*/
|
|
1391
|
+
setFrameCodec(codec) {
|
|
1392
|
+
this.#frameCodecs = frameCodecSet(codec);
|
|
1393
|
+
this.#armBusBytes();
|
|
1394
|
+
}
|
|
1395
|
+
/**
|
|
1396
|
+
* Le codec des connexions texte — celui qu'emprunte aussi le REST du plan
|
|
1397
|
+
* d'administration, pour qu'une valeur lue par `GET` soit celle que la socket
|
|
1398
|
+
* livre (#618 D9). Avec un codec binaire activé, c'est le JSON à convention
|
|
1399
|
+
* d'octets ; sinon le JSON natif.
|
|
1400
|
+
*/
|
|
1401
|
+
get textFrameCodec() {
|
|
1402
|
+
return this.#frameCodecs.text;
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* Pose les bornes des admissions (`authenticateTimeoutMs`, `allowTimeoutMs`, `maxPendingAllowsPerConnection`, `maxPendingAllowsTotal`) — 1× au boot par `RealtimeService.init()`.
|
|
1406
|
+
*/
|
|
1407
|
+
setAdmissionLimits(limits) {
|
|
1408
|
+
this.admissionBudget.configure(limits);
|
|
1409
|
+
}
|
|
1410
|
+
/**
|
|
596
1411
|
* Associe un token au peer (posé par `RealtimeController.onHandshake()` après
|
|
597
1412
|
* succès `authenticator.authenticate()` ou fallback anonyme). Lazy alloc de
|
|
598
1413
|
* la `WeakMap` au 1ᵉʳ appel (0 coût si aucun authenticator enregistré).
|
|
@@ -612,9 +1427,18 @@ var RealtimeHub = class RealtimeHub {
|
|
|
612
1427
|
* **Seam #1 (P13 → P6)** — pose le verrou de frame (cf {@link FrameAuthorizer}).
|
|
613
1428
|
* `null` retire la politique (bypass total). Posé 1× au boot par
|
|
614
1429
|
* `@nodefony/security` depuis les zones `realtime: true`. Cold path.
|
|
1430
|
+
*
|
|
1431
|
+
* @throws BootConfigurationError Le verrou ne déclare pas `judgesWrite`
|
|
1432
|
+
* alors qu'une écriture distincte de la lecture est inscrite : il la
|
|
1433
|
+
* laisserait ouverte.
|
|
615
1434
|
*/
|
|
616
1435
|
setFrameAuthorizer(authorizer, options) {
|
|
1436
|
+
if (authorizer !== null && authorizer.judgesWrite !== true) {
|
|
1437
|
+
const defect = policyDeclarationsDefect(this.#policyDeclarations, this.#channelPolicyPatterns, false);
|
|
1438
|
+
if (defect !== null) throw new BootConfigurationError(defect);
|
|
1439
|
+
}
|
|
617
1440
|
this.#frameAuthorizer = authorizer;
|
|
1441
|
+
this.#dropRetainedVerdicts();
|
|
618
1442
|
this.#channelProbe = options?.silentProbe ?? null;
|
|
619
1443
|
}
|
|
620
1444
|
/**
|
|
@@ -678,10 +1502,7 @@ var RealtimeHub = class RealtimeHub {
|
|
|
678
1502
|
return this.hasChannelPolicies() && this.#frameAuthorizer === null;
|
|
679
1503
|
}
|
|
680
1504
|
/**
|
|
681
|
-
* **Seam #1b** —
|
|
682
|
-
* (`@RealtimeChannel`/`@RealtimeInbound` avec opts). Appelé au handshake par
|
|
683
|
-
* le controller pour CHAQUE canal décoré (idempotent : même nom = écrase, les
|
|
684
|
-
* controllers d'un même endpoint déclarent la même policy). Cold path.
|
|
1505
|
+
* **Seam #1b** — pose la politique d'un canal/méthode en REMPLAÇANT ce que ce nom déclarait (voie programmatique ; les décorateurs passent par {@link RealtimeHub.declareChannelPolicy}). Cold path.
|
|
685
1506
|
*
|
|
686
1507
|
* Deux formes de nom, et la seconde garde une FAMILLE :
|
|
687
1508
|
*
|
|
@@ -691,21 +1512,124 @@ var RealtimeHub = class RealtimeHub {
|
|
|
691
1512
|
* Le `*` remplace n'importe quelle suite de caractères ; tout le reste du
|
|
692
1513
|
* motif est littéral (échappé), y compris `.` et `(`.
|
|
693
1514
|
*
|
|
694
|
-
* Précédence
|
|
695
|
-
*
|
|
696
|
-
* **
|
|
1515
|
+
* Précédence, SORTE PAR SORTE (ADR-0022 §6, décision 1 quater) : la lecture
|
|
1516
|
+
* d'un nom vient de la déclaration la plus spécifique qui POSE une lecture —
|
|
1517
|
+
* le nom **exact**, puis le motif au littéral le plus long ; l'écriture de
|
|
1518
|
+
* même. C'est ainsi qu'on ouvre une exception dans une famille gardée, sans
|
|
1519
|
+
* effacer la sorte que l'exception ne déclare pas.
|
|
697
1520
|
*
|
|
698
|
-
* @param name
|
|
1521
|
+
* @param name - nom exact du canal, de la méthode entrante ou de l'action, ou
|
|
699
1522
|
* MOTIF de famille s'il contient `*`.
|
|
700
|
-
* @param policy - exigences ({@link IChannelPolicy}) lues par
|
|
1523
|
+
* @param policy - exigences de LECTURE ({@link IChannelPolicy}) lues par
|
|
1524
|
+
* security ; seules exigées au `subscribe`.
|
|
1525
|
+
* @param admission - instance de la classe d'admission quand `policy.allow`
|
|
1526
|
+
* en est une ; ignorée sinon.
|
|
1527
|
+
* @param write - exigences d'ÉCRITURE, exigées EN PLUS de la lecture sur une
|
|
1528
|
+
* trame entrante ; `null` : ce nom n'en déclare pas.
|
|
1529
|
+
* @throws BootConfigurationError Classe d'admission sans instance ; `allow`
|
|
1530
|
+
* sur une écriture qui rencontre une lecture différente ; écriture distincte
|
|
1531
|
+
* face à un verrou qui ne sait pas la juger.
|
|
1532
|
+
*/
|
|
1533
|
+
registerChannelPolicy(name, policy, admission, write = null) {
|
|
1534
|
+
this.#declare(name, policy, write, null, admission, false);
|
|
1535
|
+
}
|
|
1536
|
+
/**
|
|
1537
|
+
* Déclare la lecture et/ou l'écriture d'un nom en les AJOUTANT à ce qu'il déclarait — une sorte posée remplace la sienne, une sorte `null` garde celle d'avant. Voie des décorateurs (`channelPolicyRegistry.ts#registerControllerPolicies`). Cold path.
|
|
1538
|
+
*
|
|
1539
|
+
* @param name - nom exact ou motif.
|
|
1540
|
+
* @param read - lecture déclarée, ou `null`.
|
|
1541
|
+
* @param write - écriture déclarée, ou `null`.
|
|
1542
|
+
* @param admission - instance de la classe d'admission désignée par l'une ou
|
|
1543
|
+
* l'autre ; ignorée sinon.
|
|
1544
|
+
* @throws BootConfigurationError Mêmes refus que {@link RealtimeHub.registerChannelPolicy}.
|
|
701
1545
|
*/
|
|
702
|
-
|
|
1546
|
+
declareChannelPolicy(name, read, write, admission) {
|
|
1547
|
+
this.#declare(name, read, write, null, admission, true);
|
|
1548
|
+
}
|
|
1549
|
+
/**
|
|
1550
|
+
* Pose le défaut FERMÉ d'une action (`{ authenticated: true }`) — son ÉCRITURE tant que rien d'exact ne déclare son nom et qu'aucun motif ne pose d'écriture ; une lecture de motif s'y ajoute sans l'ouvrir. Idempotent : une poignée de main n'en paie qu'une lecture.
|
|
1551
|
+
*
|
|
1552
|
+
* @param name - nom de l'action.
|
|
1553
|
+
*/
|
|
1554
|
+
declareActionDefault(name) {
|
|
1555
|
+
if (this.#policyDeclarations?.get(name)?.fallback === DEFAULT_ACTION_POLICY) return;
|
|
1556
|
+
this.#declare(name, null, null, DEFAULT_ACTION_POLICY, void 0, true);
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* Refuse une présence mal déclarée (ADR-0025 P3) : valeur illisible, posée sur une ÉCRITURE seule (rien ne s'y abonne), ou sur un canal réservé de la plateforme.
|
|
1560
|
+
*
|
|
1561
|
+
* Une seule porte pour les décorateurs et la voie programmatique. Une
|
|
1562
|
+
* politique posée par `@RealtimePolicy` sert la lecture ET l'écriture sous le
|
|
1563
|
+
* même objet : elle est admise, c'est la lecture qui la porte.
|
|
1564
|
+
*
|
|
1565
|
+
* @throws BootConfigurationError En nommant le canal et le défaut.
|
|
1566
|
+
*/
|
|
1567
|
+
static #assertPresence(name, read, write) {
|
|
1568
|
+
const refuse = (why) => {
|
|
1569
|
+
throw new BootConfigurationError(`canal temps réel « ${name} » : présence refusée — ${why}`);
|
|
1570
|
+
};
|
|
1571
|
+
for (const policy of [read, write]) {
|
|
1572
|
+
const defect = presenceDefect(policy?.presence);
|
|
1573
|
+
if (defect !== null) refuse(defect);
|
|
1574
|
+
}
|
|
1575
|
+
if (write !== null && write !== read && presenceSettingsOf(write) !== null) refuse("« presence » est posée sur une politique d'ÉCRITURE (@RealtimeInbound, @RealtimeAction), où rien ne s'abonne. La poser sur @RealtimeChannel.");
|
|
1576
|
+
if (presenceSettingsOf(read) !== null && isReservedSystemChannel(name)) refuse(`un canal réservé de la plateforme (${RESERVED_SYSTEM_PREFIXES.join(" ")}) ne publie pas sa présence.`);
|
|
1577
|
+
}
|
|
1578
|
+
/** Construit la déclaration d'un nom (remplacée ou fusionnée), puis la valide et la publie. */
|
|
1579
|
+
#declare(name, read, write, fallback, admission, merge) {
|
|
703
1580
|
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
|
-
|
|
705
|
-
|
|
1581
|
+
const isPattern = name.includes("*");
|
|
1582
|
+
const previous = isPattern ? this.#channelPolicyPatterns?.find((p) => p.source === name) : this.#policyDeclarations?.get(name);
|
|
1583
|
+
const declaration = declarationOf(name, read ?? (merge ? previous?.read ?? null : null), write ?? (merge ? previous?.write ?? null : null), fallback ?? previous?.fallback ?? null, admission ?? previous?.instance);
|
|
1584
|
+
RealtimeHub.#assertPresence(name, declaration.read, declaration.write);
|
|
1585
|
+
if (presenceSettingsOf(declaration.read) !== null) this.#presenceDeclared = true;
|
|
1586
|
+
if (!isPattern) {
|
|
1587
|
+
const exact = new Map(this.#policyDeclarations ?? []);
|
|
1588
|
+
exact.set(name, declaration);
|
|
1589
|
+
this.#commitPolicies(exact, this.#channelPolicyPatterns);
|
|
706
1590
|
return;
|
|
707
1591
|
}
|
|
708
|
-
|
|
1592
|
+
const re = compileChannelPattern(name);
|
|
1593
|
+
if (re === null) {
|
|
1594
|
+
this.#notifyOnce(`pattern-too-broad:${name}`, `le motif de politique "${name}" porte ${countWildcards(name)} 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.`);
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
const weight = channelPatternWeight(name);
|
|
1598
|
+
const entry = {
|
|
1599
|
+
...declaration,
|
|
1600
|
+
source: name,
|
|
1601
|
+
re,
|
|
1602
|
+
weight
|
|
1603
|
+
};
|
|
1604
|
+
const patterns = [...this.#channelPolicyPatterns ?? []];
|
|
1605
|
+
const at = patterns.findIndex((p) => p.source === name);
|
|
1606
|
+
if (at >= 0) patterns[at] = entry;
|
|
1607
|
+
else {
|
|
1608
|
+
const rank = patterns.findIndex((p) => p.weight < weight);
|
|
1609
|
+
if (rank < 0) patterns.push(entry);
|
|
1610
|
+
else patterns.splice(rank, 0, entry);
|
|
1611
|
+
}
|
|
1612
|
+
this.#commitPolicies(this.#policyDeclarations, patterns);
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1615
|
+
* Valide l'ensemble des déclarations, recalcule la vue fusionnée de chaque nom exact, puis la publie — rien n'est publié si la validation refuse.
|
|
1616
|
+
*
|
|
1617
|
+
* La vue est recalculée à chaque inscription (cold path) pour qu'une trame
|
|
1618
|
+
* sur un nom exact reste UNE lecture de `Map`, motifs compris.
|
|
1619
|
+
*/
|
|
1620
|
+
#commitPolicies(exact, patterns) {
|
|
1621
|
+
const lock = this.#frameAuthorizer;
|
|
1622
|
+
const defect = policyDeclarationsDefect(exact, patterns, lock === null || lock.judgesWrite === true);
|
|
1623
|
+
if (defect !== null) throw new BootConfigurationError(defect);
|
|
1624
|
+
let merged = null;
|
|
1625
|
+
if (exact !== null) for (const [name, declaration] of exact) {
|
|
1626
|
+
const resolved = resolveDeclaredPolicies(name, declaration, patterns);
|
|
1627
|
+
if (resolved !== null) (merged ??= /* @__PURE__ */ new Map()).set(name, resolved);
|
|
1628
|
+
}
|
|
1629
|
+
this.#policyDeclarations = exact;
|
|
1630
|
+
this.#channelPolicyPatterns = patterns;
|
|
1631
|
+
this.#channelPolicies = merged;
|
|
1632
|
+
this.#dropRetainedVerdicts();
|
|
709
1633
|
}
|
|
710
1634
|
/**
|
|
711
1635
|
* Nombre maximum de `*` dans un motif.
|
|
@@ -717,52 +1641,90 @@ var RealtimeHub = class RealtimeHub {
|
|
|
717
1641
|
* de déclaration, et on le DIT plutôt que d'exposer un chemin coûteux à ce
|
|
718
1642
|
* qu'un client peut nommer.
|
|
719
1643
|
*/
|
|
720
|
-
static MAX_PATTERN_WILDCARDS =
|
|
1644
|
+
static MAX_PATTERN_WILDCARDS = MAX_CHANNEL_PATTERN_WILDCARDS;
|
|
721
1645
|
/**
|
|
722
|
-
*
|
|
1646
|
+
* Politique déclarée pour ce canal/méthode, ou `null` si aucune. Lu par
|
|
1647
|
+
* `@nodefony/security` (`resolveChannelPolicy` de la surface DI) au moment de
|
|
1648
|
+
* la frame `subscribe`/inbound. O(1), 0 alloc quand le registre est vide.
|
|
1649
|
+
*/
|
|
1650
|
+
resolveChannelPolicy(name) {
|
|
1651
|
+
return this.#resolvePolicyEntry(name)?.policy ?? null;
|
|
1652
|
+
}
|
|
1653
|
+
/**
|
|
1654
|
+
* Lecture ET écriture qui s'appliquent à ce nom, en UNE résolution — `null` si aucune politique ne le couvre (ADR-0022 §6, décision 1 quater).
|
|
1655
|
+
*
|
|
1656
|
+
* Même résolution que {@link resolveChannelPolicy} : l'entrée la plus
|
|
1657
|
+
* spécifique (nom exact, puis motif) gouverne les deux sortes. Rendue telle
|
|
1658
|
+
* quelle : aucune allocation, une seule recherche par trame jugée.
|
|
723
1659
|
*
|
|
724
|
-
*
|
|
725
|
-
* ce que l'auteur écrit n'est interprété comme une expression régulière. Un
|
|
726
|
-
* `.` reste un point, un `(` reste une parenthèse — sans quoi une déclaration
|
|
727
|
-
* anodine deviendrait une expression, avec ce que cela ouvre.
|
|
1660
|
+
* @param name - nom du canal, de la méthode entrante ou de l'action.
|
|
728
1661
|
*/
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
1662
|
+
resolveChannelPolicies(name) {
|
|
1663
|
+
return this.#resolvePolicyEntry(name);
|
|
1664
|
+
}
|
|
1665
|
+
/**
|
|
1666
|
+
* Retient la PREMIÈRE classe qui déclare une sorte de politique sous ce nom, et rend celle qui l'avait déjà déclarée — pour refuser au démarrage deux classes en désaccord.
|
|
1667
|
+
*
|
|
1668
|
+
* @param name - nom exact ou motif.
|
|
1669
|
+
* @param kind - sorte déclarée : lecture ou écriture.
|
|
1670
|
+
* @param owner - constructeur du contrôleur qui déclare.
|
|
1671
|
+
* @param policy - la politique déclarée.
|
|
1672
|
+
* @returns la déclaration antérieure de cette sorte, ou `null`.
|
|
1673
|
+
*/
|
|
1674
|
+
claimChannelPolicy(name, kind, owner, policy) {
|
|
1675
|
+
const claims = this.#policyClaims ??= /* @__PURE__ */ new Map();
|
|
1676
|
+
const key = `${kind}\u0000${name}`;
|
|
1677
|
+
const previous = claims.get(key);
|
|
1678
|
+
if (previous !== void 0) return previous;
|
|
1679
|
+
claims.set(key, {
|
|
1680
|
+
owner,
|
|
1681
|
+
ownerName: typeof owner === "function" ? owner.name : "(contrôleur)",
|
|
744
1682
|
policy
|
|
745
|
-
};
|
|
746
|
-
|
|
747
|
-
policies[existing] = policyEntry;
|
|
748
|
-
return;
|
|
749
|
-
}
|
|
750
|
-
const rang = policies.findIndex((p) => p.weight < weight);
|
|
751
|
-
if (rang < 0) policies.push(policyEntry);
|
|
752
|
-
else policies.splice(rang, 0, policyEntry);
|
|
1683
|
+
});
|
|
1684
|
+
return null;
|
|
753
1685
|
}
|
|
754
1686
|
/**
|
|
755
|
-
*
|
|
756
|
-
*
|
|
757
|
-
*
|
|
1687
|
+
* Règle d'admission prête à appeler pour ce canal — `null` s'il ne déclare pas `allow`. Lue au `subscribe`, jamais par trame.
|
|
1688
|
+
*
|
|
1689
|
+
* Même résolution que {@link resolveChannelPolicy} (nom exact, puis motif le
|
|
1690
|
+
* plus spécifique) : la règle suit TOUJOURS la politique qui s'applique.
|
|
758
1691
|
*/
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
1692
|
+
resolveChannelAdmission(name) {
|
|
1693
|
+
return this.#resolvePolicyEntry(name)?.admission ?? null;
|
|
1694
|
+
}
|
|
1695
|
+
/**
|
|
1696
|
+
* Les politiques de cette classe de contrôleur sont-elles déjà inscrites ? Une poignée de main n'en paie que ce `has`.
|
|
1697
|
+
*
|
|
1698
|
+
* @param owner - le constructeur du contrôleur.
|
|
1699
|
+
*/
|
|
1700
|
+
hasPolicyOwner(owner) {
|
|
1701
|
+
return this.#policyOwners?.has(owner) === true;
|
|
1702
|
+
}
|
|
1703
|
+
/**
|
|
1704
|
+
* 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.
|
|
1705
|
+
*
|
|
1706
|
+
* @param owner - le constructeur du contrôleur.
|
|
1707
|
+
*/
|
|
1708
|
+
addPolicyOwner(owner) {
|
|
1709
|
+
(this.#policyOwners ??= /* @__PURE__ */ new WeakSet()).add(owner);
|
|
1710
|
+
}
|
|
1711
|
+
/**
|
|
1712
|
+
* Politiques qui s'appliquent à ce nom : la vue fusionnée d'un nom exact (une lecture de `Map`), sinon la résolution par motifs, sorte par sorte.
|
|
1713
|
+
*/
|
|
1714
|
+
#resolvePolicyEntry(name) {
|
|
1715
|
+
const merged = this.#channelPolicies?.get(name);
|
|
1716
|
+
if (merged !== void 0) return merged;
|
|
762
1717
|
const patterns = this.#channelPolicyPatterns;
|
|
763
|
-
if (patterns === null) return null;
|
|
764
|
-
|
|
765
|
-
|
|
1718
|
+
if (patterns === null) return name.startsWith(USER_CHANNEL_PREFIX) ? USER_CHANNEL_DECLARATION.self : null;
|
|
1719
|
+
return resolveDeclaredPolicies(name, void 0, patterns);
|
|
1720
|
+
}
|
|
1721
|
+
/**
|
|
1722
|
+
* Ce nom EXACT porte-t-il une déclaration — motifs ignorés ?
|
|
1723
|
+
*
|
|
1724
|
+
* @param name - nom d'action ou de canal.
|
|
1725
|
+
*/
|
|
1726
|
+
hasExactChannelPolicy(name) {
|
|
1727
|
+
return this.#policyDeclarations?.has(name) === true;
|
|
766
1728
|
}
|
|
767
1729
|
/** Des politiques de canal sont-elles déclarées (au moins une) ? */
|
|
768
1730
|
hasChannelPolicies() {
|
|
@@ -773,14 +1735,14 @@ var RealtimeHub = class RealtimeHub {
|
|
|
773
1735
|
* DYNAMIQUE n'est couvert par aucune politique déclarée, alors que d'autres
|
|
774
1736
|
* canaux du module en portent une.
|
|
775
1737
|
*
|
|
776
|
-
* Le registre des politiques
|
|
777
|
-
*
|
|
778
|
-
*
|
|
779
|
-
*
|
|
780
|
-
*
|
|
781
|
-
*
|
|
782
|
-
* canaux qu'aucun motif ne couvre
|
|
783
|
-
* de l'outil.
|
|
1738
|
+
* Le registre des politiques lit le nom **EXACT**, puis les **MOTIFS**
|
|
1739
|
+
* déclarés ({@link resolveChannelPolicy}) : `@RealtimeChannel` déclare
|
|
1740
|
+
* `"chat:room"`, mais un canal dérivé (`chat:room:1000` — cadence, forage,
|
|
1741
|
+
* suffixe) servi par la fabrique dynamique ne matche ce nom que si un motif
|
|
1742
|
+
* (`chat:room:*`) le couvre. Sans motif, l'auteur croit son canal gardé ; il
|
|
1743
|
+
* ne l'est pas, et rien ne le lui dit. C'est ce silence qu'on ferme ici :
|
|
1744
|
+
* l'avertissement ne parle que des canaux qu'aucun motif ne couvre — un vrai
|
|
1745
|
+
* trou, pas une limite de l'outil.
|
|
784
1746
|
*
|
|
785
1747
|
* Appelé par le contrôleur, seul à savoir par quelle voie le canal a été
|
|
786
1748
|
* servi. Muet si aucune politique n'est déclarée (module aux canaux libres :
|
|
@@ -810,16 +1772,6 @@ var RealtimeHub = class RealtimeHub {
|
|
|
810
1772
|
(this.#systemChannelFactories ??= /* @__PURE__ */ new Map()).set(channel, factory);
|
|
811
1773
|
}
|
|
812
1774
|
/**
|
|
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
1775
|
* Ouvre (ou referme) la réception des journaux du navigateur, avec ses bornes.
|
|
824
1776
|
*
|
|
825
1777
|
* @param limits - bornes de débit et de taille, ou `null` pour refermer le canal.
|
|
@@ -831,9 +1783,72 @@ var RealtimeHub = class RealtimeHub {
|
|
|
831
1783
|
get clientLogsLimits() {
|
|
832
1784
|
return this.#clientLogs;
|
|
833
1785
|
}
|
|
1786
|
+
/**
|
|
1787
|
+
* La raison d'un refus du verrou, quand il sait la dire — `null` sinon, ou si l'explication lève.
|
|
1788
|
+
*
|
|
1789
|
+
* Chemin FROID : appelé après un refus, jamais par trame acceptée. Le
|
|
1790
|
+
* verdict, lui, est déjà rendu : une explication fautive ne change rien à la
|
|
1791
|
+
* décision, elle se tait.
|
|
1792
|
+
*
|
|
1793
|
+
* @param frame - la trame refusée.
|
|
1794
|
+
* @param peer - la connexion qui l'a émise.
|
|
1795
|
+
* @returns la phrase, ou `null`.
|
|
1796
|
+
*/
|
|
1797
|
+
explainDenial(frame, peer) {
|
|
1798
|
+
const explain = this.#frameAuthorizer?.explain;
|
|
1799
|
+
if (explain === void 0) return null;
|
|
1800
|
+
try {
|
|
1801
|
+
const said = explain(frame, this.getTokenForPeer(peer));
|
|
1802
|
+
return typeof said === "string" && said.length > 0 ? said : null;
|
|
1803
|
+
} catch {
|
|
1804
|
+
return null;
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
/**
|
|
1808
|
+
* Pont hot-path entre `JsonRpcPeer.beforeDispatch` et le verrou métier : lit
|
|
1809
|
+
* le token déjà caché du peer (O(1), 0 lecture base) puis délègue à
|
|
1810
|
+
* l'authorizer. `true` (autorisée) si aucun verrou posé : `beforeDispatch`
|
|
1811
|
+
* est armé sur TOUTE connexion, et c'est ici que le bypass se paie — un
|
|
1812
|
+
* appel et une comparaison de champ.
|
|
1813
|
+
*
|
|
1814
|
+
* @param frame - frame JSON-RPC entrante (brute) à autoriser.
|
|
1815
|
+
* @param peer - peer émetteur (clé du mapping `peer → token`).
|
|
1816
|
+
* @returns `true` si la frame peut être dispatchée, `false` si refusée.
|
|
1817
|
+
*/
|
|
834
1818
|
runAuthorizer(frame, peer) {
|
|
835
|
-
|
|
836
|
-
|
|
1819
|
+
const authorizer = this.#frameAuthorizer;
|
|
1820
|
+
if (authorizer === null) return true;
|
|
1821
|
+
try {
|
|
1822
|
+
return authorizer(frame, this.getTokenForPeer(peer)) === true;
|
|
1823
|
+
} catch (e) {
|
|
1824
|
+
this.#notifyOnce("frame-authorizer-threw", "le verrou des trames a levé une exception — trame refusée : " + (e instanceof Error ? e.message : String(e)));
|
|
1825
|
+
return false;
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
/**
|
|
1829
|
+
* Inscrit une connexion qui vient de retenir son premier « oui » du verrou.
|
|
1830
|
+
*
|
|
1831
|
+
* Appelé par `gateFrame`, jamais par trame chaude ; à équilibrer par
|
|
1832
|
+
* {@link untrackRetainedVerdicts} à la fermeture.
|
|
1833
|
+
*
|
|
1834
|
+
* @param state - état de la connexion.
|
|
1835
|
+
*/
|
|
1836
|
+
trackRetainedVerdicts(state) {
|
|
1837
|
+
(this.#retainers ??= /* @__PURE__ */ new Set()).add(state);
|
|
1838
|
+
}
|
|
1839
|
+
/**
|
|
1840
|
+
* Retire une connexion fermée du registre des verdicts retenus.
|
|
1841
|
+
*
|
|
1842
|
+
* @param state - état de la connexion.
|
|
1843
|
+
*/
|
|
1844
|
+
untrackRetainedVerdicts(state) {
|
|
1845
|
+
this.#retainers?.delete(state);
|
|
1846
|
+
}
|
|
1847
|
+
#dropRetainedVerdicts() {
|
|
1848
|
+
const retainers = this.#retainers;
|
|
1849
|
+
if (retainers === null) return;
|
|
1850
|
+
for (const state of retainers) dropRetainedVerdicts(state);
|
|
1851
|
+
this.#retainers = null;
|
|
837
1852
|
}
|
|
838
1853
|
/**
|
|
839
1854
|
* Ce peer pourrait-il s'abonner à ce canal ? — même décision que `subscribe`,
|
|
@@ -849,11 +1864,17 @@ var RealtimeHub = class RealtimeHub {
|
|
|
849
1864
|
* @returns `true` si un `subscribe` sur ce canal serait accepté.
|
|
850
1865
|
*/
|
|
851
1866
|
probeChannel(channel, peer) {
|
|
852
|
-
|
|
853
|
-
return
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
1867
|
+
const probe = this.#channelProbe;
|
|
1868
|
+
if (probe === null) return true;
|
|
1869
|
+
try {
|
|
1870
|
+
return probe({
|
|
1871
|
+
method: "subscribe",
|
|
1872
|
+
params: { channel }
|
|
1873
|
+
}, this.getTokenForPeer(peer));
|
|
1874
|
+
} catch (e) {
|
|
1875
|
+
this.#notifyOnce("channel-probe-threw", "la sonde des canaux annoncés a levé une exception — canal annoncé : " + (e instanceof Error ? e.message : String(e)));
|
|
1876
|
+
return true;
|
|
1877
|
+
}
|
|
857
1878
|
}
|
|
858
1879
|
/** Authenticators enregistrés (debug / tests). Lecture seule. */
|
|
859
1880
|
get registeredAuthenticators() {
|
|
@@ -862,6 +1883,18 @@ var RealtimeHub = class RealtimeHub {
|
|
|
862
1883
|
}
|
|
863
1884
|
/** Dispose tous les providers et vide le hub (arrêt process / reset de test). */
|
|
864
1885
|
clear() {
|
|
1886
|
+
if (this.#presenceTimer !== null) clearTimeout(this.#presenceTimer);
|
|
1887
|
+
this.#presenceTimer = null;
|
|
1888
|
+
this.#presenceTimerAt = Number.POSITIVE_INFINITY;
|
|
1889
|
+
this.#presenceActive = null;
|
|
1890
|
+
this.#presenceBus = null;
|
|
1891
|
+
this.#presenceServed = null;
|
|
1892
|
+
this.#presenceLingering = null;
|
|
1893
|
+
this.#presenceDeclared = false;
|
|
1894
|
+
this.#presenceRejectedTotal = 0;
|
|
1895
|
+
this.#presenceListTotal = 0;
|
|
1896
|
+
this.#presenceFrameTotal = 0;
|
|
1897
|
+
this.#presenceListBuiltTotal = 0;
|
|
865
1898
|
if (this.#channels) {
|
|
866
1899
|
for (const st of this.#channels.values()) try {
|
|
867
1900
|
st.dispose?.();
|
|
@@ -875,16 +1908,31 @@ var RealtimeHub = class RealtimeHub {
|
|
|
875
1908
|
this.#authenticators = null;
|
|
876
1909
|
this.#peerTokens = null;
|
|
877
1910
|
this.#frameAuthorizer = null;
|
|
1911
|
+
this.#channelProbe = null;
|
|
1912
|
+
this.#dropRetainedVerdicts();
|
|
878
1913
|
this.#channelPolicies?.clear();
|
|
879
1914
|
this.#channelPolicies = null;
|
|
880
1915
|
this.#channelPolicyPatterns = null;
|
|
1916
|
+
this.#policyOwners = null;
|
|
1917
|
+
this.#policyClaims = null;
|
|
1918
|
+
this.#policyDeclarations = null;
|
|
881
1919
|
this.#originGuard = null;
|
|
882
1920
|
this.#backplane = null;
|
|
1921
|
+
this.#busBytes = false;
|
|
883
1922
|
this.#publishTotal = 0;
|
|
884
1923
|
this.#fanoutTotal = 0;
|
|
1924
|
+
this.#closedBytesSent = 0;
|
|
1925
|
+
this.#closedMessagesSent = 0;
|
|
1926
|
+
this.#closedDropped = 0;
|
|
1927
|
+
this.#closedBinaryDiscarded = 0;
|
|
1928
|
+
this.#closedMalformedDiscarded = 0;
|
|
885
1929
|
this.#inboundTotal = 0;
|
|
886
1930
|
this.#ingressRejectedTotal = 0;
|
|
1931
|
+
this.#ingressUnknownDiscardedTotal = 0;
|
|
1932
|
+
this.#localOnlyTotal = 0;
|
|
887
1933
|
this.#systemFloorDeniedTotal = 0;
|
|
1934
|
+
this.admissionBudget.reset();
|
|
1935
|
+
this.#frameCodecs = frameCodecSet();
|
|
888
1936
|
this.#noticed = null;
|
|
889
1937
|
this.#notice = null;
|
|
890
1938
|
}
|
|
@@ -918,8 +1966,7 @@ function compileMatcher(m) {
|
|
|
918
1966
|
};
|
|
919
1967
|
}
|
|
920
1968
|
let _hub = null;
|
|
921
|
-
/** Renvoie le hub realtime du process (singleton lazy). */
|
|
922
1969
|
function getRealtimeHub() {
|
|
923
1970
|
return _hub ??= new RealtimeHub();
|
|
924
1971
|
}
|
|
925
|
-
export { RESERVED_SYSTEM_PREFIXES, REVOCATION_REVALIDATE_MS, RealtimeHub, RealtimeHub as default, SLOW_CONSUMER_BYTES, channelFamily, getRealtimeHub, isReservedSystemChannel };
|
|
1972
|
+
export { ChannelFrame, RESERVED_SYSTEM_PREFIXES, REVOCATION_REVALIDATE_MS, RealtimeHub, RealtimeHub as default, SLOW_CONSUMER_BYTES, channelFamily, getRealtimeHub, isReservedSystemChannel };
|