@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
@@ -1,8 +1,9 @@
1
+ import "../src/server/RealtimeController.js";
1
2
  import { isRecord } from "../src/guards.js";
2
3
  import { createAdmissionResolver, registerControllerPolicies } from "../src/server/channelPolicyRegistry.js";
3
4
  import { getRealtimeHub } from "../src/server/RealtimeHub.js";
4
5
  import { BACKPLANE_CONFORMANCE_CASES, runBackplaneConformance } from "./backplaneConformance.js";
5
- import { RequestContext } from "nodefony";
6
+ import { IDENTITY_DEAD_CLOSE_CODE, ORIGIN_REFUSED_CLOSE_CODE, RequestContext } from "nodefony";
6
7
  // nodefony/testing/index.ts
7
8
  /**
8
9
  * `@nodefony/realtime/testing` — **harnais de test d'un controller temps réel**.
@@ -214,15 +215,28 @@ function createRealtimeHarness(create, options = {}) {
214
215
  closes,
215
216
  notices,
216
217
  async connect() {
217
- await feed(null);
218
- hub.onPlatformNotice((message) => {
219
- notices.push(message);
220
- });
221
- await waitFor(() => received.some((f) => f.method === "realtime:welcome"), 10);
218
+ const failures = [];
219
+ const syslog = controller.syslog;
220
+ const onLog = (pdu) => {
221
+ const payload = pdu.payload;
222
+ if (pdu.severityName === "ERROR" && typeof payload === "string" && payload.startsWith("WS realtime handshake failed")) failures.push(payload);
223
+ };
224
+ syslog?.on("onLog", onLog);
225
+ try {
226
+ await feed(null);
227
+ hub.onPlatformNotice((message) => {
228
+ notices.push(message);
229
+ });
230
+ await waitFor(() => received.some((f) => f.method === "realtime:welcome") || failures.length > 0, 10);
231
+ } finally {
232
+ syslog?.removeListener("onLog", onLog);
233
+ }
222
234
  const welcome = received.find((f) => f.method === "realtime:welcome");
223
235
  if (!welcome) {
224
- const cause = closes.length > 0 ? `socket fermée (code ${String(closes[0]?.code)})` : "aucune frame émise";
225
- throw new Error(`createRealtimeHarness: pas de realtime:welcome — ${cause}. Vérifier l'Origin (seam #4) et l'authenticator (seam #2).`);
236
+ const code = closes[0]?.code;
237
+ const cause = closes.length > 0 ? `socket fermée (code ${String(code)})` : "aucune frame émise";
238
+ const hint = failures.length > 0 ? `le serveur a levé pendant la poignée de main : ${failures[0] ?? ""}` : code === ORIGIN_REFUSED_CLOSE_CODE ? "Origin refusée (option `origin` du harnais)" : code === IDENTITY_DEAD_CLOSE_CODE ? "identité refusée (option `identity` du harnais)" : "vérifier ce que le controller construit à la connexion (services, admissions, base)";
239
+ throw new Error(`createRealtimeHarness: pas de realtime:welcome — ${cause}. ${hint}.`);
226
240
  }
227
241
  return welcome;
228
242
  },
@@ -0,0 +1,54 @@
1
+ import { RealtimeConnection } from "../src/server/RealtimeConnection.js";
2
+ import { PresenceView, REALTIME_PRESENCE_METHOD, isPresenceFrame, ownField } from "nodefony";
3
+ // nodefony/testing/presenceClient.ts
4
+ /**
5
+ * Abonne à `channel` une connexion de `hub` — nommée `user`, ou anonyme (`null`) — et relève les trames de présence qu'elle reçoit, lues et appliquées par le lecteur et la vue du cœur, comme le ferait un navigateur. Une trame que le lecteur refuse est un défaut du serveur : le banc lève en la citant.
6
+ */
7
+ function presenceClient(hub, channel, user) {
8
+ const frames = [];
9
+ const outcomes = [];
10
+ const view = new PresenceView(channel);
11
+ const codec = hub.textFrameCodec;
12
+ const conn = new RealtimeConnection({
13
+ impose: () => {},
14
+ end: () => false,
15
+ send: (frame) => {
16
+ const data = frame.encodeFor(codec);
17
+ if (typeof data !== "string") return;
18
+ const msg = JSON.parse(data);
19
+ if (ownField(msg, "method") !== REALTIME_PRESENCE_METHOD) return;
20
+ const params = ownField(msg, "params");
21
+ if (!isPresenceFrame(params)) throw new Error(`trame de présence mal formée : ${data}`);
22
+ frames.push(params);
23
+ outcomes.push(view.apply(params));
24
+ }
25
+ }, () => ({
26
+ type: user === null ? "anonymous" : "test",
27
+ authenticated: user !== null,
28
+ id: user,
29
+ userIdentifier: user ?? "anon.",
30
+ roles: [],
31
+ scopes: [],
32
+ issuer: null,
33
+ account: user !== null
34
+ }));
35
+ const sink = () => {};
36
+ if (!hub.subscribeClient(channel, sink, () => () => {}, void 0, conn)) throw new Error(`abonnement à « ${channel} » refusé`);
37
+ hub.presenceSubscribed(channel, conn);
38
+ return {
39
+ frames,
40
+ outcomes,
41
+ view,
42
+ leave: () => {
43
+ hub.unsubscribe(channel, sink);
44
+ }
45
+ };
46
+ }
47
+ /** Présents nommés d'un canal vus par `hub` (identité → onglets), triés. */
48
+ function presenceView(hub, channel) {
49
+ const v = hub.presence(channel);
50
+ const out = {};
51
+ if (v !== null) for (const [k, e] of [...v.members].sort(([a], [b]) => a.localeCompare(b))) out[k] = e.tabs;
52
+ return out;
53
+ }
54
+ export { presenceClient, presenceView };
@@ -112,11 +112,16 @@ export { createSyslogUplinkHandler, BROWSER_ORIGIN, MAX_CLIENT_SEVERITY, } from
112
112
  export type { SyslogUplinkHandlerOptions, ClientSeverity, } from "./nodefony/src/server/syslogUplink.js";
113
113
  export type { ChannelSink, ChannelFactory, ChannelFrameBuilder, } from "./nodefony/src/server/RealtimeHub.js";
114
114
  export { ChannelFrame } from "./nodefony/src/server/RealtimeHub.js";
115
+ export { PRESENCE_WINDOW_MS, DEFAULT_PRESENCE_MAX_NAMED, } from "./nodefony/src/server/channelPresence.js";
116
+ export type { IChannelPresenceView } from "./nodefony/src/server/channelPresence.js";
115
117
  export type { IBackplane, IBackplaneMessage, BackplaneHandler, IBackplaneInfo, } from "./nodefony/interfaces/IBackplane.js";
116
118
  export type { IClusterBackplaneTransport, ClusterBackplaneEnvelope, } from "./nodefony/src/backplane/ClusterBackplane.js";
117
119
  export type { IRedisBackplaneTransport, IRedisPublisher, IRedisSubscriber, IRedisBytesSubscriber, } from "./nodefony/src/backplane/RedisBackplane.js";
118
120
  export type { BackplaneFactory, IBackplaneFactoryContext, } from "./nodefony/src/backplane/backplaneRegistry.js";
119
- export { sealBackplaneEnvelope, openBackplaneEnvelope, } from "./nodefony/src/backplane/envelope.js";
121
+ export { sealBackplaneEnvelope, openBackplaneEnvelope, BackplaneEcho, } from "./nodefony/src/backplane/envelope.js";
122
+ export { BACKPLANE_STOP_TIMEOUT_MS, shutdownRealtime, } from "./nodefony/src/server/realtimeShutdown.js";
123
+ export { BackplanePublishQueue, DEFAULT_MAX_QUEUE_BYTES, type BackplaneNotice, type BackplaneNoticeSeverity, } from "./nodefony/src/backplane/publishQueue.js";
124
+ export { BackplaneIngress } from "./nodefony/src/backplane/ingress.js";
120
125
  export type { IClusterProbeTransport } from "./nodefony/src/cluster/ClusterProbeClient.js";
121
126
  export type { RawWsConnection } from "./nodefony/src/transport/WsConnectionTransport.js";
122
127
  export type { IRealtimeProbe, IRealtimeHealth, IRealtimeClusterHealth, IRealtimeChannelStat, IRealtimeConnProbe, } from "./nodefony/interfaces/IRealtimeProbe.js";
@@ -41,6 +41,18 @@ export declare function isAdmissionClass(allow: ChannelAllow): allow is ChannelA
41
41
  * @returns la raison du refus, ou `null`.
42
42
  */
43
43
  export declare function channelPolicyDefect(value: unknown): string | null;
44
+ /**
45
+ * 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.
46
+ *
47
+ * Sert à refuser deux déclarations CONTRADICTOIRES d'un même nom (une seule
48
+ * s'appliquerait) sans refuser deux déclarations ÉQUIVALENTES, et à ne juger
49
+ * l'écriture que si elle diffère de la lecture. Démarrage seulement.
50
+ *
51
+ * @param a - une politique recevable.
52
+ * @param b - une politique recevable.
53
+ * @returns `true` si aucun pair ne peut satisfaire l'une sans l'autre.
54
+ */
55
+ export declare function samePolicy(a: IChannelPolicy, b: IChannelPolicy): boolean;
44
56
  /**
45
57
  * Politique appliquée à une action RPC dont l'auteur n'a rien déclaré :
46
58
  * **exiger une connexion authentifiée**. Une action est une méthode que le pair
@@ -81,9 +93,12 @@ export type RealtimeChannelFactory = (channel: string, publish: RealtimePublish)
81
93
  * défaut, toute action applicative serait publique sans que rien ne le dise.
82
94
  * Pour une action délibérément ouverte, l'écrire : `{ authenticated: false }`.
83
95
  *
84
- * Si une politique est **déjà déclarée sous ce nom** (canal homonyme via
85
- * `@RealtimeChannel`/`@RealtimeInbound`), elle est conservée : le défaut ne
86
- * rétrograde jamais une politique existante, souvent plus stricte.
96
+ * Une politique d'action est une politique d'**écriture** : l'appel exige aussi
97
+ * la lecture déclarée sous le même nom. Sans politique, l'action **hérite** de la
98
+ * lecture d'un canal homonyme (`@RealtimeChannel`) si elle existe — le défaut ne
99
+ * rétrograde jamais une politique déclarée —, et sinon du défaut fermé, quel que
100
+ * soit l'ordre des méthodes. Deux politiques explicites différentes sur le même
101
+ * nom (action et `@RealtimeInbound`) refusent le démarrage.
87
102
  *
88
103
  * ⚠️ Le NOM peut primer sur ta politique : le plancher système de
89
104
  * `@nodefony/security` réserve les préfixes plateforme (`kernel:`, `syslog:`…)
@@ -136,6 +151,10 @@ type PolicyNamesCheck<P, T> = [NamesNotInContract<P, T>] extends [never] ? unkno
136
151
  *
137
152
  * Un nom qui porte `*` est un motif (famille de canaux), comme partout ailleurs.
138
153
  *
154
+ * La politique vaut pour la LECTURE et l'ÉCRITURE du nom (le même objet, jugé
155
+ * une fois). Elle prime sur le défaut fermé d'une action ; contredire une
156
+ * politique explicite d'un décorateur de méthode refuse le démarrage.
157
+ *
139
158
  * **Sur un contrôleur de contrat** (`extends ContractRealtimeController<C>`), le
140
159
  * décorateur lit le contrat sur la classe qu'il décore : chaque nom doit être
141
160
  * une méthode ou un canal de `C`, sinon la classe ne compile pas, et l'erreur
@@ -153,16 +172,18 @@ export declare function RealtimePolicy<P extends {
153
172
  readonly [name: string]: IChannelPolicy | undefined;
154
173
  }>(policies: P): <T extends abstract new (...args: never[]) => object>(target: T & PolicyNamesCheck<P, T>) => void;
155
174
  /**
156
- * Décorateur **méthode** — expose un **canal pub/sub** sous le nom `channel`
157
- * (match EXACT, sans préfixe `:<ms>` ni regex). La méthode décorée DOIT renvoyer
158
- * un `dispose: () => void` (la base le mémorise et l'appelle au dernier
175
+ * Décorateur **méthode** — expose un **canal pub/sub** sous le nom `channel`, ou
176
+ * une FAMILLE de canaux sous un motif (`chat:room:*`). La méthode décorée DOIT
177
+ * renvoyer un `dispose: () => void` (la base le mémorise et l'appelle au dernier
159
178
  * désabonné, comme avec l'override classique).
160
179
  *
161
- * v1 — match EXACT uniquement. Les canaux à pattern (`/^nodefony:orm:rich@(\d+)/`) ou à
162
- * suffixe de granularité (`nodefony:dashboard:1000`) restent dans l'override
163
- * `createRealtimeChannel()` (la base donne PRIORITÉ aux décorateurs, puis tombe
164
- * sur l'override en fallback — coexistence sans casse). Un mode `pattern: RegExp`
165
- * arrivera plus tard sans rompre cette API.
180
+ * Un motif (`*` = toute suite de caractères, la grammaire des politiques) sert
181
+ * chaque nom qu'il couvre : `@RealtimeChannel("chat:room:*", { allow })` déclare
182
+ * la règle ET la fabrique de tous les salons, et la méthode reçoit le nom réel
183
+ * (`chat:room:42`) en premier argument. Ordre de résolution au `subscribe` : le
184
+ * nom exact décoré, puis l'override `createRealtimeChannel()` (canaux à suffixe
185
+ * de granularité, regex — un contrôleur qui en a un garde son comportement),
186
+ * puis les motifs décorés, du plus spécifique au plus général.
166
187
  *
167
188
  * Le `this` est lié à l'instance ; le factory peut donc lire `this.kernel`, etc.
168
189
  * Mais ATTENTION : un provider est **partagé** entre toutes les connexions au
@@ -170,8 +191,20 @@ export declare function RealtimePolicy<P extends {
170
191
  * créé. Capturer des **valeurs long-lived** (broker, syslog) dans la closure,
171
192
  * jamais `this.context` (lié à la connexion créatrice qui peut fermer).
172
193
  *
194
+ * La `policy` est celle de **LECTURE** : exigée au `subscribe`, et en plus de
195
+ * l'écriture sur toute trame entrante du même nom. Absente, la lecture hérite de
196
+ * l'écriture (`@RealtimeInbound`) si elle est déclarée — une application qui ne
197
+ * garde que l'écriture reste protégée en lecture. Pour un canal que tout le
198
+ * monde lit et que seuls certains écrivent, l'écrire : `{ authenticated: false }`.
199
+ *
173
200
  * @example
174
201
  * class ChatController extends RealtimeController {
202
+ * // Tous les salons : la règle et la fabrique, une seule fois.
203
+ * @RealtimeChannel("chat:room:*", { allow: RoomAdmission })
204
+ * room(_channel: string): () => void {
205
+ * return () => {};
206
+ * }
207
+ *
175
208
  * @RealtimeChannel("chat:room42")
176
209
  * room42(_channel: string, publish: RealtimePublish): () => void {
177
210
  * const timer = setInterval(() => publish("chat:room42", { ts: Date.now() }), 1000);
@@ -192,6 +225,20 @@ export declare function RealtimeChannel(channel: string, policy?: IChannelPolicy
192
225
  * pousser tant qu'aucun handler n'est enregistré ici). Cf seam P13.8a
193
226
  * (`beforeDispatch`) pour la gate sécurité par voter P6.
194
227
  *
228
+ * La `policy` est celle d'**ÉCRITURE** : une trame entrante exige la LECTURE du
229
+ * même nom (`@RealtimeChannel`) ET elle — l'écriture n'est jamais moins stricte
230
+ * que la lecture. Sans lecture déclarée, la lecture en hérite.
231
+ *
232
+ * @example
233
+ * class ChatController extends RealtimeController {
234
+ * @RealtimeChannel("chat:typing", { authenticated: false }) // tout le monde lit
235
+ * typing(_channel: string, _publish: RealtimePublish): () => void {
236
+ * return () => {};
237
+ * }
238
+ * @RealtimeInbound("chat:typing", { authenticated: true }) // seuls les connectés écrivent
239
+ * onTyping(): void {}
240
+ * }
241
+ *
195
242
  * @example
196
243
  * class ChatController extends RealtimeController {
197
244
  * @RealtimeInbound("chat:send")
@@ -224,6 +271,21 @@ export type RealtimeActionWrapper = (method: RealtimeActionMethod, propertyKey:
224
271
  export declare function getRealtimeActions(instance: object, wrap?: RealtimeActionWrapper): Record<string, RpcActionHandler> | null;
225
272
  /** Lecture (lazy, au handshake) du registre des canaux décorés. */
226
273
  export declare function getRealtimeChannels(instance: object): Record<string, RealtimeChannelFactory> | null;
274
+ /**
275
+ * 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.
276
+ *
277
+ * Même grammaire que les politiques (`compileChannelPattern`) et même
278
+ * départage (`channelPatternWeight`, le plus spécifique d'abord) : un nom
279
+ * reçoit la fabrique du motif dont il reçoit la règle. Un motif refusé par la
280
+ * grammaire (trop d'étoiles) ne sert rien — le hub le dit déjà au démarrage.
281
+ * Cold path : appelé au premier abonné d'un canal qu'aucun nom exact ni
282
+ * `createRealtimeChannel` ne produit.
283
+ *
284
+ * @param instance - le contrôleur ; ses motifs se lisent sur sa classe.
285
+ * @param channels - le registre rendu par {@link getRealtimeChannels}.
286
+ * @param channel - le nom demandé.
287
+ */
288
+ export declare function patternChannelFactory(instance: object, channels: Readonly<Record<string, RealtimeChannelFactory>> | null, channel: string): RealtimeChannelFactory | null;
227
289
  /** Lecture (lazy, au handshake) du registre des canaux inbound décorés. */
228
290
  export declare function getRealtimeInbound(instance: object): Record<string, RealtimeInboundHandler> | null;
229
291
  /**
@@ -233,14 +295,53 @@ export declare function getRealtimeInbound(instance: object): Record<string, Rea
233
295
  * @param name - nom d'action ou de canal.
234
296
  */
235
297
  export declare function declaresAction(ctor: object, name: string): boolean;
298
+ /** Ce qu'une classe DÉCLARE sous un nom, par sorte — `null` : elle ne pose pas cette sorte. */
299
+ export interface IDeclaredPolicyKinds {
300
+ readonly read: IChannelPolicy | null;
301
+ readonly write: IChannelPolicy | null;
302
+ }
303
+ /**
304
+ * Politiques déclarées par une classe de contrôleur (ADR-0022 §6, décision 1 quater) : ce qu'elle pose par sorte — que le hub résout ensuite avec les motifs des autres classes — et sa vue seule.
305
+ */
306
+ export interface IDeclaredChannelPolicies {
307
+ /** Vue de la classe SEULE, nom → politique de LECTURE (déclarée, sinon son écriture, sinon le défaut fermé d'une action). */
308
+ readonly read: Record<string, IChannelPolicy>;
309
+ /** Nom → ce que la classe déclare, sorte par sorte (sans héritage : le hub le calcule, motifs compris). */
310
+ readonly kinds: Record<string, IDeclaredPolicyKinds>;
311
+ /** Noms des actions décorées : chacune reçoit le défaut fermé comme REPLI au hub. */
312
+ readonly actions: readonly string[];
313
+ }
236
314
  /**
237
- * 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.
315
+ * 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.
316
+ *
317
+ * Résolution indépendante de l'ordre des méthodes :
318
+ * - l'écriture d'un nom est celle déclarée (`@RealtimeInbound`, `@RealtimeAction`
319
+ * avec politique, `@RealtimePolicy`) ; pour une action SANS politique, le
320
+ * défaut fermé, sauf si une lecture est déclarée — l'action en hérite alors ;
321
+ * - la lecture est celle déclarée (`@RealtimeChannel`, `@RealtimePolicy`),
322
+ * sinon l'écriture : une application qui ne garde que l'écriture reste
323
+ * protégée en lecture ;
324
+ * - `kinds` garde ce que la classe déclare, SANS héritage : le hub le fusionne
325
+ * avec les motifs de toutes les classes, sorte par sorte.
326
+ *
327
+ * @param ctor - constructeur du contrôleur.
328
+ * @returns les deux tables, ou `null` si la classe ne déclare aucune politique.
329
+ * @throws BootConfigurationError Une politique est illisible, ou deux
330
+ * déclarations d'une même sorte se contredisent : la classe, le canal et le
331
+ * défaut sont nommés.
332
+ */
333
+ export declare function getDeclaredChannelPolicies(ctor: object): IDeclaredChannelPolicies | null;
334
+ /**
335
+ * 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.
336
+ *
337
+ * La lecture d'un nom est celle exigée au `subscribe` ; une écriture distincte
338
+ * se lit par {@link getDeclaredChannelPolicies}.
238
339
  *
239
340
  * @param ctor - constructeur du contrôleur.
240
- * @returns nom de canal ou de méthode → politique, ou `null` si la classe n'en
241
- * déclare aucune (0 allocation).
242
- * @throws BootConfigurationError Une politique est illisible : la classe, le
243
- * canal et le défaut sont nommés.
341
+ * @returns nom de canal ou de méthode → politique de lecture, ou `null` si la
342
+ * classe n'en déclare aucune.
343
+ * @throws BootConfigurationError Une politique est illisible ou contredite :
344
+ * la classe, le canal et le défaut sont nommés.
244
345
  */
245
346
  export declare function getChannelPoliciesOf(ctor: object): Record<string, IChannelPolicy> | null;
246
347
  /**
@@ -62,6 +62,10 @@ export type BackplaneHandler = (msg: IBackplaneMessage) => void;
62
62
  * - **aucun rejeu au démarrage** : lire à partir du dernier message, jamais depuis le
63
63
  * début — un ordre de fin rejoué couperait une partie rouverte ;
64
64
  * - **aucune attente de regroupement** à l'émission : `publish` part tout de suite ;
65
+ * - **un ordre de contrôle n'est jamais jeté pour faire place aux publications**
66
+ * (`PLATFORM_BUS_CHANNELS.control` : terminer un canal, éjecter un compte) —
67
+ * une file d'envoi bornée lui laisse une voie hors quota ; un ordre perdu malgré
68
+ * tout (bus injoignable) se compte et s'annonce, jamais en silence ;
65
69
  * - **le sceau appliqué** dès que le bus quitte la machine (`crossPod`) :
66
70
  * `sealBackplaneEnvelope` à l'émission, `openBackplaneEnvelope` à la réception ;
67
71
  * - `start` après `stop` remet la réception en route sans doubler les messages ;
@@ -101,7 +105,14 @@ export interface IBackplane {
101
105
  * @returns `true` si les octets traverseront le bus intacts.
102
106
  */
103
107
  enableBytes?(codec: IFrameCodec): boolean;
104
- /** Arrête le transport et libère les ressources (connexions, listeners). Idempotent. */
108
+ /**
109
+ * Arrête le transport et libère les ressources (connexions, listeners). Idempotent.
110
+ *
111
+ * Un transport asynchrone laisse d'abord partir ce qui est déjà émis (la promesse
112
+ * se résout une fois ces envois acquittés ou refusés) : à l'arrêt du processus,
113
+ * la preuve de vie finale de la présence part juste avant, et la sortie suit au
114
+ * tick suivant. L'appelant borne l'attente (`shutdownRealtime`).
115
+ */
105
116
  stop(): void | Promise<void>;
106
117
  /**
107
118
  * Carte d'identité du backplane (driver, transport, origine, cross-pod, canal).
@@ -222,12 +222,20 @@ export declare class RedisBackplane implements IBackplane {
222
222
  * L'envoi passe par la **file bornée** : si le bus n'acquitte plus, les
223
223
  * publications suivantes sont jetées et comptées plutôt que d'empiler des
224
224
  * mégaoctets dans le client Redis (sémantique at-most-once déjà assumée par le
225
- * port ; le client realtime re-synchronise).
225
+ * port ; le client realtime re-synchronise). Un ordre de contrôle, lui, part
226
+ * toujours (cf {@link BackplanePublishQueue.send}).
226
227
  */
227
228
  publish(channel: string, payload: unknown): void;
228
229
  /** Un seul handler d'ingress ; un appel ultérieur remplace le précédent. */
229
230
  onMessage(handler: BackplaneHandler): void;
230
- /** Désabonne du canal Redis et détache le handler. Idempotent. */
231
+ /**
232
+ * 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.
233
+ *
234
+ * Sans l'attente, un arrêt suivi de la sortie du processus perdait ce qui
235
+ * était encore dans la file — la preuve de vie FINALE de la présence comprise,
236
+ * et les pairs gardaient alors ce pod jusqu'au délai d'oubli. Un Redis qui ne
237
+ * répond plus fait attendre : l'appelant borne (l'arrêt du module le fait).
238
+ */
231
239
  stop(): Promise<void>;
232
240
  describe(): IBackplaneInfo;
233
241
  }
@@ -35,6 +35,29 @@ export interface IEvictUserOrder {
35
35
  }
36
36
  /** Un ordre de contrôle reconnu. */
37
37
  export type ControlOrder = ICloseChannelOrder | IEvictUserOrder;
38
+ /**
39
+ * Un texte venu du bus est-il recevable — chaîne non vide d'au plus {@link MAX_CONTROL_FIELD_LENGTH} caractères ?
40
+ *
41
+ * Seule borne des textes du bus (nom de canal, identifiant de compte), lue par
42
+ * les ordres de contrôle ET par les messages de présence (ADR-0025 P2).
43
+ *
44
+ * @param value - champ reçu (forme inconnue).
45
+ */
46
+ export declare function isBoundedBusText(value: unknown): value is string;
47
+ /**
48
+ * La charge est-elle un ordre d'un TYPE que cette version ne connaît pas — `op` lisible, mais hors de la liste fermée ?
49
+ *
50
+ * C'est ce qu'émet un serveur d'une version plus récente pendant un
51
+ * déploiement progressif (ADR-0025, « Règle de lecture du bus ») : l'ordre
52
+ * n'est pas appliqué, mais il ne se compte pas comme une injection. Rien
53
+ * d'autre que `op` n'est lu — ses autres champs appartiennent à une version
54
+ * qui n'est pas celle-ci. Une charge sans `op` textuel, ou d'un type CONNU de
55
+ * forme fausse, n'est pas « inconnue » : elle est invalide.
56
+ *
57
+ * @param payload - la charge du message reçu (forme inconnue).
58
+ * @returns `true` si le type d'ordre est inconnu de cette version.
59
+ */
60
+ export declare function isUnknownControlOrder(payload: unknown): boolean;
38
61
  /**
39
62
  * Lit un ordre de contrôle dans une charge venue du bus — `null` si la forme n'est pas celle d'un ordre connu.
40
63
  *
@@ -20,6 +20,10 @@ export declare function sealBackplaneEnvelope(msg: IBackplaneMessage, secret?: s
20
20
  * `sig`. Sans secret, l'enveloppe nue est acceptée (compat ; l'alerte de boot qui
21
21
  * signale ce mode non authentifié est portée par le wiring du driver).
22
22
  *
23
+ * Le sceau se vérifie sur la tranche REÇUE de la charge quand l'enveloppe a la
24
+ * forme qu'écrit {@link sealBackplaneEnvelope} — aucune re-sérialisation ; une
25
+ * autre forme retombe sur la re-sérialisation de la charge lue.
26
+ *
23
27
  * @param raw - message brut lu sur le bus.
24
28
  * @param secret - secret partagé attendu, ou `null`/`undefined` si aucun.
25
29
  * @returns le message vérifié, ou `null` s'il doit être ignoré (malformé, non
@@ -32,9 +36,10 @@ export declare function openBackplaneEnvelope(raw: string, secret?: string | nul
32
36
  * binaire du serveur, octets natifs dans `payload`, scellée si un secret est
33
37
  * fourni.
34
38
  *
35
- * Scellée, la charge est encodée deux fois — une pour le sceau, une dans
36
- * l'enveloppe : le codec générique ne permet pas d'assembler l'enveloppe à
37
- * partir de fragments déjà encodés, comme le fait la voie texte.
39
+ * Scellée, la charge est encodée UNE fois : le sceau se calcule sur ses octets,
40
+ * et l'enveloppe s'assemble autour d'eux. Un codec qui n'écrit pas le CBOR le plus
41
+ * court (constaté, cf {@link framesAsCbor}) encode l'enveloppe entière une seconde
42
+ * fois — correct pour tout format, plus coûteux.
38
43
  *
39
44
  * @param msg - message à transporter (canal, charge, origine).
40
45
  * @param codec - le codec binaire du serveur (`CBOR_FRAME_CODEC` de `nodefony/cbor`).
@@ -48,10 +53,10 @@ export declare function sealBackplaneEnvelopeBytes(msg: IBackplaneMessage, codec
48
53
  * règle que {@link openBackplaneEnvelope} : fail-closed strict sous un secret,
49
54
  * aucun downgrade en retirant `sig`.
50
55
  *
51
- * Le sceau se recalcule sur le RÉ-ENCODAGE de la charge décodée, comme la voie
52
- * texte le fait sur sa re-sérialisation : le codec CBOR du cœur rend la forme la
53
- * plus courte de chaque valeur, donc émetteur et récepteur signent les mêmes
54
- * octets.
56
+ * Le sceau se vérifie sur la tranche REÇUE de la charge quand l'enveloppe a la
57
+ * forme qu'assemble l'émetteur — aucun ré-encodage ; une autre forme retombe sur
58
+ * le ré-encodage de la charge décodée (le codec CBOR du cœur rend la forme la plus
59
+ * courte de chaque valeur, donc émetteur et récepteur signent les mêmes octets).
55
60
  *
56
61
  * @param raw - octets lus sur le sujet binaire.
57
62
  * @param codec - le codec binaire du serveur.
@@ -59,3 +64,41 @@ export declare function sealBackplaneEnvelopeBytes(msg: IBackplaneMessage, codec
59
64
  * @returns le message vérifié, ou `null` s'il doit être ignoré.
60
65
  */
61
66
  export declare function openBackplaneEnvelopeBytes(raw: Uint8Array, codec: IFrameCodec, secret?: string | null): IBackplaneMessage | null;
67
+ /**
68
+ * Reconnaît l'**écho** d'un pod — ce que le bus lui renvoie de ses propres
69
+ * publications — à la FIN du message, sans le décoder (ADR-0021 S6, ADR-0025).
70
+ *
71
+ * Un bus pub/sub comme Redis rend à l'émetteur chacune de ses publications : sur un
72
+ * pod chargé, c'est un message reçu sur deux, et la présence répartie en fait un
73
+ * signal (le bus est vivant tant que l'écho revient). Le reconnaître coûte, sous un
74
+ * secret, UN HMAC sur la tranche de la charge : ni `JSON.parse`, ni décodage CBOR.
75
+ *
76
+ * Sous un secret, l'écho est VÉRIFIÉ avant d'être compté : un tiers qui forgerait
77
+ * l'origine de ce pod pour simuler un bus vivant échoue au sceau. Sans secret, rien
78
+ * ne peut l'être — le bus est ouvert, et le wiring l'annonce au démarrage.
79
+ *
80
+ * Un message que ce lecteur ne reconnaît pas (forme tierce, sceau faux) n'est PAS
81
+ * jeté : il suit la voie ordinaire, qui le vérifie en entier.
82
+ */
83
+ export declare class BackplaneEcho {
84
+ #private;
85
+ /**
86
+ * @param originId - identité de CE pod sur le bus.
87
+ * @param secret - secret partagé, ou `null` sur un bus non scellé.
88
+ */
89
+ constructor(originId: string, secret: string | null);
90
+ /**
91
+ * Ce message texte est-il l'écho de ce pod (sceau vérifié sous un secret) ?
92
+ *
93
+ * @param raw - message brut lu sur le sujet texte.
94
+ */
95
+ text(raw: string): boolean;
96
+ /**
97
+ * Ce message binaire est-il l'écho de ce pod (sceau vérifié sous un secret) ?
98
+ *
99
+ * @param raw - octets lus sur le sujet binaire.
100
+ * @param codec - le codec binaire du serveur ; un codec qui n'écrit pas le CBOR
101
+ * attendu (cf {@link framesAsCbor}) laisse tout message à la voie ordinaire.
102
+ */
103
+ bytes(raw: Uint8Array, codec: IFrameCodec): boolean;
104
+ }
@@ -0,0 +1,48 @@
1
+ import type { IFrameCodec } from "nodefony";
2
+ import type { IBackplaneMessage } from "../../interfaces/IBackplane.js";
3
+ import type { BackplaneNotice } from "./publishQueue.js";
4
+ /**
5
+ * **Entrée d'un bus partagé** — le tri de ce qu'un pilote reçoit, mutualisé pour
6
+ * que tout pilote à transport réseau (Redis aujourd'hui ; Kafka, NATS, RabbitMQ…
7
+ * demain) l'hérite sans le réécrire : écho de ce pod reconnu et compté, sceau
8
+ * vérifié, refus comptés et annoncés.
9
+ *
10
+ * Le pilote ne garde que son transport : il passe chaque message reçu à
11
+ * {@link text} ou {@link bytes} et remet au hub ce qui en sort.
12
+ *
13
+ * Ordre du tri, du moins cher au plus cher :
14
+ * 1. l'ÉCHO de ce pod se reconnaît à la fin du message, sans le décoder — un HMAC
15
+ * sous un secret (cf {@link BackplaneEcho}) ;
16
+ * 2. sinon, l'enveloppe est ouverte et son sceau vérifié sur la tranche reçue ;
17
+ * 3. un message ouvert qui porte l'origine de ce pod sous une autre forme est
18
+ * encore un écho — compté, jamais réinjecté (anti-boucle).
19
+ */
20
+ export declare class BackplaneIngress {
21
+ #private;
22
+ /**
23
+ * @param originId - identité de CE pod sur le bus.
24
+ * @param secret - secret partagé, ou `null` sur un bus non scellé.
25
+ * @param notice - annonce des sceaux refusés ; omis = silencieux (tests).
26
+ */
27
+ constructor(originId: string, secret: string | null, notice?: BackplaneNotice | null);
28
+ /**
29
+ * Trie un message reçu sur le sujet TEXTE du bus.
30
+ *
31
+ * @param raw - message brut tel que lu sur le transport.
32
+ * @returns le message à remettre au hub, ou `null` s'il est jeté (écho de ce
33
+ * pod, malformé, sceau refusé — chacun compté).
34
+ */
35
+ text(raw: string): IBackplaneMessage | null;
36
+ /**
37
+ * Trie un message reçu sur le sujet BINAIRE du bus (ADR-0021 S6).
38
+ *
39
+ * @param raw - octets tels que lus sur le transport.
40
+ * @param codec - le codec binaire du serveur.
41
+ * @returns le message à remettre au hub, ou `null` s'il est jeté.
42
+ */
43
+ bytes(raw: Uint8Array, codec: IFrameCodec): IBackplaneMessage | null;
44
+ /** Échos de ce pod reçus du bus (monotone) — le bus rend ce qu'on y publie. */
45
+ get echoTotal(): number;
46
+ /** Messages jetés au sceau refusé, sous un secret (monotone). */
47
+ get sealRejectedTotal(): number;
48
+ }