@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.
- package/LICENSE +544 -0
- package/README.md +207 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/index.js +218 -0
- package/dist/nodefony/config/config.js +63 -0
- package/dist/nodefony/config/defineModuleConfig.js +60 -0
- package/dist/nodefony/decorators/realtimeDecorators.js +240 -0
- package/dist/nodefony/interfaces/IBackplane.js +1 -0
- package/dist/nodefony/interfaces/IChannelPolicy.js +1 -0
- package/dist/nodefony/interfaces/IClientLogsLimits.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeAuthenticator.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeAuthenticatorMatcher.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeController.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeHandshake.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeProbe.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeToken.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/src/backplane/ClusterBackplane.js +105 -0
- package/dist/nodefony/src/backplane/LoopbackBackplane.js +40 -0
- package/dist/nodefony/src/backplane/RedisBackplane.js +176 -0
- package/dist/nodefony/src/backplane/backplaneRegistry.js +20 -0
- package/dist/nodefony/src/backplane/envelope.js +109 -0
- package/dist/nodefony/src/backplane/originId.js +35 -0
- package/dist/nodefony/src/backplane/publishQueue.js +96 -0
- package/dist/nodefony/src/cluster/ClusterProbeClient.js +284 -0
- package/dist/nodefony/src/server/AnonymousRealtimeToken.js +35 -0
- package/dist/nodefony/src/server/RealtimeAdminApi.js +87 -0
- package/dist/nodefony/src/server/RealtimeController.js +601 -0
- package/dist/nodefony/src/server/RealtimeHub.js +926 -0
- package/dist/nodefony/src/server/ServerRealtimeSocket.js +168 -0
- package/dist/nodefony/src/server/deniedDetail.js +29 -0
- package/dist/nodefony/src/server/syslogUplink.js +105 -0
- package/dist/nodefony/src/server/welcomeEnv.js +25 -0
- package/dist/nodefony/src/service/RealtimeService.js +240 -0
- package/dist/nodefony/src/transport/WsConnectionTransport.js +94 -0
- package/dist/nodefony/testing/index.js +163 -0
- package/dist/types/index.d.ts +99 -0
- package/dist/types/nodefony/config/config.d.ts +43 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +49 -0
- package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +190 -0
- package/dist/types/nodefony/interfaces/IBackplane.d.ts +128 -0
- package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +28 -0
- package/dist/types/nodefony/interfaces/IClientLogsLimits.d.ts +22 -0
- package/dist/types/nodefony/interfaces/IRealtimeAuthenticator.d.ts +53 -0
- package/dist/types/nodefony/interfaces/IRealtimeAuthenticatorMatcher.d.ts +46 -0
- package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +36 -0
- package/dist/types/nodefony/interfaces/IRealtimeHandshake.d.ts +27 -0
- package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +206 -0
- package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +48 -0
- package/dist/types/nodefony/interfaces/index.d.ts +18 -0
- package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +76 -0
- package/dist/types/nodefony/src/backplane/LoopbackBackplane.d.ts +29 -0
- package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +164 -0
- package/dist/types/nodefony/src/backplane/backplaneRegistry.d.ts +51 -0
- package/dist/types/nodefony/src/backplane/envelope.d.ts +27 -0
- package/dist/types/nodefony/src/backplane/originId.d.ts +22 -0
- package/dist/types/nodefony/src/backplane/publishQueue.d.ts +71 -0
- package/dist/types/nodefony/src/cluster/ClusterProbeClient.d.ts +90 -0
- package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +3 -0
- package/dist/types/nodefony/src/server/RealtimeAdminApi.d.ts +18 -0
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +165 -0
- package/dist/types/nodefony/src/server/RealtimeHub.d.ts +516 -0
- package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +63 -0
- package/dist/types/nodefony/src/server/deniedDetail.d.ts +25 -0
- package/dist/types/nodefony/src/server/syslogUplink.d.ts +58 -0
- package/dist/types/nodefony/src/server/welcomeEnv.d.ts +21 -0
- package/dist/types/nodefony/src/service/RealtimeService.d.ts +167 -0
- package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +78 -0
- package/dist/types/nodefony/testing/index.d.ts +169 -0
- package/docs/actions.md +573 -0
- package/docs/architecture.md +889 -0
- package/docs/configuration.md +803 -0
- package/docs/cookbook-chat.md +1619 -0
- package/docs/couverture-bancs.md +139 -0
- package/docs/index.md +372 -0
- package/docs/observabilite.md +592 -0
- package/docs/protocole.md +518 -0
- package/docs/securite.md +845 -0
- package/docs/vocabulaire.md +845 -0
- 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 @@
|
|
|
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 };
|