@nodefony/realtime 10.0.0-beta.6 → 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.
Files changed (62) hide show
  1. package/README.md +5 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +15 -3
  4. package/dist/nodefony/decorators/realtimeDecorators.js +231 -39
  5. package/dist/nodefony/src/backplane/RedisBackplane.js +33 -40
  6. package/dist/nodefony/src/backplane/controlOrder.js +34 -5
  7. package/dist/nodefony/src/backplane/envelope.js +422 -45
  8. package/dist/nodefony/src/backplane/ingress.js +93 -0
  9. package/dist/nodefony/src/backplane/presenceMessage.js +205 -0
  10. package/dist/nodefony/src/backplane/publishQueue.js +97 -3
  11. package/dist/nodefony/src/server/RealtimeConnection.js +31 -6
  12. package/dist/nodefony/src/server/RealtimeController.js +87 -44
  13. package/dist/nodefony/src/server/RealtimeHub.js +631 -127
  14. package/dist/nodefony/src/server/admissionBudget.js +8 -8
  15. package/dist/nodefony/src/server/channelPolicyRegistry.js +35 -13
  16. package/dist/nodefony/src/server/channelPolicyResolution.js +191 -0
  17. package/dist/nodefony/src/server/channelPresence.js +488 -0
  18. package/dist/nodefony/src/server/contractRoutes.js +3 -13
  19. package/dist/nodefony/src/server/frameAdmission.js +27 -35
  20. package/dist/nodefony/src/server/presenceBus.js +486 -0
  21. package/dist/nodefony/src/server/realtimeShutdown.js +31 -0
  22. package/dist/nodefony/src/server/remotePresence.js +541 -0
  23. package/dist/nodefony/src/server/subscribedAck.js +3 -0
  24. package/dist/nodefony/src/service/RealtimeService.js +9 -0
  25. package/dist/nodefony/testing/backplaneConformance.js +79 -3
  26. package/dist/nodefony/testing/index.js +22 -8
  27. package/dist/nodefony/testing/presenceClient.js +54 -0
  28. package/dist/types/index.d.ts +6 -1
  29. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +117 -16
  30. package/dist/types/nodefony/interfaces/IBackplane.d.ts +12 -1
  31. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +10 -2
  32. package/dist/types/nodefony/src/backplane/controlOrder.d.ts +23 -0
  33. package/dist/types/nodefony/src/backplane/envelope.d.ts +50 -7
  34. package/dist/types/nodefony/src/backplane/ingress.d.ts +48 -0
  35. package/dist/types/nodefony/src/backplane/presenceMessage.d.ts +150 -0
  36. package/dist/types/nodefony/src/backplane/publishQueue.d.ts +25 -1
  37. package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +3 -0
  38. package/dist/types/nodefony/src/server/RealtimeController.d.ts +19 -1
  39. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +139 -30
  40. package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +9 -2
  41. package/dist/types/nodefony/src/server/channelPolicyResolution.d.ts +112 -0
  42. package/dist/types/nodefony/src/server/channelPresence.d.ts +155 -0
  43. package/dist/types/nodefony/src/server/contractRoutes.d.ts +1 -1
  44. package/dist/types/nodefony/src/server/frameAdmission.d.ts +14 -18
  45. package/dist/types/nodefony/src/server/presenceBus.d.ts +112 -0
  46. package/dist/types/nodefony/src/server/realtimeShutdown.d.ts +21 -0
  47. package/dist/types/nodefony/src/server/remotePresence.d.ts +151 -0
  48. package/dist/types/nodefony/src/server/subscribedAck.d.ts +3 -0
  49. package/dist/types/nodefony/src/service/RealtimeService.d.ts +8 -1
  50. package/dist/types/nodefony/testing/backplaneConformance.d.ts +14 -2
  51. package/dist/types/nodefony/testing/index.d.ts +1 -1
  52. package/dist/types/nodefony/testing/presenceClient.d.ts +20 -0
  53. package/docs/actions.md +1 -0
  54. package/docs/architecture.md +21 -6
  55. package/docs/canaux-a-membres.md +13 -3
  56. package/docs/cas-d-usage.md +4 -3
  57. package/docs/configuration.md +38 -2
  58. package/docs/cookbook-chat.md +400 -152
  59. package/docs/observabilite.md +28 -9
  60. package/docs/protocole.md +10 -10
  61. package/docs/securite.md +64 -10
  62. package/package.json +12 -12
package/README.md CHANGED
@@ -145,6 +145,10 @@ import { registerBackplaneDriver } from "@nodefony/realtime";
145
145
  registerBackplaneDriver("nats", (config) => new MyNatsBackplane(config));
146
146
  ```
147
147
 
148
+ Un bus fourni par l'application (service `realtimeBackplane`) s'arrête à la charge de
149
+ l'application : `await shutdownRealtime(getRealtimeHub(), bus)` dans son `onTerminate`, pour que
150
+ la fin de la présence parte avant la sortie du processus.
151
+
148
152
  Détail des réglages : [`docs/configuration.md`](./docs/configuration.md).
149
153
 
150
154
  ## API
@@ -162,6 +166,7 @@ Détail des réglages : [`docs/configuration.md`](./docs/configuration.md).
162
166
  | `LoopbackBackplane` / `ClusterBackplane` / `RedisBackplane` | classes | Les 3 drivers natifs |
163
167
  | `registerBackplaneDriver` / `getBackplaneDriver` / `listBackplaneDrivers` | functions | Registre ouvert de drivers |
164
168
  | `IBackplane` | interface | Contrat d'un fond de panier |
169
+ | `shutdownRealtime` / `BACKPLANE_STOP_TIMEOUT_MS` | fn / const | Arrêt propre : fin de la présence annoncée aux pairs, puis arrêt du bus en laissant partir ce qui est émis (borné). Appelé par le module pour le bus qu'il crée ; à appeler soi-même pour un bus fourni (`realtimeBackplane`) |
165
170
  | `ContractRealtimeController<C>` | classe | Base d'un contrôleur qui implémente un contrat (`realtimeActions()` complète si le contrat déclare des `serverMethods`, rien sinon) |
166
171
  | `getRealtimeHub<C>()` | fonction | Le hub lu par le contrat (`IContractRealtimeHub<C>`) : `publish` d'un service vérifié ; sans paramètre, le hub libre |
167
172
  | `ContractRealtimePublish<C>` | type | Le `publish` d'une fabrique de canal, typé par le contrat |
@@ -1,4 +1,4 @@
1
- // \0@oxc-project+runtime@0.157.0/helpers/esm/decorate.js
1
+ // \0@oxc-project+runtime@0.158.0/helpers/esm/decorate.js
2
2
  function __decorate(decorators, target, key, desc) {
3
3
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
4
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
package/dist/index.js CHANGED
@@ -16,12 +16,16 @@ import { getBackplaneDriver, listBackplaneDrivers, registerBackplaneDriver } fro
16
16
  import { ClusterProbeClient, clusterProbeHealth, clusterProbeInstance, clusterProbeRequestEnrich, mergeClusterHealth, processProbeTransport, setClusterProbeClient } from "./nodefony/src/cluster/ClusterProbeClient.js";
17
17
  import { WsConnectionTransport } from "./nodefony/src/transport/WsConnectionTransport.js";
18
18
  import { buildOwnHealth, buildRealtimeHealth, createRealtimeAdminApi } from "./nodefony/src/server/RealtimeAdminApi.js";
19
- import __decorate from "./_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorate.js";
19
+ import { BACKPLANE_STOP_TIMEOUT_MS, shutdownRealtime } from "./nodefony/src/server/realtimeShutdown.js";
20
+ import __decorate from "./_virtual/_@oxc-project_runtime@0.158.0/helpers/esm/decorate.js";
20
21
  import { ANONYMOUS_REALTIME_TOKEN, ANONYMOUS_SUBSCRIBER_IDENTITY } from "./nodefony/src/server/AnonymousRealtimeToken.js";
21
22
  import { RealtimeAction, RealtimeBroadcast, RealtimeChannel, RealtimeInbound, RealtimePolicy, getDeclaredBroadcastPrefixes } from "./nodefony/decorators/realtimeDecorators.js";
22
23
  import { ContractRealtimeController, ContractRealtimeControllerBase, ContractRealtimeControllerStrict } from "./nodefony/src/server/ContractRealtimeController.js";
23
24
  import { BROWSER_ORIGIN, MAX_CLIENT_SEVERITY, createSyslogUplinkHandler } from "./nodefony/src/server/syslogUplink.js";
24
- import { openBackplaneEnvelope, sealBackplaneEnvelope } from "./nodefony/src/backplane/envelope.js";
25
+ import { DEFAULT_PRESENCE_MAX_NAMED, PRESENCE_WINDOW_MS } from "./nodefony/src/server/channelPresence.js";
26
+ import { BackplaneEcho, openBackplaneEnvelope, sealBackplaneEnvelope } from "./nodefony/src/backplane/envelope.js";
27
+ import { BackplanePublishQueue, DEFAULT_MAX_QUEUE_BYTES } from "./nodefony/src/backplane/publishQueue.js";
28
+ import { BackplaneIngress } from "./nodefony/src/backplane/ingress.js";
25
29
  import { IDENTITY_CHANGED_EVENT, Module, services, withTimeout } from "nodefony";
26
30
  import { getRouteSchema, isFrameworkRouter } from "@nodefony/framework";
27
31
  // index.ts
@@ -80,6 +84,8 @@ let Realtime = class Realtime extends Module {
80
84
  static critical = false;
81
85
  /** Rôle topologique du process, capté à `onCluster` (défaut mono-process). */
82
86
  #clusterRole = "MONO";
87
+ /** Bus créé PAR ce module (registre de pilotes) — à lui de l'arrêter ; `null` s'il est fourni par l'application ou absent. */
88
+ #ownedBackplane = null;
83
89
  constructor(kernel) {
84
90
  super("realtime", kernel, import.meta.url, config);
85
91
  kernel.once("onCluster", (role) => {
@@ -128,6 +134,11 @@ let Realtime = class Realtime extends Module {
128
134
  getRealtimeHub().refreshIdentity(event.identifier);
129
135
  });
130
136
  await this.#wireBackplane();
137
+ this.kernel?.once("onTerminate", async () => {
138
+ const stopped = await shutdownRealtime(getRealtimeHub(), this.#ownedBackplane);
139
+ this.#ownedBackplane = null;
140
+ if (!stopped) this.log(`realtime backplane : arrêt non terminé en ${BACKPLANE_STOP_TIMEOUT_MS} ms (bus injoignable ?) — la fin de la présence peut ne pas avoir atteint les autres serveurs, qui oublieront celui-ci au délai d'oubli`, "WARNING");
141
+ });
131
142
  this.#wireClusterProbe(this.#clusterRole);
132
143
  return this;
133
144
  }
@@ -229,6 +240,7 @@ let Realtime = class Realtime extends Module {
229
240
  return;
230
241
  }
231
242
  hub.setBackplane(backplane);
243
+ this.#ownedBackplane = backplane;
232
244
  const info = backplane.describe();
233
245
  this.log(`realtime backplane driver=${info.driver} kind=${info.kind} origin=${info.originId} cross-pod=${info.crossPod ? "yes" : "no"}` + (info.channel ? ` channel=${info.channel}` : ""), "INFO");
234
246
  this.#logBroadcastPrefixes(hub);
@@ -287,4 +299,4 @@ let Realtime = class Realtime extends Module {
287
299
  };
288
300
  Realtime = __decorate([services([RealtimeService])], Realtime);
289
301
  var realtime_default = Realtime;
290
- export { ANONYMOUS_REALTIME_TOKEN, ANONYMOUS_SUBSCRIBER_IDENTITY, BROWSER_ORIGIN, ChannelFrame, ClusterBackplane, ClusterProbeClient, ContractRealtimeController, ContractRealtimeControllerBase, ContractRealtimeControllerStrict, LoopbackBackplane, MAX_CLIENT_SEVERITY, REDIS_RT_CHANNEL, RESERVED_SYSTEM_PREFIXES, Realtime, RealtimeAction, RealtimeBroadcast, RealtimeChannel, RealtimeController, RealtimeHub, RealtimeInbound, RealtimePolicy, RealtimeService, RedisBackplane, SLOW_CONSUMER_BYTES, ServerRealtimeSocket, WsConnectionTransport, buildOwnHealth, buildRealtimeHealth, clusterProbeHealth, clusterProbeInstance, clusterProbeRequestEnrich, createRealtimeAdminApi, createRedisServiceTransport, createSyslogUplinkHandler, realtime_default as default, defineRealtimeConfig, getBackplaneDriver, getDeclaredBroadcastPrefixes, getRealtimeHub, isReservedSystemChannel, listBackplaneDrivers, mergeClusterHealth, openBackplaneEnvelope, processIpcTransport, processProbeTransport, realtimeConfigJsonSchema, registerBackplaneDriver, resolveBackplaneOriginId, resolveRedisBytesChannel, resolveRedisChannel, sealBackplaneEnvelope, serverSocket, setClusterProbeClient };
302
+ export { ANONYMOUS_REALTIME_TOKEN, ANONYMOUS_SUBSCRIBER_IDENTITY, BACKPLANE_STOP_TIMEOUT_MS, BROWSER_ORIGIN, BackplaneEcho, BackplaneIngress, BackplanePublishQueue, ChannelFrame, ClusterBackplane, ClusterProbeClient, ContractRealtimeController, ContractRealtimeControllerBase, ContractRealtimeControllerStrict, DEFAULT_MAX_QUEUE_BYTES, DEFAULT_PRESENCE_MAX_NAMED, LoopbackBackplane, MAX_CLIENT_SEVERITY, PRESENCE_WINDOW_MS, REDIS_RT_CHANNEL, RESERVED_SYSTEM_PREFIXES, Realtime, RealtimeAction, RealtimeBroadcast, RealtimeChannel, RealtimeController, RealtimeHub, RealtimeInbound, RealtimePolicy, RealtimeService, RedisBackplane, SLOW_CONSUMER_BYTES, ServerRealtimeSocket, WsConnectionTransport, buildOwnHealth, buildRealtimeHealth, clusterProbeHealth, clusterProbeInstance, clusterProbeRequestEnrich, createRealtimeAdminApi, createRedisServiceTransport, createSyslogUplinkHandler, realtime_default as default, defineRealtimeConfig, getBackplaneDriver, getDeclaredBroadcastPrefixes, getRealtimeHub, isReservedSystemChannel, listBackplaneDrivers, mergeClusterHealth, openBackplaneEnvelope, processIpcTransport, processProbeTransport, realtimeConfigJsonSchema, registerBackplaneDriver, resolveBackplaneOriginId, resolveRedisBytesChannel, resolveRedisChannel, sealBackplaneEnvelope, serverSocket, setClusterProbeClient, shutdownRealtime };
@@ -1,5 +1,6 @@
1
1
  import { isCallable, isRecord } from "../src/guards.js";
2
- import { BootConfigurationError, Injector } from "nodefony";
2
+ import { presenceDefect, samePresence } from "../src/server/channelPresence.js";
3
+ import { BootConfigurationError, Injector, channelPatternWeight, compileChannelPattern } from "nodefony";
3
4
  import "reflect-metadata";
4
5
  // nodefony/decorators/realtimeDecorators.ts
5
6
  /** Clés de métadonnées posées sur le constructor de la classe controller. */
@@ -7,6 +8,10 @@ const ACTIONS_KEY = "realtime:actions";
7
8
  const CHANNELS_KEY = "realtime:channels";
8
9
  const INBOUND_KEY = "realtime:inbound";
9
10
  const POLICIES_KEY = "realtime:policies";
11
+ const WRITE_POLICIES_KEY = "realtime:write-policies";
12
+ const OWN_READ_KEY = "realtime:policies:own-read";
13
+ const OWN_WRITE_KEY = "realtime:policies:own-write";
14
+ const CONFLICTS_KEY = "realtime:policies:conflicts";
10
15
  /**
11
16
  * Lit une métadonnée de classe posée par ces décorateurs : un objet nom → valeur,
12
17
  * ou `undefined` quand la classe n'en porte pas. Les valeurs restent `unknown` :
@@ -17,6 +22,11 @@ function readMetadataMap(key, ctor) {
17
22
  const raw = Reflect.getMetadata(key, ctor);
18
23
  return isRecord(raw) ? raw : void 0;
19
24
  }
25
+ /** Comme {@link readMetadataMap}, sans remonter la chaîne de prototypes : ce que la classe a posé ELLE-MÊME. */
26
+ function readOwnMetadataMap(key, ctor) {
27
+ const raw = Reflect.getOwnMetadata(key, ctor);
28
+ return isRecord(raw) ? raw : void 0;
29
+ }
20
30
  /** Vrai si la valeur est un tableau de chaînes. */
21
31
  function isStringArray(value) {
22
32
  return Array.isArray(value) && value.every((v) => typeof v === "string");
@@ -30,7 +40,8 @@ const POLICY_KEYS = /* @__PURE__ */ new Set([
30
40
  "roles",
31
41
  "scopes",
32
42
  "allow",
33
- "anonymous"
43
+ "anonymous",
44
+ "presence"
34
45
  ]);
35
46
  /**
36
47
  * `allow` désigne-t-il une classe d'admission (`@injectable`) plutôt qu'une flèche ?
@@ -54,7 +65,9 @@ function isAdmissionClass(allow) {
54
65
  function channelPolicyDefect(value) {
55
66
  if (!isRecord(value)) return "la politique n'est pas un objet";
56
67
  for (const key of Object.keys(value)) if (!POLICY_KEYS.has(key)) return `clé inconnue « ${key} » (clés admises : ${[...POLICY_KEYS].join(", ")})`;
57
- const { authenticated, roles, scopes, allow, anonymous } = value;
68
+ const { authenticated, roles, scopes, allow, anonymous, presence } = value;
69
+ const presenceIssue = presenceDefect(presence);
70
+ if (presenceIssue !== null) return presenceIssue;
58
71
  if (authenticated !== void 0 && typeof authenticated !== "boolean") return "« authenticated » doit être un booléen";
59
72
  if (roles !== void 0 && !isStringArray(roles)) return "« roles » doit être un tableau de chaînes";
60
73
  if (scopes !== void 0 && !isStringArray(scopes)) return "« scopes » doit être un tableau de chaînes";
@@ -66,6 +79,34 @@ function channelPolicyDefect(value) {
66
79
  if (!Injector.isInjectable(allow) && isRecord(proto) && typeof proto.allow === "function") return `« allow » est une classe (${allow.name}) sans @injectable`;
67
80
  return null;
68
81
  }
82
+ /** Une valeur lue sous la métadonnée des politiques est-elle une politique recevable ? */
83
+ function isChannelPolicy(value) {
84
+ return channelPolicyDefect(value) === null;
85
+ }
86
+ const NO_NAMES = [];
87
+ /** Deux listes de rôles ou de scopes désignent-elles le même ENSEMBLE — ordre et doublons indifférents, absente = vide ? */
88
+ function sameNames(a, b) {
89
+ const x = a ?? NO_NAMES;
90
+ const y = b ?? NO_NAMES;
91
+ for (const name of x) if (!y.includes(name)) return false;
92
+ for (const name of y) if (!x.includes(name)) return false;
93
+ return true;
94
+ }
95
+ /**
96
+ * Deux politiques exigent-elles exactement la même chose ? Structurel : deux littéraux identiques écrits dans deux classes le sont ; `allow` se compare par référence.
97
+ *
98
+ * Sert à refuser deux déclarations CONTRADICTOIRES d'un même nom (une seule
99
+ * s'appliquerait) sans refuser deux déclarations ÉQUIVALENTES, et à ne juger
100
+ * l'écriture que si elle diffère de la lecture. Démarrage seulement.
101
+ *
102
+ * @param a - une politique recevable.
103
+ * @param b - une politique recevable.
104
+ * @returns `true` si aucun pair ne peut satisfaire l'une sans l'autre.
105
+ */
106
+ function samePolicy(a, b) {
107
+ if (a === b) return true;
108
+ return a.authenticated === true === (b.authenticated === true) && sameNames(a.roles, b.roles) && sameNames(a.scopes, b.scopes) && a.allow === b.allow && a.anonymous === true === (b.anonymous === true);
109
+ }
69
110
  /**
70
111
  * Exige que chaque entrée du registre soit une politique recevable — sinon le démarrage s'arrête en nommant la classe, le canal et le défaut.
71
112
  *
@@ -89,12 +130,30 @@ function hasDefinedKey(policy) {
89
130
  }
90
131
  /** Libération d'un canal dont la fabrique décorée n'en a rendu aucune. */
91
132
  const NO_DISPOSE = () => {};
92
- /** Enregistre (idempotent) une politique non vide sous son nom de canal/méthode. */
93
- function definePolicy(ctor, name, policy) {
133
+ /**
134
+ * Déclare une politique non vide d'une sorte sous son nom — sans jamais écraser une déclaration contradictoire de la même classe : celle-ci est retenue pour refuser le démarrage.
135
+ */
136
+ function declarePolicy(ctor, name, kind, policy) {
94
137
  if (!policy || !hasDefinedKey(policy)) return;
95
- const map = { ...readMetadataMap(POLICIES_KEY, ctor) };
96
- map[name] = policy;
97
- Reflect.defineMetadata(POLICIES_KEY, map, ctor);
138
+ const key = kind === "read" ? POLICIES_KEY : WRITE_POLICIES_KEY;
139
+ const ownKey = kind === "read" ? OWN_READ_KEY : OWN_WRITE_KEY;
140
+ const own = readOwnMetadataMap(ownKey, ctor);
141
+ const current = readMetadataMap(key, ctor)?.[name];
142
+ if (own?.[name] === true && current !== void 0 && !(isChannelPolicy(current) && isChannelPolicy(policy) && samePolicy(current, policy) && samePresence(current, policy))) {
143
+ Reflect.defineMetadata(CONFLICTS_KEY, {
144
+ ...readOwnMetadataMap(CONFLICTS_KEY, ctor),
145
+ [name]: kind
146
+ }, ctor);
147
+ return;
148
+ }
149
+ Reflect.defineMetadata(key, {
150
+ ...readMetadataMap(key, ctor),
151
+ [name]: policy
152
+ }, ctor);
153
+ Reflect.defineMetadata(ownKey, {
154
+ ...own,
155
+ [name]: true
156
+ }, ctor);
98
157
  }
99
158
  /**
100
159
  * Politique appliquée à une action RPC dont l'auteur n'a rien déclaré :
@@ -103,11 +162,6 @@ function definePolicy(ctor, name, policy) {
103
162
  * Pour une action délibérément publique, l'écrire : `{ authenticated: false }`.
104
163
  */
105
164
  const DEFAULT_ACTION_POLICY = Object.freeze({ authenticated: true });
106
- /** Une politique est-elle DÉJÀ déclarée sous ce nom ? */
107
- function hasPolicy(ctor, name) {
108
- const map = readMetadataMap(POLICIES_KEY, ctor);
109
- return map ? Object.prototype.hasOwnProperty.call(map, name) : false;
110
- }
111
165
  /**
112
166
  * Décorateur **méthode** — expose une **action RPC** request→response du
113
167
  * controller realtime sous le nom `method` (ex. `"nodefony:kernel:ping"`, `"chat:send"`).
@@ -127,9 +181,12 @@ function hasPolicy(ctor, name) {
127
181
  * défaut, toute action applicative serait publique sans que rien ne le dise.
128
182
  * Pour une action délibérément ouverte, l'écrire : `{ authenticated: false }`.
129
183
  *
130
- * Si une politique est **déjà déclarée sous ce nom** (canal homonyme via
131
- * `@RealtimeChannel`/`@RealtimeInbound`), elle est conservée : le défaut ne
132
- * rétrograde jamais une politique existante, souvent plus stricte.
184
+ * Une politique d'action est une politique d'**écriture** : l'appel exige aussi
185
+ * la lecture déclarée sous le même nom. Sans politique, l'action **hérite** de la
186
+ * lecture d'un canal homonyme (`@RealtimeChannel`) si elle existe — le défaut ne
187
+ * rétrograde jamais une politique déclarée —, et sinon du défaut fermé, quel que
188
+ * soit l'ordre des méthodes. Deux politiques explicites différentes sur le même
189
+ * nom (action et `@RealtimeInbound`) refusent le démarrage.
133
190
  *
134
191
  * ⚠️ Le NOM peut primer sur ta politique : le plancher système de
135
192
  * `@nodefony/security` réserve les préfixes plateforme (`kernel:`, `syslog:`…)
@@ -159,8 +216,7 @@ function RealtimeAction(method, policy) {
159
216
  const map = { ...readMetadataMap(ACTIONS_KEY, ctor) };
160
217
  map[method] = propertyKey;
161
218
  Reflect.defineMetadata(ACTIONS_KEY, map, ctor);
162
- if (policy) definePolicy(ctor, method, policy);
163
- else if (!hasPolicy(ctor, method)) definePolicy(ctor, method, DEFAULT_ACTION_POLICY);
219
+ declarePolicy(ctor, method, "write", policy);
164
220
  };
165
221
  }
166
222
  /**
@@ -178,6 +234,10 @@ function RealtimeAction(method, policy) {
178
234
  *
179
235
  * Un nom qui porte `*` est un motif (famille de canaux), comme partout ailleurs.
180
236
  *
237
+ * La politique vaut pour la LECTURE et l'ÉCRITURE du nom (le même objet, jugé
238
+ * une fois). Elle prime sur le défaut fermé d'une action ; contredire une
239
+ * politique explicite d'un décorateur de méthode refuse le démarrage.
240
+ *
181
241
  * **Sur un contrôleur de contrat** (`extends ContractRealtimeController<C>`), le
182
242
  * décorateur lit le contrat sur la classe qu'il décore : chaque nom doit être
183
243
  * une méthode ou un canal de `C`, sinon la classe ne compile pas, et l'erreur
@@ -193,20 +253,25 @@ function RealtimeAction(method, policy) {
193
253
  */
194
254
  function RealtimePolicy(policies) {
195
255
  return function(target) {
196
- for (const [name, policy] of Object.entries(policies)) definePolicy(target, name, policy);
256
+ for (const [name, policy] of Object.entries(policies)) {
257
+ declarePolicy(target, name, "read", policy);
258
+ declarePolicy(target, name, "write", policy);
259
+ }
197
260
  };
198
261
  }
199
262
  /**
200
- * Décorateur **méthode** — expose un **canal pub/sub** sous le nom `channel`
201
- * (match EXACT, sans préfixe `:<ms>` ni regex). La méthode décorée DOIT renvoyer
202
- * un `dispose: () => void` (la base le mémorise et l'appelle au dernier
263
+ * Décorateur **méthode** — expose un **canal pub/sub** sous le nom `channel`, ou
264
+ * une FAMILLE de canaux sous un motif (`chat:room:*`). La méthode décorée DOIT
265
+ * renvoyer un `dispose: () => void` (la base le mémorise et l'appelle au dernier
203
266
  * désabonné, comme avec l'override classique).
204
267
  *
205
- * v1 — match EXACT uniquement. Les canaux à pattern (`/^nodefony:orm:rich@(\d+)/`) ou à
206
- * suffixe de granularité (`nodefony:dashboard:1000`) restent dans l'override
207
- * `createRealtimeChannel()` (la base donne PRIORITÉ aux décorateurs, puis tombe
208
- * sur l'override en fallback — coexistence sans casse). Un mode `pattern: RegExp`
209
- * arrivera plus tard sans rompre cette API.
268
+ * Un motif (`*` = toute suite de caractères, la grammaire des politiques) sert
269
+ * chaque nom qu'il couvre : `@RealtimeChannel("chat:room:*", { allow })` déclare
270
+ * la règle ET la fabrique de tous les salons, et la méthode reçoit le nom réel
271
+ * (`chat:room:42`) en premier argument. Ordre de résolution au `subscribe` : le
272
+ * nom exact décoré, puis l'override `createRealtimeChannel()` (canaux à suffixe
273
+ * de granularité, regex — un contrôleur qui en a un garde son comportement),
274
+ * puis les motifs décorés, du plus spécifique au plus général.
210
275
  *
211
276
  * Le `this` est lié à l'instance ; le factory peut donc lire `this.kernel`, etc.
212
277
  * Mais ATTENTION : un provider est **partagé** entre toutes les connexions au
@@ -214,8 +279,20 @@ function RealtimePolicy(policies) {
214
279
  * créé. Capturer des **valeurs long-lived** (broker, syslog) dans la closure,
215
280
  * jamais `this.context` (lié à la connexion créatrice qui peut fermer).
216
281
  *
282
+ * La `policy` est celle de **LECTURE** : exigée au `subscribe`, et en plus de
283
+ * l'écriture sur toute trame entrante du même nom. Absente, la lecture hérite de
284
+ * l'écriture (`@RealtimeInbound`) si elle est déclarée — une application qui ne
285
+ * garde que l'écriture reste protégée en lecture. Pour un canal que tout le
286
+ * monde lit et que seuls certains écrivent, l'écrire : `{ authenticated: false }`.
287
+ *
217
288
  * @example
218
289
  * class ChatController extends RealtimeController {
290
+ * // Tous les salons : la règle et la fabrique, une seule fois.
291
+ * @RealtimeChannel("chat:room:*", { allow: RoomAdmission })
292
+ * room(_channel: string): () => void {
293
+ * return () => {};
294
+ * }
295
+ *
219
296
  * @RealtimeChannel("chat:room42")
220
297
  * room42(_channel: string, publish: RealtimePublish): () => void {
221
298
  * const timer = setInterval(() => publish("chat:room42", { ts: Date.now() }), 1000);
@@ -229,7 +306,7 @@ function RealtimeChannel(channel, policy) {
229
306
  const map = { ...readMetadataMap(CHANNELS_KEY, ctor) };
230
307
  map[channel] = propertyKey;
231
308
  Reflect.defineMetadata(CHANNELS_KEY, map, ctor);
232
- definePolicy(ctor, channel, policy);
309
+ declarePolicy(ctor, channel, "read", policy);
233
310
  };
234
311
  }
235
312
  /**
@@ -244,6 +321,20 @@ function RealtimeChannel(channel, policy) {
244
321
  * pousser tant qu'aucun handler n'est enregistré ici). Cf seam P13.8a
245
322
  * (`beforeDispatch`) pour la gate sécurité par voter P6.
246
323
  *
324
+ * La `policy` est celle d'**ÉCRITURE** : une trame entrante exige la LECTURE du
325
+ * même nom (`@RealtimeChannel`) ET elle — l'écriture n'est jamais moins stricte
326
+ * que la lecture. Sans lecture déclarée, la lecture en hérite.
327
+ *
328
+ * @example
329
+ * class ChatController extends RealtimeController {
330
+ * @RealtimeChannel("chat:typing", { authenticated: false }) // tout le monde lit
331
+ * typing(_channel: string, _publish: RealtimePublish): () => void {
332
+ * return () => {};
333
+ * }
334
+ * @RealtimeInbound("chat:typing", { authenticated: true }) // seuls les connectés écrivent
335
+ * onTyping(): void {}
336
+ * }
337
+ *
247
338
  * @example
248
339
  * class ChatController extends RealtimeController {
249
340
  * @RealtimeInbound("chat:send")
@@ -260,7 +351,7 @@ function RealtimeInbound(method, policy) {
260
351
  const map = { ...readMetadataMap(INBOUND_KEY, ctor) };
261
352
  map[method] = propertyKey;
262
353
  Reflect.defineMetadata(INBOUND_KEY, map, ctor);
263
- definePolicy(ctor, method, policy);
354
+ declarePolicy(ctor, method, "write", policy);
264
355
  };
265
356
  }
266
357
  /**
@@ -301,6 +392,47 @@ function getRealtimeChannels(instance) {
301
392
  }
302
393
  return out;
303
394
  }
395
+ /**
396
+ * Motifs décorés de chaque classe, compilés au premier besoin : du plus
397
+ * spécifique au plus général. Par CLASSE et non par instance — un contrôleur
398
+ * temps réel est instancié par connexion, ses motifs ne changent pas.
399
+ */
400
+ const decoratedPatterns = /* @__PURE__ */ new WeakMap();
401
+ /**
402
+ * La fabrique décorée sous un MOTIF qui couvre `channel` — `@RealtimeChannel("chat:room:*")` sert `chat:room:42` —, ou `null` si aucun motif ne le couvre.
403
+ *
404
+ * Même grammaire que les politiques (`compileChannelPattern`) et même
405
+ * départage (`channelPatternWeight`, le plus spécifique d'abord) : un nom
406
+ * reçoit la fabrique du motif dont il reçoit la règle. Un motif refusé par la
407
+ * grammaire (trop d'étoiles) ne sert rien — le hub le dit déjà au démarrage.
408
+ * Cold path : appelé au premier abonné d'un canal qu'aucun nom exact ni
409
+ * `createRealtimeChannel` ne produit.
410
+ *
411
+ * @param instance - le contrôleur ; ses motifs se lisent sur sa classe.
412
+ * @param channels - le registre rendu par {@link getRealtimeChannels}.
413
+ * @param channel - le nom demandé.
414
+ */
415
+ function patternChannelFactory(instance, channels, channel) {
416
+ if (channels === null) return null;
417
+ let patterns = decoratedPatterns.get(instance.constructor);
418
+ if (patterns === void 0) {
419
+ const compiled = [];
420
+ for (const source of Object.keys(channels)) {
421
+ if (!source.includes("*")) continue;
422
+ const re = compileChannelPattern(source);
423
+ if (re !== null) compiled.push({
424
+ source,
425
+ re,
426
+ weight: channelPatternWeight(source)
427
+ });
428
+ }
429
+ compiled.sort((a, b) => b.weight - a.weight);
430
+ patterns = compiled;
431
+ decoratedPatterns.set(instance.constructor, patterns);
432
+ }
433
+ for (const pattern of patterns) if (pattern.re.test(channel)) return channels[pattern.source] ?? null;
434
+ return null;
435
+ }
304
436
  /** Lecture (lazy, au handshake) du registre des canaux inbound décorés. */
305
437
  function getRealtimeInbound(instance) {
306
438
  const map = readMetadataMap(INBOUND_KEY, instance.constructor);
@@ -323,19 +455,79 @@ function declaresAction(ctor, name) {
323
455
  return map !== void 0 && Object.prototype.hasOwnProperty.call(map, name);
324
456
  }
325
457
  /**
326
- * Politiques d'autorisation déclarées sur une CLASSE de contrôleur (`@RealtimeChannel`/`@RealtimeInbound`/`@RealtimePolicy` avec politique), validées — lues au démarrage du noyau, jamais par trame.
458
+ * Politiques déclarées sur une CLASSE de contrôleur, validées et résolues par sorte — lues au démarrage du noyau, jamais par trame.
459
+ *
460
+ * Résolution indépendante de l'ordre des méthodes :
461
+ * - l'écriture d'un nom est celle déclarée (`@RealtimeInbound`, `@RealtimeAction`
462
+ * avec politique, `@RealtimePolicy`) ; pour une action SANS politique, le
463
+ * défaut fermé, sauf si une lecture est déclarée — l'action en hérite alors ;
464
+ * - la lecture est celle déclarée (`@RealtimeChannel`, `@RealtimePolicy`),
465
+ * sinon l'écriture : une application qui ne garde que l'écriture reste
466
+ * protégée en lecture ;
467
+ * - `kinds` garde ce que la classe déclare, SANS héritage : le hub le fusionne
468
+ * avec les motifs de toutes les classes, sorte par sorte.
469
+ *
470
+ * @param ctor - constructeur du contrôleur.
471
+ * @returns les deux tables, ou `null` si la classe ne déclare aucune politique.
472
+ * @throws BootConfigurationError Une politique est illisible, ou deux
473
+ * déclarations d'une même sorte se contredisent : la classe, le canal et le
474
+ * défaut sont nommés.
475
+ */
476
+ function getDeclaredChannelPolicies(ctor) {
477
+ const readMap = readMetadataMap(POLICIES_KEY, ctor);
478
+ const writeMap = readMetadataMap(WRITE_POLICIES_KEY, ctor);
479
+ const actions = readMetadataMap(ACTIONS_KEY, ctor);
480
+ if (readMap === void 0 && writeMap === void 0 && actions === void 0) return null;
481
+ const owner = typeof ctor === "function" ? ctor.name : "(contrôleur)";
482
+ const conflicts = readMetadataMap(CONFLICTS_KEY, ctor);
483
+ if (conflicts !== void 0) for (const [name, kind] of Object.entries(conflicts)) throw new BootConfigurationError(`${owner} : le canal temps réel « ${name} » déclare deux politiques de ${kind === "read" ? "LECTURE" : "ÉCRITURE"} différentes — une seule s'appliquerait, l'autre disparaîtrait sans un mot. Garder une seule déclaration ; pour garder la lecture et l'écriture séparément : @RealtimeChannel(nom, lecture) et @RealtimeInbound(nom, écriture).`);
484
+ if (readMap !== void 0) assertPolicyMap(owner, readMap);
485
+ if (writeMap !== void 0) assertPolicyMap(owner, writeMap);
486
+ const read = {};
487
+ const kinds = {};
488
+ let any = false;
489
+ if (readMap !== void 0) for (const [name, policy] of Object.entries(readMap)) {
490
+ read[name] = policy;
491
+ kinds[name] = {
492
+ read: policy,
493
+ write: null
494
+ };
495
+ any = true;
496
+ }
497
+ if (writeMap !== void 0) for (const [name, policy] of Object.entries(writeMap)) {
498
+ kinds[name] = {
499
+ read: kinds[name]?.read ?? null,
500
+ write: policy
501
+ };
502
+ read[name] ??= policy;
503
+ any = true;
504
+ }
505
+ const actionNames = actions === void 0 ? [] : Object.keys(actions);
506
+ for (const name of actionNames) {
507
+ if (Object.hasOwn(read, name)) continue;
508
+ read[name] = DEFAULT_ACTION_POLICY;
509
+ any = true;
510
+ }
511
+ return any ? {
512
+ read,
513
+ kinds,
514
+ actions: actionNames
515
+ } : null;
516
+ }
517
+ /**
518
+ * Politiques de LECTURE déclarées sur une CLASSE de contrôleur (`@RealtimeChannel`/`@RealtimeInbound`/`@RealtimeAction`/`@RealtimePolicy`), validées — lues au démarrage du noyau, jamais par trame.
519
+ *
520
+ * La lecture d'un nom est celle exigée au `subscribe` ; une écriture distincte
521
+ * se lit par {@link getDeclaredChannelPolicies}.
327
522
  *
328
523
  * @param ctor - constructeur du contrôleur.
329
- * @returns nom de canal ou de méthode → politique, ou `null` si la classe n'en
330
- * déclare aucune (0 allocation).
331
- * @throws BootConfigurationError Une politique est illisible : la classe, le
332
- * canal et le défaut sont nommés.
524
+ * @returns nom de canal ou de méthode → politique de lecture, ou `null` si la
525
+ * classe n'en déclare aucune.
526
+ * @throws BootConfigurationError Une politique est illisible ou contredite :
527
+ * la classe, le canal et le défaut sont nommés.
333
528
  */
334
529
  function getChannelPoliciesOf(ctor) {
335
- const map = readMetadataMap(POLICIES_KEY, ctor);
336
- if (map === void 0) return null;
337
- assertPolicyMap(typeof ctor === "function" ? ctor.name : "(contrôleur)", map);
338
- return map;
530
+ return getDeclaredChannelPolicies(ctor)?.read ?? null;
339
531
  }
340
532
  /**
341
533
  * Politiques déclarées sur la classe d'une instance de contrôleur — même lecture que {@link getChannelPoliciesOf}.
@@ -393,4 +585,4 @@ function getDeclaredBroadcastPrefixes() {
393
585
  function applyDeclaredBroadcastPrefixes(hub) {
394
586
  for (const prefix of declaredBroadcastPrefixes) hub.markBroadcastChannel(prefix);
395
587
  }
396
- export { DEFAULT_ACTION_POLICY, RealtimeAction, RealtimeBroadcast, RealtimeChannel, RealtimeInbound, RealtimePolicy, applyDeclaredBroadcastPrefixes, channelPolicyDefect, declaresAction, getChannelPoliciesOf, getDeclaredBroadcastPrefixes, getRealtimeActions, getRealtimeChannelPolicies, getRealtimeChannels, getRealtimeInbound, isAdmissionClass };
588
+ export { DEFAULT_ACTION_POLICY, RealtimeAction, RealtimeBroadcast, RealtimeChannel, RealtimeInbound, RealtimePolicy, applyDeclaredBroadcastPrefixes, channelPolicyDefect, declaresAction, getChannelPoliciesOf, getDeclaredBroadcastPrefixes, getDeclaredChannelPolicies, getRealtimeActions, getRealtimeChannelPolicies, getRealtimeChannels, getRealtimeInbound, isAdmissionClass, patternChannelFactory, samePolicy };
@@ -1,6 +1,7 @@
1
1
  import resolveBackplaneOriginId from "./originId.js";
2
2
  import { isCallable, isRecord, isThenable } from "../guards.js";
3
- import { openBackplaneEnvelope, openBackplaneEnvelopeBytes, sealBackplaneEnvelope, sealBackplaneEnvelopeBytes } from "./envelope.js";
3
+ import { sealBackplaneEnvelope, sealBackplaneEnvelopeBytes } from "./envelope.js";
4
+ import { BackplaneIngress } from "./ingress.js";
4
5
  import { BackplanePublishQueue } from "./publishQueue.js";
5
6
  import { containsBytes } from "nodefony";
6
7
  // nodefony/src/backplane/RedisBackplane.ts
@@ -167,8 +168,8 @@ var RedisBackplane = class RedisBackplane {
167
168
  /** Borne mémoire des publications en vol (cf {@link BackplanePublishQueue}). */
168
169
  #queue;
169
170
  #notice;
170
- /** Messages reçus dont le sceau est refusé alors qu'un secret est posé (monotone). */
171
- #sealRejectedTotal = 0;
171
+ /** Tri de l'entrée : écho, sceau, refus comptés (brique commune aux pilotes). */
172
+ #ingress;
172
173
  #handler = null;
173
174
  #started = false;
174
175
  /**
@@ -183,6 +184,7 @@ var RedisBackplane = class RedisBackplane {
183
184
  this.#secret = secret;
184
185
  this.#notice = options.onNotice ?? null;
185
186
  this.#queue = new BackplanePublishQueue(options.maxQueueBytes ?? 8388608, this.#notice);
187
+ this.#ingress = new BackplaneIngress(originId, secret, this.#notice);
186
188
  }
187
189
  /**
188
190
  * Abonne au canal Redis dédié. Idempotent (le flag est posé avant l'`await` →
@@ -191,7 +193,7 @@ var RedisBackplane = class RedisBackplane {
191
193
  async start() {
192
194
  if (this.#started) return;
193
195
  this.#started = true;
194
- await Promise.all([this.#transport.subscribe(this.#redisChannel, (raw) => this.#ingress(raw)), this.#subscribeBytes()]);
196
+ await Promise.all([this.#transport.subscribe(this.#redisChannel, (raw) => this.#receive(raw)), this.#subscribeBytes()]);
195
197
  }
196
198
  /**
197
199
  * Arme le sujet binaire (ADR-0021 S6) : les charges qui contiennent des octets
@@ -216,7 +218,7 @@ var RedisBackplane = class RedisBackplane {
216
218
  async #subscribeBytes() {
217
219
  const bytes = this.#bytes;
218
220
  if (bytes === null) return;
219
- await this.#transport.subscribeBytes?.(bytes.channel, (raw) => this.#ingressBytes(raw, bytes.codec));
221
+ await this.#transport.subscribeBytes?.(bytes.channel, (raw) => this.#receiveBytes(raw, bytes.codec));
220
222
  }
221
223
  /**
222
224
  * Propage une publication locale aux autres pods via Redis. NE refait PAS le
@@ -225,7 +227,8 @@ var RedisBackplane = class RedisBackplane {
225
227
  * L'envoi passe par la **file bornée** : si le bus n'acquitte plus, les
226
228
  * publications suivantes sont jetées et comptées plutôt que d'empiler des
227
229
  * mégaoctets dans le client Redis (sémantique at-most-once déjà assumée par le
228
- * port ; le client realtime re-synchronise).
230
+ * port ; le client realtime re-synchronise). Un ordre de contrôle, lui, part
231
+ * toujours (cf {@link BackplanePublishQueue.send}).
229
232
  */
230
233
  publish(channel, payload) {
231
234
  const env = {
@@ -236,55 +239,44 @@ var RedisBackplane = class RedisBackplane {
236
239
  const bytes = this.#bytes;
237
240
  if (bytes !== null && containsBytes(payload)) {
238
241
  const bin = sealBackplaneEnvelopeBytes(env, bytes.codec, this.#secret);
239
- this.#queue.send(bin.byteLength, () => this.#transport.publish(bytes.channel, bin));
242
+ this.#queue.send(channel, bin.byteLength, () => this.#transport.publish(bytes.channel, bin));
240
243
  return;
241
244
  }
242
245
  const raw = sealBackplaneEnvelope(env, this.#secret);
243
- this.#queue.send(Buffer.byteLength(raw), () => this.#transport.publish(this.#redisChannel, raw));
246
+ this.#queue.send(channel, Buffer.byteLength(raw), () => this.#transport.publish(this.#redisChannel, raw));
244
247
  }
245
248
  /** Un seul handler d'ingress ; un appel ultérieur remplace le précédent. */
246
249
  onMessage(handler) {
247
250
  this.#handler = handler;
248
251
  }
249
252
  /**
250
- * Ouverture d'enveloppe (parse + vérification du sceau) + anti-echo + délégation.
251
- * Robuste par construction : un message malformé, non scellé alors qu'un secret
252
- * est exigé, ou au sceau invalide, est **ignoré** — jamais réinjecté, jamais fatal.
253
- * Sous un secret, chaque rejet est compté (`sealRejectedTotal`) et journalisé la
254
- * première fois puis une fois sur cent (ADR-0021 S6) : une rotation du secret ou
255
- * un producteur mal configuré ne perd plus de messages en silence.
253
+ * Remet au hub ce que le tri d'entrée admet ({@link BackplaneIngress}) : l'écho
254
+ * de ce pod (Redis renvoie à l'émetteur) est compté sans être décodé ; un message
255
+ * malformé ou au sceau refusé est **ignoré** — jamais réinjecté, jamais fatal —,
256
+ * compté et annoncé la première fois puis une fois sur cent (ADR-0021 S6).
256
257
  */
257
- #ingress(raw) {
258
- const msg = openBackplaneEnvelope(raw, this.#secret);
259
- if (msg === null) {
260
- if (this.#secret !== null) this.#sealRejected();
261
- return;
262
- }
263
- if (msg.originId === this.originId) return;
264
- this.#handler?.(msg);
265
- }
266
- /** Même tri que {@link #ingress}, sur le sujet binaire : décodage, sceau, anti-echo. */
267
- #ingressBytes(raw, codec) {
268
- const msg = openBackplaneEnvelopeBytes(raw, codec, this.#secret);
269
- if (msg === null) {
270
- if (this.#secret !== null) this.#sealRejected();
271
- return;
272
- }
273
- if (msg.originId === this.originId) return;
274
- this.#handler?.(msg);
258
+ #receive(raw) {
259
+ const msg = this.#ingress.text(raw);
260
+ if (msg !== null) this.#handler?.(msg);
275
261
  }
276
- /** Compte un sceau refusé ; annonce le premier, puis un sur cent (pas d'inondation du journal). */
277
- #sealRejected() {
278
- this.#sealRejectedTotal += 1;
279
- const n = this.#sealRejectedTotal;
280
- if (n !== 1 && n % 100 !== 0) return;
281
- this.#notice?.(`backplane : ${n} message(s) reçu(s) au sceau refusé, JETÉS. Causes : rotation du secret en cours (des exemplaires n'ont pas le même NF_REALTIME_BACKPLANE_SECRET), producteur mal configuré, ou écriture tierce dans le bus. Compteur : sealRejectedTotal.`, "WARNING");
262
+ /** Même tri que {@link #receive}, sur le sujet binaire. */
263
+ #receiveBytes(raw, codec) {
264
+ const msg = this.#ingress.bytes(raw, codec);
265
+ if (msg !== null) this.#handler?.(msg);
282
266
  }
283
- /** Désabonne du canal Redis et détache le handler. Idempotent. */
267
+ /**
268
+ * Laisse partir les publications déjà émises (acquittées ou refusées par Redis), puis désabonne du canal et détache le handler. Idempotent.
269
+ *
270
+ * Sans l'attente, un arrêt suivi de la sortie du processus perdait ce qui
271
+ * était encore dans la file — la preuve de vie FINALE de la présence comprise,
272
+ * et les pairs gardaient alors ce pod jusqu'au délai d'oubli. Un Redis qui ne
273
+ * répond plus fait attendre : l'appelant borne (l'arrêt du module le fait).
274
+ */
284
275
  async stop() {
285
276
  this.#handler = null;
286
277
  if (!this.#started) return;
287
278
  this.#started = false;
279
+ await this.#queue.settled();
288
280
  await Promise.all([this.#transport.unsubscribe(this.#redisChannel), this.#bytes !== null ? this.#transport.unsubscribe(this.#bytes.channel) : void 0]);
289
281
  }
290
282
  describe() {
@@ -295,7 +287,8 @@ var RedisBackplane = class RedisBackplane {
295
287
  crossPod: true,
296
288
  channel: this.#redisChannel,
297
289
  sealed: this.#secret !== null,
298
- ...this.#secret !== null && { sealRejectedTotal: this.#sealRejectedTotal },
290
+ ...this.#secret !== null && { sealRejectedTotal: this.#ingress.sealRejectedTotal },
291
+ echoTotal: this.#ingress.echoTotal,
299
292
  queue: this.#queue.describe(),
300
293
  ...this.#bytes !== null && { bytes: true }
301
294
  };