@nodefony/realtime 10.0.0-alpha.1

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 (81) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +207 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/index.js +218 -0
  6. package/dist/nodefony/config/config.js +63 -0
  7. package/dist/nodefony/config/defineModuleConfig.js +60 -0
  8. package/dist/nodefony/decorators/realtimeDecorators.js +240 -0
  9. package/dist/nodefony/interfaces/IBackplane.js +1 -0
  10. package/dist/nodefony/interfaces/IChannelPolicy.js +1 -0
  11. package/dist/nodefony/interfaces/IClientLogsLimits.js +1 -0
  12. package/dist/nodefony/interfaces/IRealtimeAuthenticator.js +1 -0
  13. package/dist/nodefony/interfaces/IRealtimeAuthenticatorMatcher.js +1 -0
  14. package/dist/nodefony/interfaces/IRealtimeController.js +1 -0
  15. package/dist/nodefony/interfaces/IRealtimeHandshake.js +1 -0
  16. package/dist/nodefony/interfaces/IRealtimeProbe.js +1 -0
  17. package/dist/nodefony/interfaces/IRealtimeToken.js +1 -0
  18. package/dist/nodefony/interfaces/index.js +1 -0
  19. package/dist/nodefony/src/backplane/ClusterBackplane.js +105 -0
  20. package/dist/nodefony/src/backplane/LoopbackBackplane.js +40 -0
  21. package/dist/nodefony/src/backplane/RedisBackplane.js +176 -0
  22. package/dist/nodefony/src/backplane/backplaneRegistry.js +20 -0
  23. package/dist/nodefony/src/backplane/envelope.js +109 -0
  24. package/dist/nodefony/src/backplane/originId.js +35 -0
  25. package/dist/nodefony/src/backplane/publishQueue.js +96 -0
  26. package/dist/nodefony/src/cluster/ClusterProbeClient.js +284 -0
  27. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +35 -0
  28. package/dist/nodefony/src/server/RealtimeAdminApi.js +87 -0
  29. package/dist/nodefony/src/server/RealtimeController.js +601 -0
  30. package/dist/nodefony/src/server/RealtimeHub.js +926 -0
  31. package/dist/nodefony/src/server/ServerRealtimeSocket.js +168 -0
  32. package/dist/nodefony/src/server/deniedDetail.js +29 -0
  33. package/dist/nodefony/src/server/syslogUplink.js +105 -0
  34. package/dist/nodefony/src/server/welcomeEnv.js +25 -0
  35. package/dist/nodefony/src/service/RealtimeService.js +240 -0
  36. package/dist/nodefony/src/transport/WsConnectionTransport.js +94 -0
  37. package/dist/nodefony/testing/index.js +163 -0
  38. package/dist/types/index.d.ts +99 -0
  39. package/dist/types/nodefony/config/config.d.ts +43 -0
  40. package/dist/types/nodefony/config/defineModuleConfig.d.ts +49 -0
  41. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +190 -0
  42. package/dist/types/nodefony/interfaces/IBackplane.d.ts +128 -0
  43. package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +28 -0
  44. package/dist/types/nodefony/interfaces/IClientLogsLimits.d.ts +22 -0
  45. package/dist/types/nodefony/interfaces/IRealtimeAuthenticator.d.ts +53 -0
  46. package/dist/types/nodefony/interfaces/IRealtimeAuthenticatorMatcher.d.ts +46 -0
  47. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +36 -0
  48. package/dist/types/nodefony/interfaces/IRealtimeHandshake.d.ts +27 -0
  49. package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +206 -0
  50. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +48 -0
  51. package/dist/types/nodefony/interfaces/index.d.ts +18 -0
  52. package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +76 -0
  53. package/dist/types/nodefony/src/backplane/LoopbackBackplane.d.ts +29 -0
  54. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +164 -0
  55. package/dist/types/nodefony/src/backplane/backplaneRegistry.d.ts +51 -0
  56. package/dist/types/nodefony/src/backplane/envelope.d.ts +27 -0
  57. package/dist/types/nodefony/src/backplane/originId.d.ts +22 -0
  58. package/dist/types/nodefony/src/backplane/publishQueue.d.ts +71 -0
  59. package/dist/types/nodefony/src/cluster/ClusterProbeClient.d.ts +90 -0
  60. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +3 -0
  61. package/dist/types/nodefony/src/server/RealtimeAdminApi.d.ts +18 -0
  62. package/dist/types/nodefony/src/server/RealtimeController.d.ts +165 -0
  63. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +516 -0
  64. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +63 -0
  65. package/dist/types/nodefony/src/server/deniedDetail.d.ts +25 -0
  66. package/dist/types/nodefony/src/server/syslogUplink.d.ts +58 -0
  67. package/dist/types/nodefony/src/server/welcomeEnv.d.ts +21 -0
  68. package/dist/types/nodefony/src/service/RealtimeService.d.ts +167 -0
  69. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +78 -0
  70. package/dist/types/nodefony/testing/index.d.ts +169 -0
  71. package/docs/actions.md +573 -0
  72. package/docs/architecture.md +889 -0
  73. package/docs/configuration.md +803 -0
  74. package/docs/cookbook-chat.md +1619 -0
  75. package/docs/couverture-bancs.md +139 -0
  76. package/docs/index.md +372 -0
  77. package/docs/observabilite.md +592 -0
  78. package/docs/protocole.md +518 -0
  79. package/docs/securite.md +845 -0
  80. package/docs/vocabulaire.md +845 -0
  81. package/package.json +83 -0
@@ -0,0 +1,240 @@
1
+ import "reflect-metadata";
2
+ //#region nodefony/decorators/realtimeDecorators.ts
3
+ /** Clés de métadonnées posées sur le constructor de la classe controller. */
4
+ const ACTIONS_KEY = "realtime:actions";
5
+ const CHANNELS_KEY = "realtime:channels";
6
+ const INBOUND_KEY = "realtime:inbound";
7
+ const POLICIES_KEY = "realtime:policies";
8
+ /** Enregistre (idempotent) une politique non vide sous son nom de canal/méthode. */
9
+ function definePolicy(ctor, name, policy) {
10
+ if (!policy || policy.authenticated === void 0 && policy.roles === void 0 && policy.scopes === void 0) return;
11
+ const map = Reflect.getMetadata(POLICIES_KEY, ctor) ?? {};
12
+ map[name] = policy;
13
+ Reflect.defineMetadata(POLICIES_KEY, map, ctor);
14
+ }
15
+ /**
16
+ * Politique appliquée à une action RPC dont l'auteur n'a rien déclaré :
17
+ * **exiger une connexion authentifiée**. Une action est une méthode que le pair
18
+ * APPELLE et qui AGIT — le défaut sûr est de la fermer, pas de l'ouvrir.
19
+ * Pour une action délibérément publique, l'écrire : `{ authenticated: false }`.
20
+ */
21
+ const DEFAULT_ACTION_POLICY = Object.freeze({ authenticated: true });
22
+ /** Une politique est-elle DÉJÀ déclarée sous ce nom ? */
23
+ function hasPolicy(ctor, name) {
24
+ const map = Reflect.getMetadata(POLICIES_KEY, ctor);
25
+ return map ? Object.prototype.hasOwnProperty.call(map, name) : false;
26
+ }
27
+ /**
28
+ * Décorateur **méthode** — expose une **action RPC** request→response du
29
+ * controller realtime sous le nom `method` (ex. `"nodefony:kernel:ping"`, `"chat:send"`).
30
+ *
31
+ * La méthode décorée devient le handler appelé sur une requête entrante AVEC `id` ;
32
+ * sa valeur de retour est sérialisée comme `result`. Sync OU async (Promise) ;
33
+ * un throw est mappé sur `-32603 internal error` (Zero Trust — message générique
34
+ * au pair, détail loggé via `onError`).
35
+ *
36
+ * Le `this` est lié à l'instance de controller (`handler.bind(instance)` au
37
+ * handshake). La méthode peut donc lire `this.context`, `this.kernel`, etc.
38
+ *
39
+ * **Autorisation — FERMÉE PAR DÉFAUT.** Une action sans `policy` exige une
40
+ * connexion **authentifiée**. C'est délibéré : une action est une méthode que le
41
+ * pair appelle et qui AGIT (`nodefony:kernel:gc`, `nodefony:scaffold:run`, `orders:quote`), et le
42
+ * verrou de frame laisse passer tout ce qu'aucune politique ne couvre — sans ce
43
+ * défaut, toute action applicative serait publique sans que rien ne le dise.
44
+ * Pour une action délibérément ouverte, l'écrire : `{ authenticated: false }`.
45
+ *
46
+ * Si une politique est **déjà déclarée sous ce nom** (canal homonyme via
47
+ * `@RealtimeChannel`/`@RealtimeInbound`), elle est conservée : le défaut ne
48
+ * rétrograde jamais une politique existante, souvent plus stricte.
49
+ *
50
+ * ⚠️ Le NOM peut primer sur ta politique : le plancher système de
51
+ * `@nodefony/security` réserve les préfixes plateforme (`kernel:`, `syslog:`…)
52
+ * **et** tout nom contenant `:health` ou `:stats`. Une action ainsi nommée reste
53
+ * réservée même déclarée `{ authenticated: false }`.
54
+ *
55
+ * @param method - nom RPC exposé au pair.
56
+ * @param policy - politique d'autorisation ; par défaut `{ authenticated: true }`.
57
+ *
58
+ * @example
59
+ * class ChatController extends RealtimeController {
60
+ * @RealtimeAction("chat:ping") // authentifié requis
61
+ * ping(): { pong: true; ts: number } {
62
+ * return { pong: true, ts: Date.now() };
63
+ * }
64
+ *
65
+ * @RealtimeAction("admin:purge", { roles: ["ROLE_ADMIN"] })
66
+ * purge(): void {}
67
+ *
68
+ * @RealtimeAction("catalog:browse", { authenticated: false }) // ouvert, assumé
69
+ * browse(): string { return "ok"; }
70
+ * }
71
+ */
72
+ function RealtimeAction(method, policy) {
73
+ return function(target, propertyKey) {
74
+ const ctor = target.constructor;
75
+ const map = Reflect.getMetadata(ACTIONS_KEY, ctor) ?? {};
76
+ map[method] = propertyKey;
77
+ Reflect.defineMetadata(ACTIONS_KEY, map, ctor);
78
+ if (policy) definePolicy(ctor, method, policy);
79
+ else if (!hasPolicy(ctor, method)) definePolicy(ctor, method, DEFAULT_ACTION_POLICY);
80
+ };
81
+ }
82
+ /**
83
+ * Décorateur **méthode** — expose un **canal pub/sub** sous le nom `channel`
84
+ * (match EXACT, sans préfixe `:<ms>` ni regex). La méthode décorée DOIT renvoyer
85
+ * un `dispose: () => void` (la base le mémorise et l'appelle au dernier
86
+ * désabonné, comme avec l'override classique).
87
+ *
88
+ * v1 — match EXACT uniquement. Les canaux à pattern (`/^nodefony:orm:rich@(\d+)/`) ou à
89
+ * suffixe de granularité (`nodefony:dashboard:1000`) restent dans l'override
90
+ * `createRealtimeChannel()` (la base donne PRIORITÉ aux décorateurs, puis tombe
91
+ * sur l'override en fallback — coexistence sans casse). Un mode `pattern: RegExp`
92
+ * arrivera plus tard sans rompre cette API.
93
+ *
94
+ * Le `this` est lié à l'instance ; le factory peut donc lire `this.kernel`, etc.
95
+ * Mais ATTENTION : un provider est **partagé** entre toutes les connexions au
96
+ * même canal (cf `RealtimeHub.subscribe`) — il survit à la connexion qui l'a
97
+ * créé. Capturer des **valeurs long-lived** (broker, syslog) dans la closure,
98
+ * jamais `this.context` (lié à la connexion créatrice qui peut fermer).
99
+ *
100
+ * @example
101
+ * class ChatController extends RealtimeController {
102
+ * @RealtimeChannel("chat:room42")
103
+ * room42(_channel: string, publish: RealtimePublish): () => void {
104
+ * const timer = setInterval(() => publish("chat:room42", { ts: Date.now() }), 1000);
105
+ * return () => clearInterval(timer);
106
+ * }
107
+ * }
108
+ */
109
+ function RealtimeChannel(channel, policy) {
110
+ return function(target, propertyKey) {
111
+ const ctor = target.constructor;
112
+ const map = Reflect.getMetadata(CHANNELS_KEY, ctor) ?? {};
113
+ map[channel] = propertyKey;
114
+ Reflect.defineMetadata(CHANNELS_KEY, map, ctor);
115
+ definePolicy(ctor, channel, policy);
116
+ };
117
+ }
118
+ /**
119
+ * Décorateur **méthode** — déclare un **canal FULL-DUPLEX entrant** (méthode WS
120
+ * inverse : le client pousse via une notification `method: <name>`, params libres,
121
+ * sans `id`). La méthode décorée reçoit `(params, reply)` :
122
+ * - `params` : NON FIABLE (entrée client) — valider AVANT d'agir.
123
+ * - `reply(payload)` : push serveur→client sur le MÊME canal, vers CETTE connexion.
124
+ *
125
+ * Équivalent décoratif de `realtimeInbound()` override. Sécurité : un canal n'est
126
+ * inbound QUE s'il est explicitement déclaré (défaut sûr — un client ne peut rien
127
+ * pousser tant qu'aucun handler n'est enregistré ici). Cf seam P13.8a
128
+ * (`beforeDispatch`) pour la gate sécurité par voter P6.
129
+ *
130
+ * @example
131
+ * class ChatController extends RealtimeController {
132
+ * @RealtimeInbound("chat:send")
133
+ * onChatSend(params: unknown, reply: (payload: unknown) => void): void {
134
+ * const text = (params as { text?: unknown })?.text;
135
+ * if (typeof text !== "string") return;
136
+ * reply({ ok: true, echoed: text });
137
+ * }
138
+ * }
139
+ */
140
+ function RealtimeInbound(method, policy) {
141
+ return function(target, propertyKey) {
142
+ const ctor = target.constructor;
143
+ const map = Reflect.getMetadata(INBOUND_KEY, ctor) ?? {};
144
+ map[method] = propertyKey;
145
+ Reflect.defineMetadata(INBOUND_KEY, map, ctor);
146
+ definePolicy(ctor, method, policy);
147
+ };
148
+ }
149
+ /** Lecture (lazy, au handshake) du registre des actions décorées. */
150
+ function getRealtimeActions(instance) {
151
+ const map = Reflect.getMetadata(ACTIONS_KEY, instance.constructor);
152
+ if (!map) return null;
153
+ const out = {};
154
+ for (const [name, prop] of Object.entries(map)) {
155
+ const fn = instance[prop];
156
+ if (typeof fn === "function") out[name] = fn.bind(instance);
157
+ }
158
+ return out;
159
+ }
160
+ /** Lecture (lazy, au handshake) du registre des canaux décorés. */
161
+ function getRealtimeChannels(instance) {
162
+ const map = Reflect.getMetadata(CHANNELS_KEY, instance.constructor);
163
+ if (!map) return null;
164
+ const out = {};
165
+ for (const [name, prop] of Object.entries(map)) {
166
+ const fn = instance[prop];
167
+ if (typeof fn === "function") out[name] = fn.bind(instance);
168
+ }
169
+ return out;
170
+ }
171
+ /** Lecture (lazy, au handshake) du registre des canaux inbound décorés. */
172
+ function getRealtimeInbound(instance) {
173
+ const map = Reflect.getMetadata(INBOUND_KEY, instance.constructor);
174
+ if (!map) return null;
175
+ const out = {};
176
+ for (const [name, prop] of Object.entries(map)) {
177
+ const fn = instance[prop];
178
+ if (typeof fn === "function") out[name] = fn.bind(instance);
179
+ }
180
+ return out;
181
+ }
182
+ /**
183
+ * Lecture (lazy, au handshake) des politiques d'autorisation déclarées
184
+ * (`@RealtimeChannel`/`@RealtimeInbound` avec opts). `null` si aucune → le
185
+ * controller n'enregistre rien au hub (0 alloc). Donnée pure (pas de `bind` :
186
+ * une policy n'est pas une méthode), renvoyée telle quelle pour publication au
187
+ * hub realtime, qui l'expose à `@nodefony/security`.
188
+ */
189
+ function getRealtimeChannelPolicies(instance) {
190
+ return Reflect.getMetadata(POLICIES_KEY, instance.constructor) ?? null;
191
+ }
192
+ /**
193
+ * Préfixes de canaux **broadcast** déclarés statiquement, tous controllers
194
+ * confondus. Rempli à l'IMPORT des classes (le décorateur s'exécute au
195
+ * chargement du module), donc connu **avant** tout trafic. `Set` : la
196
+ * déclaration est idempotente et deux endpoints peuvent partager un préfixe.
197
+ */
198
+ const declaredBroadcastPrefixes = /* @__PURE__ */ new Set();
199
+ /**
200
+ * Décorateur **classe** — déclare les préfixes de canaux **broadcast**
201
+ * (cross-process) servis par cet endpoint : leurs publications traversent le
202
+ * {@link IBackplane} au lieu de rester dans le processus.
203
+ *
204
+ * POURQUOI STATIQUE (trouvé sur un banc multi-pods) : la politique de forward
205
+ * est une propriété du code, pas de l'instant. Déclarée seulement au handshake
206
+ * — via l'override `realtimeBroadcastChannels()` — elle laissait un pod qui
207
+ * publie **sans abonné local** (job planifié, webhook, worker) ne rien
208
+ * propager : `publish` évaluait « pas broadcast », en silence. Ici la
209
+ * déclaration existe dès l'import de la classe ; le service l'applique au hub
210
+ * au boot, identiquement sur tous les pods.
211
+ *
212
+ * @example
213
+ * \@RealtimeBroadcast("chat:", "presence:")
214
+ * \@controller("/api/chat")
215
+ * class ChatController extends RealtimeController { … }
216
+ *
217
+ * @param prefixes - préfixes de canal (`"chat:"` couvre `chat:room1:1000`).
218
+ */
219
+ function RealtimeBroadcast(...prefixes) {
220
+ return function() {
221
+ for (const prefix of prefixes) declaredBroadcastPrefixes.add(prefix);
222
+ };
223
+ }
224
+ /** Préfixes broadcast déclarés par décorateur (introspection / tests). */
225
+ function getDeclaredBroadcastPrefixes() {
226
+ return [...declaredBroadcastPrefixes];
227
+ }
228
+ /**
229
+ * Applique au hub les préfixes broadcast déclarés statiquement. Appelé au boot
230
+ * par `RealtimeService` — avant tout trafic, donc la politique est en place que
231
+ * le pod ait ou non reçu une connexion. Idempotent
232
+ * ({@link RealtimeHub.markBroadcastChannel} l'est déjà).
233
+ *
234
+ * @param hub - hub à configurer (celui du processus).
235
+ */
236
+ function applyDeclaredBroadcastPrefixes(hub) {
237
+ for (const prefix of declaredBroadcastPrefixes) hub.markBroadcastChannel(prefix);
238
+ }
239
+ //#endregion
240
+ export { DEFAULT_ACTION_POLICY, RealtimeAction, RealtimeBroadcast, RealtimeChannel, RealtimeInbound, applyDeclaredBroadcastPrefixes, getDeclaredBroadcastPrefixes, getRealtimeActions, getRealtimeChannelPolicies, getRealtimeChannels, getRealtimeInbound };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,105 @@
1
+ import resolveBackplaneOriginId from "./originId.js";
2
+ import { CLUSTER_RT_KIND } from "nodefony";
3
+ //#region nodefony/src/backplane/ClusterBackplane.ts
4
+ /**
5
+ * Transport de production : `process.send` (émission) + `process.on("message")`
6
+ * (réception). Standalone — utilisable tel quel. En vrai câblage cluster (Phase 3c),
7
+ * un transport branché sur l'event kernel `onMessage` (déjà alimenté par
8
+ * `Kernel.initCluster`) est préféré pour éviter un 2ᵉ listener + le log par message.
9
+ */
10
+ const processIpcTransport = {
11
+ send(env) {
12
+ process.send?.(env);
13
+ },
14
+ onReceive(cb) {
15
+ process.on("message", (msg) => cb(msg));
16
+ }
17
+ };
18
+ /**
19
+ * Type-guard d'enveloppe realtime — narrowing sûr d'un message IPC `unknown`.
20
+ * Filtre tout ce qui n'est pas une publication realtime (autres messages de contrôle).
21
+ */
22
+ function isEnvelope(m) {
23
+ if (typeof m !== "object" || m === null) return false;
24
+ const e = m;
25
+ return e.kind === CLUSTER_RT_KIND && typeof e.channel === "string" && typeof e.originId === "string";
26
+ }
27
+ /**
28
+ * Backplane **IPC cross-process** (Phase 3) — implémentation worker-side du port
29
+ * {@link IBackplane} dans un cluster Node natif, où les workers ne se parlent qu'à
30
+ * travers le **master-gateway** (un worker ne peut envoyer qu'au master via IPC).
31
+ *
32
+ * Flux d'une publication cross-process :
33
+ * - {@link publish} → emballe `{kind,channel,payload,originId}` → `transport.send`
34
+ * (worker → master) ; le master rebroadcast aux **autres** workers (cf `ClusterRelay`,
35
+ * Phase 3b) ; le fan-out **local** a déjà été fait par le hub avant cet appel ;
36
+ * - réception (master → worker) → {@link onMessage} handler → le hub réinjecte en
37
+ * fan-out **local uniquement** (`publishLocal`), jamais re-publié (anti-boucle).
38
+ *
39
+ * Anti-echo (2ᵉ barrière du contrat) : à la réception, on **filtre son propre
40
+ * `originId`** — même si le master renvoyait à la source, le worker l'ignore.
41
+ *
42
+ * Perf : hors mono-process (où le hub garde `#backplane === null` → ce code ne tourne
43
+ * jamais). En cluster, `publish` alloue 1 enveloppe + 1 sérialisation IPC (coût dominé
44
+ * par l'IPC, pas par l'alloc) — sur le chemin realtime broadcast (tickers/fan-out),
45
+ * PAS sur le hot path du request HTTP. SERVEUR uniquement (`process`).
46
+ */
47
+ var ClusterBackplane = class ClusterBackplane {
48
+ /** Nom du driver — source unique du littéral (registre + config). */
49
+ static driver = "cluster";
50
+ originId;
51
+ #transport;
52
+ #handler = null;
53
+ #started = false;
54
+ constructor(transport = processIpcTransport, originId = resolveBackplaneOriginId()) {
55
+ this.#transport = transport;
56
+ this.originId = originId;
57
+ }
58
+ /** Branche la réception IPC. Idempotent. */
59
+ start() {
60
+ if (this.#started) return;
61
+ this.#started = true;
62
+ this.#transport.onReceive((msg) => this.#ingress(msg));
63
+ }
64
+ /**
65
+ * Propage une publication locale aux autres pairs via le master. NE refait PAS le
66
+ * fan-out local (déjà fait par le hub). Sérialisé par le transport IPC.
67
+ */
68
+ publish(channel, payload) {
69
+ this.#transport.send({
70
+ kind: CLUSTER_RT_KIND,
71
+ channel,
72
+ payload,
73
+ originId: this.originId
74
+ });
75
+ }
76
+ /** Un seul handler d'ingress ; un appel ultérieur remplace le précédent. */
77
+ onMessage(handler) {
78
+ this.#handler = handler;
79
+ }
80
+ /** Tri + anti-echo + délégation au handler. Robuste : ignore tout message non-realtime. */
81
+ #ingress(msg) {
82
+ if (!isEnvelope(msg)) return;
83
+ if (msg.originId === this.originId) return;
84
+ this.#handler?.({
85
+ channel: msg.channel,
86
+ payload: msg.payload,
87
+ originId: msg.originId
88
+ });
89
+ }
90
+ /** Détache le handler. Le listener du transport prod (`process`) survit au process. */
91
+ stop() {
92
+ this.#handler = null;
93
+ this.#started = false;
94
+ }
95
+ describe() {
96
+ return {
97
+ driver: ClusterBackplane.driver,
98
+ kind: "ipc",
99
+ originId: this.originId,
100
+ crossPod: false
101
+ };
102
+ }
103
+ };
104
+ //#endregion
105
+ export { ClusterBackplane, ClusterBackplane as default, processIpcTransport };
@@ -0,0 +1,40 @@
1
+ import resolveBackplaneOriginId from "./originId.js";
2
+ //#region nodefony/src/backplane/LoopbackBackplane.ts
3
+ /**
4
+ * Backplane **mono-process no-op** — implémentation de RÉFÉRENCE du port
5
+ * {@link IBackplane} quand il n'existe **aucun pair** (1 process = 1 pod).
6
+ *
7
+ * `publish` ne sort rien (pas de pair à qui propager), `onMessage` ne fire jamais
8
+ * (rien n'arrive de l'extérieur) → 0 fan-out cross-process, le hub se comporte
9
+ * exactement comme en local pur.
10
+ *
11
+ * Rôle : (1) matérialiser le contrat pour les tests (cible de vérification du câblage
12
+ * du hub) ; (2) câblage explicite « backplane présent mais inactif ». NB perf : en
13
+ * mono-process le {@link RealtimeHub} garde `#backplane === null` (0 overhead réel,
14
+ * style lazy du fichier) — ce Loopback n'est donc PAS sur le hot path par défaut, il
15
+ * sert à prouver que brancher un backplane ne change rien tant qu'il n'y a pas de pair.
16
+ *
17
+ * SERVEUR uniquement (`process.pid`) — pas isomorphe.
18
+ */
19
+ var LoopbackBackplane = class LoopbackBackplane {
20
+ /** Nom du driver — source unique du littéral (registre + config). */
21
+ static driver = "loopback";
22
+ originId;
23
+ constructor(originId = resolveBackplaneOriginId()) {
24
+ this.originId = originId;
25
+ }
26
+ start() {}
27
+ publish(_channel, _payload) {}
28
+ onMessage(_handler) {}
29
+ stop() {}
30
+ describe() {
31
+ return {
32
+ driver: LoopbackBackplane.driver,
33
+ kind: "local",
34
+ originId: this.originId,
35
+ crossPod: false
36
+ };
37
+ }
38
+ };
39
+ //#endregion
40
+ export { LoopbackBackplane, LoopbackBackplane as default };
@@ -0,0 +1,176 @@
1
+ import resolveBackplaneOriginId from "./originId.js";
2
+ import { openBackplaneEnvelope, sealBackplaneEnvelope } from "./envelope.js";
3
+ import { BackplanePublishQueue } from "./publishQueue.js";
4
+ //#region nodefony/src/backplane/RedisBackplane.ts
5
+ /**
6
+ * BASE du canal Redis **dédié** transportant les enveloppes realtime entre pods.
7
+ * Un seul canal pub/sub par app (les canaux logiques voyagent DANS l'enveloppe,
8
+ * comme le `channel` IPC du {@link ClusterBackplane}) → 1 `SUBSCRIBE` au boot, pas
9
+ * de (dés)abonnement Redis dynamique par canal applicatif.
10
+ *
11
+ * ⚠️ Le `database` Redis ne cloisonne PAS le pub/sub (global au serveur) → sur un
12
+ * Redis mutualisé, deux apps sur le même canal se parlent (cross-talk). Le canal
13
+ * effectif est donc suffixé par un namespace via {@link resolveRedisChannel}
14
+ * (config `backplane.namespace`, sinon dérivé de `kernel.projectName` au wiring).
15
+ */
16
+ const REDIS_RT_CHANNEL = "nodefony:realtime";
17
+ /**
18
+ * Construit le canal pub/sub effectif : `nodefony:realtime:<namespace>`, ou la
19
+ * base seule si aucun namespace n'est résolu (compat mono-app). Deux déploiements
20
+ * de la MÊME app sur un Redis partagé (staging + prod) ont le même nom dérivé →
21
+ * y poser un `backplane.namespace` EXPLICITE distinct.
22
+ *
23
+ * @param namespace - cloison logique (config `backplane.namespace` ou nom d'app).
24
+ * @returns le nom du canal Redis à `SUBSCRIBE`/`PUBLISH`.
25
+ */
26
+ function resolveRedisChannel(namespace) {
27
+ return namespace ? `${REDIS_RT_CHANNEL}:${namespace}` : REDIS_RT_CHANNEL;
28
+ }
29
+ /**
30
+ * Adapte deux clients Redis (publisher + subscriber dédié) au seam
31
+ * {@link IRedisBackplaneTransport}. SEUL point de contact avec la lib `redis` —
32
+ * et même ici le couplage est **structurel** (aucun import). Le consommateur
33
+ * branche typiquement les connexions du module `@nodefony/redis` :
34
+ *
35
+ * ```ts
36
+ * const redis = kernel.get("redis"); // RedisService
37
+ * const bp = new RedisBackplane(
38
+ * createRedisServiceTransport(redis.getClient("publish")!, redis.getClient("subscribe")!),
39
+ * );
40
+ * defineRealtimeConfig({ backplane: { driver: "redis" } }, { backplane: bp });
41
+ * ```
42
+ *
43
+ * Le listener Redis est conservé pour un `unsubscribe(channel, listener)` ciblé
44
+ * (n'arrache pas d'éventuels autres abonnés du même client).
45
+ */
46
+ function createRedisServiceTransport(publisher, subscriber) {
47
+ let listener = null;
48
+ return {
49
+ publish(channel, message) {
50
+ return publisher.publish(channel, message);
51
+ },
52
+ subscribe(channel, onMessage) {
53
+ listener = (message) => onMessage(message);
54
+ return subscriber.subscribe(channel, listener);
55
+ },
56
+ unsubscribe(channel) {
57
+ const current = listener;
58
+ listener = null;
59
+ return current ? subscriber.unsubscribe(channel, current) : subscriber.unsubscribe(channel);
60
+ }
61
+ };
62
+ }
63
+ /**
64
+ * Backplane **Redis pub/sub** (P13.5) — implémentation du port {@link IBackplane}
65
+ * pour le fan-out realtime **cross-pod** (multi-host), là où le {@link ClusterBackplane}
66
+ * ne couvrait que les workers d'un même pod (IPC). **Drop-in** : le hub ne change pas,
67
+ * seul le driver branché change.
68
+ *
69
+ * Flux d'une publication cross-pod :
70
+ * - {@link publish} → emballe `{channel,payload,originId}` → `PUBLISH nodefony:realtime`
71
+ * (le fan-out **local** a déjà été fait par le hub avant cet appel) ;
72
+ * - réception (`SUBSCRIBE`) → {@link onMessage} handler → le hub réinjecte en
73
+ * fan-out **local uniquement** (`publishLocal`), jamais re-publié (anti-boucle).
74
+ *
75
+ * Anti-echo (2ᵉ barrière du contrat) : Redis renvoie au pod émetteur ce qu'il
76
+ * publie (publisher + subscriber sont 2 connexions du même pod) → on **filtre son
77
+ * propre `originId`** à la réception, sinon double fan-out local.
78
+ *
79
+ * Authenticité (F83) : Redis pub/sub n'authentifie PAS l'émetteur d'un message —
80
+ * quiconque écrit dans ce Redis (autre app d'un Redis mutualisé, credential fuité,
81
+ * SSRF vers le port) publierait sur les canaux de tous les pods. Avec un `secret`
82
+ * partagé, l'enveloppe est **scellée** (HMAC, cf `envelope.ts`) et l'ingress devient
83
+ * fail-closed strict : non scellé ou mal scellé = ignoré, sans downgrade possible.
84
+ * Sans secret le transport reste ouvert (compat) — le wiring alerte alors au boot.
85
+ *
86
+ * Livraison : **best-effort / at-most-once** — pub/sub Redis ne persiste ni ne
87
+ * rejoue ; un pod déconnecté rate les messages émis pendant sa coupure (le client
88
+ * realtime re-synchronise). Ne pas sur-concevoir une fiabilité que le backing
89
+ * n'offre pas (cf contrat {@link IBackplane}).
90
+ *
91
+ * Perf : hors mono-process (où le hub garde `#backplane === null`). `publish`
92
+ * alloue 1 enveloppe + 1 `JSON.stringify` — sur le chemin broadcast realtime
93
+ * (tickers/fan-out), PAS sur le hot path HTTP. SERVEUR uniquement (`process.pid`).
94
+ */
95
+ var RedisBackplane = class RedisBackplane {
96
+ /** Nom du driver — source unique du littéral (registre + config). */
97
+ static driver = "redis";
98
+ originId;
99
+ #transport;
100
+ #redisChannel;
101
+ /** Secret de scellement partagé entre pods ; `null` = bus non authentifié. */
102
+ #secret;
103
+ /** Borne mémoire des publications en vol (cf {@link BackplanePublishQueue}). */
104
+ #queue;
105
+ #handler = null;
106
+ #started = false;
107
+ constructor(transport, originId = resolveBackplaneOriginId(), redisChannel = REDIS_RT_CHANNEL, secret = null, options = {}) {
108
+ this.#transport = transport;
109
+ this.originId = originId;
110
+ this.#redisChannel = redisChannel;
111
+ this.#secret = secret;
112
+ this.#queue = new BackplanePublishQueue(options.maxQueueBytes ?? 8388608, options.onNotice ?? null);
113
+ }
114
+ /**
115
+ * Abonne au canal Redis dédié. Idempotent (le flag est posé avant l'`await` →
116
+ * un 2ᵉ appel concurrent ne ré-abonne pas).
117
+ */
118
+ async start() {
119
+ if (this.#started) return;
120
+ this.#started = true;
121
+ await this.#transport.subscribe(this.#redisChannel, (raw) => this.#ingress(raw));
122
+ }
123
+ /**
124
+ * Propage une publication locale aux autres pods via Redis. NE refait PAS le
125
+ * fan-out local (déjà fait par le hub). Sérialisé une fois ici.
126
+ *
127
+ * L'envoi passe par la **file bornée** : si le bus n'acquitte plus, les
128
+ * publications suivantes sont jetées et comptées plutôt que d'empiler des
129
+ * mégaoctets dans le client Redis (sémantique at-most-once déjà assumée par le
130
+ * port ; le client realtime re-synchronise).
131
+ */
132
+ publish(channel, payload) {
133
+ const env = {
134
+ channel,
135
+ payload,
136
+ originId: this.originId
137
+ };
138
+ const raw = sealBackplaneEnvelope(env, this.#secret);
139
+ this.#queue.send(Buffer.byteLength(raw), () => this.#transport.publish(this.#redisChannel, raw));
140
+ }
141
+ /** Un seul handler d'ingress ; un appel ultérieur remplace le précédent. */
142
+ onMessage(handler) {
143
+ this.#handler = handler;
144
+ }
145
+ /**
146
+ * Ouverture d'enveloppe (parse + vérification du sceau) + anti-echo + délégation.
147
+ * Robuste par construction : un message malformé, non scellé alors qu'un secret
148
+ * est exigé, ou au sceau invalide, est **ignoré** — jamais réinjecté, jamais fatal.
149
+ */
150
+ #ingress(raw) {
151
+ const msg = openBackplaneEnvelope(raw, this.#secret);
152
+ if (msg === null) return;
153
+ if (msg.originId === this.originId) return;
154
+ this.#handler?.(msg);
155
+ }
156
+ /** Désabonne du canal Redis et détache le handler. Idempotent. */
157
+ async stop() {
158
+ this.#handler = null;
159
+ if (!this.#started) return;
160
+ this.#started = false;
161
+ await this.#transport.unsubscribe(this.#redisChannel);
162
+ }
163
+ describe() {
164
+ return {
165
+ driver: RedisBackplane.driver,
166
+ kind: "redis-pubsub",
167
+ originId: this.originId,
168
+ crossPod: true,
169
+ channel: this.#redisChannel,
170
+ sealed: this.#secret !== null,
171
+ queue: this.#queue.describe()
172
+ };
173
+ }
174
+ };
175
+ //#endregion
176
+ export { REDIS_RT_CHANNEL, RedisBackplane, RedisBackplane as default, createRedisServiceTransport, resolveRedisChannel };
@@ -0,0 +1,20 @@
1
+ //#region nodefony/src/backplane/backplaneRegistry.ts
2
+ const drivers = /* @__PURE__ */ new Map();
3
+ /**
4
+ * Enregistre (ou remplace) la fabrique d'un driver backplane. Appelé par les
5
+ * drivers natifs au chargement du module, et par l'utilisateur pour ses drivers
6
+ * custom (NATS, Pulsar, RabbitMQ…).
7
+ */
8
+ function registerBackplaneDriver(name, factory) {
9
+ drivers.set(name, factory);
10
+ }
11
+ /** Fabrique d'un driver par nom, ou `undefined` si inconnu. */
12
+ function getBackplaneDriver(name) {
13
+ return drivers.get(name);
14
+ }
15
+ /** Noms des drivers enregistrés (introspection / Studio / tests). */
16
+ function listBackplaneDrivers() {
17
+ return [...drivers.keys()];
18
+ }
19
+ //#endregion
20
+ export { getBackplaneDriver, listBackplaneDrivers, registerBackplaneDriver };