@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.
Files changed (78) hide show
  1. package/README.md +50 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +79 -12
  4. package/dist/nodefony/config/config.js +7 -0
  5. package/dist/nodefony/decorators/realtimeDecorators.js +125 -19
  6. package/dist/nodefony/interfaces/IRealtimeConnection.js +1 -0
  7. package/dist/nodefony/src/backplane/ClusterBackplane.js +32 -4
  8. package/dist/nodefony/src/backplane/RedisBackplane.js +143 -31
  9. package/dist/nodefony/src/backplane/controlOrder.js +57 -0
  10. package/dist/nodefony/src/backplane/envelope.js +113 -12
  11. package/dist/nodefony/src/cluster/ClusterProbeClient.js +2 -0
  12. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
  13. package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
  14. package/dist/nodefony/src/server/RealtimeConnection.js +110 -0
  15. package/dist/nodefony/src/server/RealtimeController.js +340 -81
  16. package/dist/nodefony/src/server/RealtimeHub.js +604 -61
  17. package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
  18. package/dist/nodefony/src/server/admissionBudget.js +176 -0
  19. package/dist/nodefony/src/server/channelPolicyRegistry.js +73 -0
  20. package/dist/nodefony/src/server/connectionAdmission.js +242 -0
  21. package/dist/nodefony/src/server/contractRoutes.js +85 -0
  22. package/dist/nodefony/src/server/deniedDetail.js +10 -3
  23. package/dist/nodefony/src/server/frameAdmission.js +168 -0
  24. package/dist/nodefony/src/server/subscribedAck.js +10 -1
  25. package/dist/nodefony/src/server/syslogUplink.js +3 -4
  26. package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
  27. package/dist/nodefony/src/service/RealtimeService.js +42 -1
  28. package/dist/nodefony/src/service/publicOrigin.js +40 -0
  29. package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
  30. package/dist/nodefony/testing/backplaneConformance.js +271 -0
  31. package/dist/nodefony/testing/index.js +21 -4
  32. package/dist/types/index.d.ts +34 -7
  33. package/dist/types/nodefony/config/config.d.ts +9 -0
  34. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +86 -6
  35. package/dist/types/nodefony/interfaces/IBackplane.d.ts +34 -50
  36. package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
  37. package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
  38. package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
  39. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
  40. package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
  41. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
  42. package/dist/types/nodefony/interfaces/index.d.ts +2 -1
  43. package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
  44. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +79 -16
  45. package/dist/types/nodefony/src/backplane/controlOrder.d.ts +52 -0
  46. package/dist/types/nodefony/src/backplane/envelope.d.ts +34 -0
  47. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +10 -0
  48. package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
  49. package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +17 -0
  50. package/dist/types/nodefony/src/server/RealtimeController.d.ts +91 -11
  51. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +321 -24
  52. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
  53. package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
  54. package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +34 -0
  55. package/dist/types/nodefony/src/server/connectionAdmission.d.ts +68 -0
  56. package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
  57. package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
  58. package/dist/types/nodefony/src/server/frameAdmission.d.ts +70 -0
  59. package/dist/types/nodefony/src/server/subscribedAck.d.ts +7 -0
  60. package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
  61. package/dist/types/nodefony/src/service/RealtimeService.d.ts +10 -2
  62. package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
  63. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
  64. package/dist/types/nodefony/testing/backplaneConformance.d.ts +66 -0
  65. package/dist/types/nodefony/testing/index.d.ts +12 -1
  66. package/docs/actions.md +419 -18
  67. package/docs/architecture.md +83 -8
  68. package/docs/cadence-et-contre-pression.md +87 -11
  69. package/docs/canaux-a-membres.md +267 -0
  70. package/docs/cas-d-usage.md +1702 -0
  71. package/docs/configuration.md +137 -17
  72. package/docs/cookbook-chat.md +314 -85
  73. package/docs/index.md +11 -1
  74. package/docs/observabilite.md +71 -14
  75. package/docs/protocole.md +133 -24
  76. package/docs/securite.md +284 -32
  77. package/docs/vocabulaire.md +13 -12
  78. 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
- revoke: (entry) => entry.close(4001, "session revoked"),
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, serialize) {
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
- return this.subscribe(channel, sink, factory, serialize);
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, serialize) {
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.serialize === null && serialize !== void 0) st.serialize = serialize;
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 (serialize !== void 0) st.serialize = serialize;
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
- serialize: serialize ?? null,
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
- serialize: null,
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
- return sys ? sys(channel, publishFn) : null;
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)) this.#backplane.publish(channel, payload);
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 raw;
317
- if (st.serialize !== null && st.sinks.size > 1) try {
318
- raw = st.serialize(payload);
695
+ let frame;
696
+ if (st.buildFrame !== null && st.sinks.size > 1) try {
697
+ frame = new ChannelFrame(st.buildFrame(payload));
319
698
  } catch {
320
- raw = void 0;
699
+ frame = void 0;
321
700
  }
322
701
  for (const sink of st.sinks) try {
323
- sink(payload, raw);
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
- /** Retire une connexion du registre de la sonde (au close). No-op si absente. */
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 = 0;
483
- let messagesSentTotal = 0;
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 = 0;
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: this.#backplane?.describe() ?? {
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, policy);
1164
+ this.#registerChannelPolicyPattern(name, entry);
706
1165
  return;
707
1166
  }
708
- (this.#channelPolicies ??= /* @__PURE__ */ new Map()).set(name, policy);
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 = 3;
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, policy) {
730
- const etoiles = source.split("*").length - 1;
731
- if (etoiles > RealtimeHub.MAX_PATTERN_WILDCARDS) {
732
- this.#notifyOnce(`pattern-too-broad:${source}`, `le motif de politique "${source}" porte ${etoiles} caractères « * » (maximum ${RealtimeHub.MAX_PATTERN_WILDCARDS}) : il est IGNORÉ. Un motif de canal en demande un, deux au pire — au-delà, la mise en correspondance coûte cher sur des noms que le client choisit.`);
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
- policy
1201
+ ...entry
745
1202
  };
746
1203
  if (existing >= 0) {
747
1204
  policies[existing] = policyEntry;
748
1205
  return;
749
1206
  }
750
- const rang = policies.findIndex((p) => p.weight < weight);
751
- if (rang < 0) policies.push(policyEntry);
752
- else policies.splice(rang, 0, policyEntry);
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 === null) return null;
764
- for (const pattern of patterns) if (pattern.re.test(name)) return pattern.policy;
765
- return null;
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 est indexé par nom **EXACT** : `@RealtimeChannel`
777
- * déclare `"chat:room"`, mais un canal dérivé (`chat:room:1000` — cadence,
778
- * forage, suffixe) est servi par la fabrique dynamique et **ne matche pas**.
779
- * L'auteur croit alors son canal gardé ; il ne l'est pas, et rien ne le lui
780
- * dit. C'est ce silence qu'on ferme ici — et depuis qu'une politique peut se
781
- * déclarer par MOTIF (`chat:room:*`), cet avertissement ne parle plus que des
782
- * canaux qu'aucun motif ne couvre : il désigne un vrai trou, pas une limite
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
- if (this.#frameAuthorizer === null) return true;
836
- return this.#frameAuthorizer(frame, this.getTokenForPeer(peer));
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
- if (this.#channelProbe === null) return true;
853
- return this.#channelProbe({
854
- method: "subscribe",
855
- params: { channel }
856
- }, this.getTokenForPeer(peer));
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 };