@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,601 @@
|
|
|
1
|
+
import { ANONYMOUS_REALTIME_TOKEN } from "./AnonymousRealtimeToken.js";
|
|
2
|
+
import { getRealtimeHub } from "./RealtimeHub.js";
|
|
3
|
+
import { DEFAULT_ACTION_POLICY, getRealtimeActions, getRealtimeChannelPolicies, getRealtimeChannels, getRealtimeInbound } from "../../decorators/realtimeDecorators.js";
|
|
4
|
+
import { createSyslogUplinkHandler } from "./syslogUplink.js";
|
|
5
|
+
import { WsConnectionTransport } from "../transport/WsConnectionTransport.js";
|
|
6
|
+
import { welcomeEnv } from "./welcomeEnv.js";
|
|
7
|
+
import { deniedDetail } from "./deniedDetail.js";
|
|
8
|
+
import { JsonRpcPeer, PLATFORM_INBOUND, RequestContext, RpcEnvelope, RpcError } from "nodefony";
|
|
9
|
+
import { readBackpressureOptions } from "@nodefony/http";
|
|
10
|
+
import { Controller } from "@nodefony/framework";
|
|
11
|
+
//#region nodefony/src/server/RealtimeController.ts
|
|
12
|
+
/**
|
|
13
|
+
* Statut HTTP-équivalent d'une erreur survenue pendant une invocation du pont.
|
|
14
|
+
*
|
|
15
|
+
* UNE seule lecture pour les trois sources possibles — la `RpcError` déjà mappée
|
|
16
|
+
* (404 d'un path inconnu…), le `nodefonyError` HTTP-like levé par une action ou
|
|
17
|
+
* une garde (`@IsGranted` → 403), et le `HttpError` 405 agrégé du Router. Écrire
|
|
18
|
+
* ce test à trois endroits l'aurait fait diverger (règle : une décision, une
|
|
19
|
+
* fonction).
|
|
20
|
+
*
|
|
21
|
+
* @returns le statut, ou `null` si l'erreur n'est pas HTTP-like (→ opaque).
|
|
22
|
+
*/
|
|
23
|
+
function httpStatusOfFrameError(e) {
|
|
24
|
+
if (e instanceof RpcError) {
|
|
25
|
+
const status = e.data?.status;
|
|
26
|
+
if (typeof status === "number") return status;
|
|
27
|
+
return e.code === -32602 ? 400 : null;
|
|
28
|
+
}
|
|
29
|
+
const code = e.code;
|
|
30
|
+
return typeof code === "number" && code >= 400 && code <= 599 ? code : null;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Erreur renvoyée au pair pour une invocation en échec.
|
|
34
|
+
*
|
|
35
|
+
* Un refus est une RÉPONSE (statut exposé, symétrie d'un `fetch`) ; une panne
|
|
36
|
+
* reste OPAQUE (`-32603` générique côté peer — Zero Trust). En profiling, l'id
|
|
37
|
+
* du profil est joint aux refus : le client peut alors radiographier son propre
|
|
38
|
+
* refus (le 403 `@IsGranted` devient pédagogique au lieu d'être un mur).
|
|
39
|
+
*/
|
|
40
|
+
function toFrameRpcError(e, status, requestId) {
|
|
41
|
+
if (e instanceof RpcError) {
|
|
42
|
+
if (!requestId) return e;
|
|
43
|
+
const data = typeof e.data === "object" && e.data !== null ? {
|
|
44
|
+
...e.data,
|
|
45
|
+
requestId
|
|
46
|
+
} : { requestId };
|
|
47
|
+
return new RpcError(e.message, e.code, data);
|
|
48
|
+
}
|
|
49
|
+
if (status !== null) {
|
|
50
|
+
const data = { status };
|
|
51
|
+
if (requestId) data.requestId = requestId;
|
|
52
|
+
return new RpcError(e instanceof Error ? e.message : String(e), -32e3, data);
|
|
53
|
+
}
|
|
54
|
+
return e;
|
|
55
|
+
}
|
|
56
|
+
let warnedUnenforcedPolicies = false;
|
|
57
|
+
/**
|
|
58
|
+
* RealtimeController — base d'un endpoint WebSocket temps réel SERVEUR (JSON-RPC 2.0).
|
|
59
|
+
*
|
|
60
|
+
* Factorise TOUT le protocole (handshake/welcome, discrimination request/notification/
|
|
61
|
+
* response via {@link JsonRpcPeer}, actions `result`/`error`, pub/sub par canal, cleanup)
|
|
62
|
+
* — écrit UNE fois, partagé par tous les modules. Un contrôleur concret ne déclare que
|
|
63
|
+
* son métier : {@link createRealtimeChannel} (providers de canaux) + {@link realtimeActions}.
|
|
64
|
+
*
|
|
65
|
+
* Chaque connexion compose un `JsonRpcPeer` (le MÊME que `RealtimeClient` côté navigateur
|
|
66
|
+
* — symétrie isomorphe) branché sur un {@link WsConnectionTransport}.
|
|
67
|
+
*
|
|
68
|
+
* Usage : le sous-classe garde sa route WS et délègue —
|
|
69
|
+
* ```ts
|
|
70
|
+
* @route("ws", { path: "/realtime", requirements: { methods: ["WEBSOCKET"] } })
|
|
71
|
+
* async realtime(message: string | Buffer | null) { this.handleRealtime(message); }
|
|
72
|
+
* ```
|
|
73
|
+
*
|
|
74
|
+
* Perf : les canaux sont **partagés** via {@link RealtimeHub} (1 provider/canal/pod, pas
|
|
75
|
+
* 1 par connexion) ; chaque connexion n'ajoute/retire qu'un *sink* (fan-out). Provider
|
|
76
|
+
* créé au 1ᵉʳ abonné, `dispose()` au dernier (`unsubscribe` ou `onFinish`). Le dispatch
|
|
77
|
+
* d'action n'est payé que sur une requête `id`. C'est aussi le seam du backplane Redis.
|
|
78
|
+
*/
|
|
79
|
+
var RealtimeController = class extends Controller {
|
|
80
|
+
/**
|
|
81
|
+
* Map des factories de canaux EXACT, lue depuis les décorateurs `@RealtimeChannel`
|
|
82
|
+
* au handshake (cold-path) et mémoïsée par instance. `null` = pas de décorateur
|
|
83
|
+
* sur cette classe → la base court-circuite la lecture metadata par frame.
|
|
84
|
+
*/
|
|
85
|
+
_decoratedChannels = null;
|
|
86
|
+
/**
|
|
87
|
+
* Crée le provider d'un canal (listener/ticker → `publish`) et renvoie son `dispose`.
|
|
88
|
+
* `null` si le canal est inconnu — la base ne souscrit pas.
|
|
89
|
+
*
|
|
90
|
+
* **Deux façons de déclarer un canal** (coexistent sans casse) :
|
|
91
|
+
* 1. **Décorateur `@RealtimeChannel(name)`** sur une méthode (match EXACT, déclaratif).
|
|
92
|
+
* 2. **Override de cette méthode** (pattern/regex, suffixe `:<ms>`, drill `@<pid>`).
|
|
93
|
+
*
|
|
94
|
+
* La base CONSULTE D'ABORD les décorateurs ; si aucun match, elle appelle
|
|
95
|
+
* cette méthode (fallback). Défaut : `null`. Un controller qui n'utilise QUE
|
|
96
|
+
* des décorateurs peut donc se passer d'override.
|
|
97
|
+
*/
|
|
98
|
+
createRealtimeChannel(_channel, _publish) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
/** Actions RPC exposées (requête→réponse). À surcharger ; défaut : aucune. */
|
|
102
|
+
realtimeActions() {
|
|
103
|
+
return {};
|
|
104
|
+
}
|
|
105
|
+
/** Canaux annoncés au handshake. À surcharger ; défaut : aucun. */
|
|
106
|
+
realtimeChannels() {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Préfixes de canaux **broadcast** (cross-process) de cet endpoint. À surcharger ;
|
|
111
|
+
* défaut : **aucun** → tous les canaux restent **instance-local** (observabilité,
|
|
112
|
+
* état du pod). Un canal listé ici traverse le {@link IBackplane} (cluster IPC /
|
|
113
|
+
* Redis) : chat, présence, notifications… Cf {@link RealtimeHub.markBroadcastChannel}
|
|
114
|
+
* (défaut sûr : pas de fuite cross-pod de données per-instance sans intention explicite).
|
|
115
|
+
*/
|
|
116
|
+
realtimeBroadcastChannels() {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Canaux FULL-DUPLEX acceptant une entrée client (`method` = nom du canal). À
|
|
121
|
+
* surcharger ; défaut : **aucun** (sûr — un client ne peut rien pousser au serveur
|
|
122
|
+
* tant qu'un canal n'est pas explicitement déclaré ici). Seam des backings entrants
|
|
123
|
+
* (SIP, bridge). Cf {@link RealtimeInboundHandler} (params NON FIABLES).
|
|
124
|
+
*/
|
|
125
|
+
realtimeInbound() {
|
|
126
|
+
return {};
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Opt-in du **pont API** (« API souveraine », POC Ph.3) : `true` → la
|
|
130
|
+
* connexion expose la méthode RPC `api.request {path}` qui re-route le path
|
|
131
|
+
* porté par le message vers l'action controller correspondante — la MÊME
|
|
132
|
+
* action que le GET REST (`socket.request("/nodefony/kernel/api/modules")`
|
|
133
|
+
* côté client). Le pont n'atteint QUE les routes déclarant le transport
|
|
134
|
+
* `WEBSOCKET` dans leurs `methods` (zéro bypass : une action dit à quels
|
|
135
|
+
* transports elle répond). À surcharger ; défaut : `false` — aucune surface
|
|
136
|
+
* d'invocation ajoutée à un hub sans intention explicite.
|
|
137
|
+
*/
|
|
138
|
+
realtimeApiRequest() {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Point d'entrée à appeler depuis la route WS du contrôleur. `message === null`
|
|
143
|
+
* = handshake (1ʳᵉ invocation) ; sinon = frame entrante.
|
|
144
|
+
*
|
|
145
|
+
* Le handshake est désormais ASYNC (seams sécu #2/#4 : origin check + run
|
|
146
|
+
* d'authenticator au handshake). Les frames texte entrantes pendant
|
|
147
|
+
* l'authentification sont DROP silencieusement — le transport JSON-RPC n'est pas
|
|
148
|
+
* encore branché, il n'existe donc AUCUN canal pour porter un refus. Le silence
|
|
149
|
+
* est structurel, pas un choix : c'est au client d'attendre `realtime:welcome`
|
|
150
|
+
* avant de pousser.
|
|
151
|
+
*
|
|
152
|
+
* ⚠️ Cette phrase a longtemps ajouté « ce que `RealtimeClient` fait nativement ».
|
|
153
|
+
* C'était FAUX : le client rejouait ses abonnements dès l'ouverture de la socket,
|
|
154
|
+
* si bien qu'un `subscribe` posé avant le démarrage et TOUS ceux d'après une
|
|
155
|
+
* reconnexion étaient perdus ici même, sans un mot des deux côtés. Un contrat écrit
|
|
156
|
+
* d'un seul côté du fil n'est pas tenu ; il l'est désormais par
|
|
157
|
+
* `RealtimeClient.replaySubscriptions` et les deux cas « la fenêtre où le serveur
|
|
158
|
+
* écoute (welcome) » de `RealtimeClientCoverage.test.ts`.
|
|
159
|
+
*/
|
|
160
|
+
handleRealtime(message) {
|
|
161
|
+
const ctx = this.context;
|
|
162
|
+
if (!ctx) return;
|
|
163
|
+
if (message == null) {
|
|
164
|
+
this.onHandshake(ctx);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
ctx.__nfRealtime?.transport.feed(message.toString());
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* **L1 — duplex serveur→client.** Émet une REQUÊTE vers le client de CETTE
|
|
171
|
+
* connexion (sur une action qu'il a exposée via `client.register(...)`) et attend
|
|
172
|
+
* sa réponse. Possible depuis L0 : le client compose le même `JsonRpcPeer`, il
|
|
173
|
+
* est donc un *callee* (avant L0 : `-32601` systématique).
|
|
174
|
+
*
|
|
175
|
+
* Usages : confirmation d'action (le serveur demande au client de valider),
|
|
176
|
+
* invalidation de cache poussée AVEC accusé, health applicatif serveur→client.
|
|
177
|
+
* Pour une notification sans réponse → {@link notifyClient} (ou un canal pub/sub).
|
|
178
|
+
*
|
|
179
|
+
* @returns le `result` du handler client ; rejette sur erreur applicative
|
|
180
|
+
* ({@link RpcError} `code`/`data` préservés), méthode inconnue (`-32601`),
|
|
181
|
+
* timeout, ou connexion fermée (`dispose`).
|
|
182
|
+
* @throws (rejet) si le handshake n'est pas terminé (aucun peer encore).
|
|
183
|
+
*/
|
|
184
|
+
requestClient(method, params, timeoutMs) {
|
|
185
|
+
const state = this.context.__nfRealtime;
|
|
186
|
+
if (!state) return Promise.reject(/* @__PURE__ */ new Error("requestClient: connexion realtime non établie (welcome non émis)"));
|
|
187
|
+
return state.peer.request(method, params, timeoutMs);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Notification CIBLÉE serveur→client (sans réponse) vers CETTE connexion, hors
|
|
191
|
+
* canal pub/sub — pendant *fire-and-forget* de {@link requestClient}. Pour un
|
|
192
|
+
* fan-out à N abonnés, préférer un canal (`publish` via le hub). No-op si le
|
|
193
|
+
* handshake n'est pas terminé.
|
|
194
|
+
*/
|
|
195
|
+
notifyClient(method, params) {
|
|
196
|
+
this.context.__nfRealtime?.peer.notify(method, params);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Handshake : seams sécurité #4 (Origin RFC 6455 §10.2) + #2 (authenticator
|
|
200
|
+
* réseau) PUIS crée peer+transport, enregistre les actions, welcome + cleanup.
|
|
201
|
+
*
|
|
202
|
+
* Pipeline (cold path, 1× par connexion) :
|
|
203
|
+
* 1. Origin check via `hub.checkOrigin()` (bypass si `csrf.checkOrigin.enabled=false`).
|
|
204
|
+
* 2. Resolve authenticator via `hub.resolveAuthenticator(handshake)` (matchers
|
|
205
|
+
* ordonnés, 1ʳᵉ qui matche capture) → `authenticate()` async.
|
|
206
|
+
* 3. Aucun match OU `enabled=false` → `ANONYMOUS_REALTIME_TOKEN`.
|
|
207
|
+
* 4. Pose le token sur `hub.peer → token` (lookup voters P6 hot-path).
|
|
208
|
+
*
|
|
209
|
+
* Échecs (close WebSocket, codes plage applicative RFC 6455 §7.4.2) :
|
|
210
|
+
* - Origin refusée → code 4003 (`forbidden`).
|
|
211
|
+
* - `authenticate` throw → code 4001 (`unauthorized`).
|
|
212
|
+
*/
|
|
213
|
+
async onHandshake(ctx) {
|
|
214
|
+
const holder = ctx;
|
|
215
|
+
if (holder.__nfRealtime?.welcomed) return;
|
|
216
|
+
const conn = ctx.connection;
|
|
217
|
+
if (!conn) return;
|
|
218
|
+
const hub = getRealtimeHub();
|
|
219
|
+
const handshake = buildHandshakeFromContext(ctx);
|
|
220
|
+
if (!hub.checkOrigin(handshake.origin)) {
|
|
221
|
+
this.log(`WS realtime upgrade refused: Origin "${handshake.origin ?? "(missing)"}" not allowed`, "WARNING");
|
|
222
|
+
conn.close(4003, "origin not allowed");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const authenticator = hub.resolveAuthenticator(handshake);
|
|
226
|
+
let token;
|
|
227
|
+
if (authenticator !== null && authenticator.supports(handshake)) try {
|
|
228
|
+
token = await authenticator.authenticate(handshake);
|
|
229
|
+
authenticator.onSuccess?.(handshake, token);
|
|
230
|
+
} catch (e) {
|
|
231
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
232
|
+
try {
|
|
233
|
+
authenticator.onFailure?.(handshake, err);
|
|
234
|
+
} catch {}
|
|
235
|
+
this.log(`WS realtime auth failed (${authenticator.name}): ${err.message}`, "WARNING");
|
|
236
|
+
conn.close(4001, "unauthorized");
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
else token = ANONYMOUS_REALTIME_TOKEN;
|
|
240
|
+
const transport = new WsConnectionTransport(conn, readBackpressureOptions(ctx.server ?? null));
|
|
241
|
+
const peerOptions = {
|
|
242
|
+
send: (frame) => {
|
|
243
|
+
let json;
|
|
244
|
+
try {
|
|
245
|
+
json = JSON.stringify(frame);
|
|
246
|
+
} catch (e) {
|
|
247
|
+
this.log(`WS realtime send: payload non sérialisable — ${e instanceof Error ? e.message : String(e)}`, "ERROR");
|
|
248
|
+
const id = frame.id;
|
|
249
|
+
if (id === void 0) return;
|
|
250
|
+
json = JSON.stringify({
|
|
251
|
+
jsonrpc: "2.0",
|
|
252
|
+
id,
|
|
253
|
+
error: {
|
|
254
|
+
code: -32603,
|
|
255
|
+
message: "internal error: non-serializable payload"
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
transport.send(json);
|
|
260
|
+
},
|
|
261
|
+
onNotification: (method, params) => this.onRealtimeNotification(ctx, method, params),
|
|
262
|
+
onError: (context, err) => this.log(`${context}: ${err instanceof Error ? err.message : String(err)}`, "ERROR")
|
|
263
|
+
};
|
|
264
|
+
peerOptions.beforeDispatch = (frame) => hub.runAuthorizer(frame, peer);
|
|
265
|
+
peerOptions.onFrameAudit = (reason, frame, auditedPeer) => {
|
|
266
|
+
if (reason !== "denied") return;
|
|
267
|
+
this.log("WS realtime frame refused by authorizer", "WARNING");
|
|
268
|
+
const f = frame;
|
|
269
|
+
if (f.id !== void 0) return;
|
|
270
|
+
const channel = f.method === "subscribe" ? typeof f.params?.channel === "string" ? f.params.channel : void 0 : typeof f.method === "string" ? f.method : void 0;
|
|
271
|
+
if (channel !== void 0) {
|
|
272
|
+
const denied = {
|
|
273
|
+
channel,
|
|
274
|
+
reason: "forbidden",
|
|
275
|
+
...deniedDetail(this.kernel?.environment, `le verrou de frame a refusé l'accès à « ${channel} » pour cette identité : vérifie les rôles ou scopes exigés par le canal (décorateur \`@RealtimeChannel(nom, { roles })\` ou clé \`realtimeChannels\` de la configuration de sécurité), et ceux que porte le jeton — le \`realtime:welcome\` te les rend`)
|
|
276
|
+
};
|
|
277
|
+
auditedPeer.notify("realtime:denied", denied);
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
const peer = new JsonRpcPeer(peerOptions);
|
|
281
|
+
hub.setTokenForPeer(peer, token);
|
|
282
|
+
transport.onMessage((raw) => {
|
|
283
|
+
let parsed;
|
|
284
|
+
try {
|
|
285
|
+
parsed = JSON.parse(raw);
|
|
286
|
+
} catch {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
peer.receive(parsed);
|
|
290
|
+
});
|
|
291
|
+
const allActions = {
|
|
292
|
+
...getRealtimeActions(this),
|
|
293
|
+
...this.realtimeActions()
|
|
294
|
+
};
|
|
295
|
+
for (const [name, handler] of Object.entries(allActions)) {
|
|
296
|
+
peer.register(name, handler);
|
|
297
|
+
if (hub.resolveChannelPolicy(name) === null) hub.registerChannelPolicy(name, DEFAULT_ACTION_POLICY);
|
|
298
|
+
}
|
|
299
|
+
if (this.realtimeApiRequest()) peer.register("api.request", (params) => this.invokeApiRequest(ctx, params, peer));
|
|
300
|
+
this._decoratedChannels = getRealtimeChannels(this);
|
|
301
|
+
const channelPolicies = getRealtimeChannelPolicies(this);
|
|
302
|
+
if (channelPolicies) {
|
|
303
|
+
for (const name in channelPolicies) hub.registerChannelPolicy(name, channelPolicies[name]);
|
|
304
|
+
if (hub.hasUnenforcedChannelPolicies() && !warnedUnenforcedPolicies) {
|
|
305
|
+
warnedUnenforcedPolicies = true;
|
|
306
|
+
this.log("Realtime channel policies declared but NO frame authorizer is wired — these policies are NOT enforced (a protected channel is currently open). Load @nodefony/security with a realtime zone to enforce them.", "WARNING");
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
hub.onPlatformNotice((message, severity) => this.log(message, severity));
|
|
310
|
+
hub.registerConnection(transport);
|
|
311
|
+
let revocable = null;
|
|
312
|
+
if (typeof token.isValid === "function") {
|
|
313
|
+
revocable = {
|
|
314
|
+
token,
|
|
315
|
+
close: (code, reason) => conn.close(code, reason)
|
|
316
|
+
};
|
|
317
|
+
hub.registerRevocable(revocable);
|
|
318
|
+
}
|
|
319
|
+
const broadcast = this.realtimeBroadcastChannels();
|
|
320
|
+
for (let i = 0; i < broadcast.length; i++) hub.markBroadcastChannel(broadcast[i]);
|
|
321
|
+
const decoratedInbound = getRealtimeInbound(this);
|
|
322
|
+
const overrideInbound = this.realtimeInbound();
|
|
323
|
+
const inboundMap = {
|
|
324
|
+
...decoratedInbound,
|
|
325
|
+
...overrideInbound
|
|
326
|
+
};
|
|
327
|
+
const clientLogs = hub.clientLogsLimits;
|
|
328
|
+
if (clientLogs !== null && inboundMap[PLATFORM_INBOUND.syslogUplink] === void 0) {
|
|
329
|
+
const syslog = this.syslog;
|
|
330
|
+
if (syslog) inboundMap[PLATFORM_INBOUND.syslogUplink] = createSyslogUplinkHandler({
|
|
331
|
+
syslog,
|
|
332
|
+
...clientLogs
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
const inbound = Object.keys(inboundMap).length > 0 ? inboundMap : null;
|
|
336
|
+
const state = {
|
|
337
|
+
welcomed: true,
|
|
338
|
+
peer,
|
|
339
|
+
transport,
|
|
340
|
+
channels: /* @__PURE__ */ new Map(),
|
|
341
|
+
inbound
|
|
342
|
+
};
|
|
343
|
+
holder.__nfRealtime = state;
|
|
344
|
+
ctx.once?.("onFinish", () => {
|
|
345
|
+
const liveHub = getRealtimeHub();
|
|
346
|
+
for (const [channel, sink] of state.channels) liveHub.unsubscribe(channel, sink);
|
|
347
|
+
state.channels.clear();
|
|
348
|
+
liveHub.unregisterConnection(transport);
|
|
349
|
+
if (revocable) liveHub.unregisterRevocable(revocable);
|
|
350
|
+
transport.fireClose();
|
|
351
|
+
peer.dispose("ws closed");
|
|
352
|
+
this.log("WS realtime client disconnected — cleanup done", "INFO");
|
|
353
|
+
});
|
|
354
|
+
const announcedChannels = [...this._decoratedChannels ? Object.keys(this._decoratedChannels) : [], ...this.realtimeChannels()].filter((channel) => hub.probeChannel(channel, peer));
|
|
355
|
+
const env = this.kernel?.environment;
|
|
356
|
+
const welcome = {
|
|
357
|
+
ts: Date.now(),
|
|
358
|
+
protocol: "jsonrpc-2.0",
|
|
359
|
+
channels: announcedChannels,
|
|
360
|
+
methods: peer.methods,
|
|
361
|
+
...welcomeEnv(env),
|
|
362
|
+
identity: {
|
|
363
|
+
type: token.type,
|
|
364
|
+
authenticated: token.isAuthenticated(),
|
|
365
|
+
userIdentifier: token.getUserIdentifier(),
|
|
366
|
+
roles: token.getRoles(),
|
|
367
|
+
scopes: token.getScopes()
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
peer.notify("realtime:welcome", welcome);
|
|
371
|
+
this.log("WS realtime client connected", "INFO");
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Notifications entrantes : pub/sub (subscribe/unsubscribe), heartbeat (ping), puis
|
|
375
|
+
* canaux FULL-DUPLEX déclarés (entrée client → handler `realtimeInbound`).
|
|
376
|
+
*/
|
|
377
|
+
onRealtimeNotification(ctx, method, params) {
|
|
378
|
+
if (method === "subscribe") {
|
|
379
|
+
this.startChannel(ctx, params?.channel);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (method === "unsubscribe") {
|
|
383
|
+
this.stopChannel(ctx, params?.channel);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const state = ctx.__nfRealtime;
|
|
387
|
+
const handler = state?.inbound?.[method];
|
|
388
|
+
if (handler) {
|
|
389
|
+
getRealtimeHub().recordInbound();
|
|
390
|
+
handler(params, (payload) => state.peer.notify(method, payload));
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
/** Abonne la connexion à un canal via le hub partagé (idempotent par connexion). */
|
|
394
|
+
startChannel(ctx, channel) {
|
|
395
|
+
if (!channel) return;
|
|
396
|
+
const state = ctx.__nfRealtime;
|
|
397
|
+
if (!state || state.channels.has(channel)) return;
|
|
398
|
+
const cap = getRealtimeHub().maxChannelsPerConnection;
|
|
399
|
+
if (cap !== null && state.channels.size >= cap) {
|
|
400
|
+
const denied = {
|
|
401
|
+
channel,
|
|
402
|
+
reason: "limit",
|
|
403
|
+
...deniedDetail(this.kernel?.environment, `plafond de ${cap} canaux par connexion atteint : désabonne-toi d'un canal avant d'en ouvrir un autre, ou relève \`maxChannelsPerConnection\` dans la configuration realtime`)
|
|
404
|
+
};
|
|
405
|
+
state.peer.notify("realtime:denied", denied);
|
|
406
|
+
this.log(`WS subscribe refusé (cap ${cap} canaux/connexion atteint) → ${channel}`, "DEBUG");
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const sink = (payload, serialized) => {
|
|
410
|
+
if (serialized !== void 0) state.transport.send(serialized);
|
|
411
|
+
else state.peer.notify(channel, payload);
|
|
412
|
+
};
|
|
413
|
+
let servedByPattern = false;
|
|
414
|
+
if (getRealtimeHub().subscribeClient(channel, sink, (ch, publish) => {
|
|
415
|
+
const decFactory = this._decoratedChannels?.[ch];
|
|
416
|
+
if (decFactory) return decFactory(ch, publish);
|
|
417
|
+
const dispose = this.createRealtimeChannel(ch, publish);
|
|
418
|
+
if (dispose !== null) servedByPattern = true;
|
|
419
|
+
return dispose;
|
|
420
|
+
}, (payload) => JSON.stringify(JsonRpcPeer.buildNotification(channel, payload)))) {
|
|
421
|
+
state.channels.set(channel, sink);
|
|
422
|
+
if (servedByPattern) getRealtimeHub().noticeUnguardedDynamicChannel(channel);
|
|
423
|
+
this.log(`WS subscribe → ${channel}`, "DEBUG");
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const floor = getRealtimeHub().isClosedBySystemFloor(channel);
|
|
427
|
+
const denied = {
|
|
428
|
+
channel,
|
|
429
|
+
reason: floor ? "forbidden" : "unknown",
|
|
430
|
+
...deniedDetail(this.kernel?.environment, floor ? `« ${channel} » appartient au namespace de plateforme, dont le plancher est CLOS tant qu'aucun module de sécurité n'est chargé — ce n'est pas ton identité qui est en cause` : `aucun producteur pour « ${channel} » sur ce pod : vérifie l'orthographe du canal, puis qu'un \`@RealtimeChannel("${channel}")\` est bien déclaré dans un controller CHARGÉ (le runtime lit \`dist/\`, pas les sources)`)
|
|
431
|
+
};
|
|
432
|
+
state.peer.notify("realtime:denied", denied);
|
|
433
|
+
this.log(floor ? `WS subscribe refusé (canal de plateforme, aucun module de sécurité) → ${channel}` : `WS subscribe refusé (aucun producteur pour ce canal) → ${channel}`, "DEBUG");
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Handler de la méthode RPC `api.request {path}` — le **pont API souverain**
|
|
437
|
+
* (POC Ph.3) : résout le path porté par le message via le Router
|
|
438
|
+
* (`cleanPathOverride` — l'URL de la connexion n'est jamais mutée) puis
|
|
439
|
+
* exécute l'action SANS la rendre (`executeAction`) : la valeur nue est
|
|
440
|
+
* enveloppée `{id, result}` par le peer — snapshot ≡ GET REST par
|
|
441
|
+
* construction.
|
|
442
|
+
*
|
|
443
|
+
* Query : le `?…` du path invoqué est parsé ici (paires plates, clés répétées
|
|
444
|
+
* → array — sémantique `qs` plate) et porté par `resolver.queryOverride`
|
|
445
|
+
* (per-invocation : zéro bleed entre requêtes concurrentes de la même
|
|
446
|
+
* socket). Limitation POC assumée : syntaxe nested `a[b]=c` non supportée
|
|
447
|
+
* par le pont (doc §11 — le parse complet du transport HTTP n'est pas
|
|
448
|
+
* mutualisé tant que Ph.6 n'a pas tranché).
|
|
449
|
+
*
|
|
450
|
+
* Erreurs → {@link RpcError} (`data.status` = statut HTTP équivalent) :
|
|
451
|
+
* `-32602` params invalides, 404 path inconnu, 408 timeout d'action…
|
|
452
|
+
* Tout autre throw de l'action reste opaque (`-32603`, Zero Trust).
|
|
453
|
+
*/
|
|
454
|
+
async invokeApiRequest(ctx, params, peer) {
|
|
455
|
+
const p = params;
|
|
456
|
+
const path = p?.path;
|
|
457
|
+
if (typeof path !== "string" || path.charCodeAt(0) !== 47) throw new RpcError("api.request: params.path invalide", -32602);
|
|
458
|
+
const method = typeof p?.method === "string" ? p.method.toUpperCase() : "GET";
|
|
459
|
+
if (method !== "GET" && method !== "POST" && method !== "PUT" && method !== "PATCH" && method !== "DELETE") throw new RpcError(`api.request: méthode ${method} non supportée`, -32602);
|
|
460
|
+
const router = ctx.router;
|
|
461
|
+
if (!router) throw new RpcError("api.request: router indisponible", -32e3, { status: 500 });
|
|
462
|
+
const qIdx = path.indexOf("?");
|
|
463
|
+
const pathname = qIdx === -1 ? path : path.slice(0, qIdx);
|
|
464
|
+
const frame = ctx.beginFrame(method, path);
|
|
465
|
+
try {
|
|
466
|
+
frame?.phaseStart("resolve");
|
|
467
|
+
let resolver;
|
|
468
|
+
try {
|
|
469
|
+
resolver = router.resolve(ctx, pathname, method === "GET" ? void 0 : method);
|
|
470
|
+
} finally {
|
|
471
|
+
frame?.phaseEnd("resolve");
|
|
472
|
+
}
|
|
473
|
+
if (!resolver.resolve) throw new RpcError(`api.request: not found ${pathname}`, -32e3, { status: 404 });
|
|
474
|
+
if (frame) frame.resolver = resolver;
|
|
475
|
+
if (qIdx !== -1 && qIdx < path.length - 1) {
|
|
476
|
+
const sp = new URLSearchParams(path.slice(qIdx + 1));
|
|
477
|
+
const query = {};
|
|
478
|
+
for (const [k, v] of sp) {
|
|
479
|
+
const prev = query[k];
|
|
480
|
+
if (prev === void 0) query[k] = v;
|
|
481
|
+
else if (Array.isArray(prev)) prev.push(v);
|
|
482
|
+
else query[k] = [prev, v];
|
|
483
|
+
}
|
|
484
|
+
resolver.queryOverride = query;
|
|
485
|
+
}
|
|
486
|
+
const token = getRealtimeHub().getTokenForPeer(peer);
|
|
487
|
+
if (token.isValid) {
|
|
488
|
+
let valid;
|
|
489
|
+
frame?.phaseStart("identity");
|
|
490
|
+
try {
|
|
491
|
+
valid = await token.isValid();
|
|
492
|
+
} catch {
|
|
493
|
+
valid = false;
|
|
494
|
+
} finally {
|
|
495
|
+
frame?.phaseEnd("identity");
|
|
496
|
+
}
|
|
497
|
+
if (!valid) throw new RpcError("api.request: identité de session expirée ou invalide", -32e3, { status: 401 });
|
|
498
|
+
}
|
|
499
|
+
const renderSink = {};
|
|
500
|
+
const result = await RequestContext.run({
|
|
501
|
+
requestId: ctx.requestId,
|
|
502
|
+
scheme: ctx.scheme,
|
|
503
|
+
token,
|
|
504
|
+
user: token.getAttribute("user"),
|
|
505
|
+
userId: token.getUserIdentifier(),
|
|
506
|
+
context: ctx,
|
|
507
|
+
body: p?.body,
|
|
508
|
+
idempotencyKey: typeof p?.idempotencyKey === "string" ? p.idempotencyKey : void 0,
|
|
509
|
+
renderSink,
|
|
510
|
+
invocation: frame ?? void 0,
|
|
511
|
+
queries: frame?.profilerQueries ?? void 0
|
|
512
|
+
}, async () => {
|
|
513
|
+
frame?.phaseStart("action");
|
|
514
|
+
try {
|
|
515
|
+
const { result: actionResult } = await resolver.executeActionGuarded(void 0, true);
|
|
516
|
+
const raw = await Promise.resolve(actionResult);
|
|
517
|
+
if (renderSink.body !== void 0) {
|
|
518
|
+
const text = Buffer.isBuffer(renderSink.body) ? renderSink.body.toString("utf8") : renderSink.body;
|
|
519
|
+
try {
|
|
520
|
+
return JSON.parse(text);
|
|
521
|
+
} catch {
|
|
522
|
+
return text;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return raw;
|
|
526
|
+
} finally {
|
|
527
|
+
frame?.phaseEnd("action");
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
frame?.finish(200);
|
|
531
|
+
return frame ? new RpcEnvelope(result, { requestId: frame.requestId }) : result;
|
|
532
|
+
} catch (e) {
|
|
533
|
+
const status = httpStatusOfFrameError(e);
|
|
534
|
+
frame?.finish(status ?? 500, e);
|
|
535
|
+
throw toFrameRpcError(e, status, frame?.requestId);
|
|
536
|
+
} finally {
|
|
537
|
+
ctx.collectFrame(frame);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
/** Désabonne la connexion d'un canal (le hub dispose le provider au dernier abonné). */
|
|
541
|
+
stopChannel(ctx, channel) {
|
|
542
|
+
if (!channel) return;
|
|
543
|
+
const state = ctx.__nfRealtime;
|
|
544
|
+
const sink = state?.channels.get(channel);
|
|
545
|
+
if (sink) {
|
|
546
|
+
getRealtimeHub().unsubscribe(channel, sink);
|
|
547
|
+
state.channels.delete(channel);
|
|
548
|
+
this.log(`WS unsubscribe → ${channel}`, "DEBUG");
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
/**
|
|
553
|
+
* Construit un {@link IRealtimeHandshake} immuable depuis le `WebsocketContext`
|
|
554
|
+
* de @nodefony/http — DTO neutre passé aux authenticators réseau (zéro
|
|
555
|
+
* dépendance security/http côté contrat). Cold path (1× par upgrade).
|
|
556
|
+
*
|
|
557
|
+
* - `cookies` est aplati en `Map<string, string>` (Context expose
|
|
558
|
+
* `Record<string, Cookie>` — on ne garde que `name → value`, les options
|
|
559
|
+
* path/domain/expires ne sont pas utiles à l'authenticator).
|
|
560
|
+
* - `protocols` : la liste des sous-protocoles annoncés (`Sec-WebSocket-Protocol`,
|
|
561
|
+
* csv séparé virgule selon RFC 6455 §4.1).
|
|
562
|
+
*/
|
|
563
|
+
function buildHandshakeFromContext(ctx) {
|
|
564
|
+
const headers = ctx.request?.headers ?? {};
|
|
565
|
+
const cookies = /* @__PURE__ */ new Map();
|
|
566
|
+
const rawCookies = ctx.cookies ?? {};
|
|
567
|
+
for (const name in rawCookies) {
|
|
568
|
+
const c = rawCookies[name];
|
|
569
|
+
if (c && typeof c.value === "string") cookies.set(name, c.value);
|
|
570
|
+
}
|
|
571
|
+
const rawProto = headers["sec-websocket-protocol"];
|
|
572
|
+
let protocols;
|
|
573
|
+
if (Array.isArray(rawProto)) protocols = rawProto.flatMap((p) => p.split(",").map((x) => x.trim()));
|
|
574
|
+
else if (typeof rawProto === "string") protocols = rawProto.split(",").map((x) => x.trim()).filter(Boolean);
|
|
575
|
+
else protocols = [];
|
|
576
|
+
return {
|
|
577
|
+
headers,
|
|
578
|
+
cookies,
|
|
579
|
+
url: handshakePath(ctx),
|
|
580
|
+
remoteAddress: ctx.remoteAddress ?? "",
|
|
581
|
+
origin: ctx.origin && ctx.origin.length > 0 ? ctx.origin : void 0,
|
|
582
|
+
protocols
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Extrait le **path** (pathname + query) de l'URL du handshake. `WebsocketContext.url`
|
|
587
|
+
* est absolue (`url.format(wsUrl)`) ; un path relatif (tests/clients legacy) est
|
|
588
|
+
* conservé tel quel. Jamais throw (cold path, 1× par upgrade).
|
|
589
|
+
*/
|
|
590
|
+
function handshakePath(ctx) {
|
|
591
|
+
const raw = ctx.url;
|
|
592
|
+
if (typeof raw !== "string" || raw.length === 0) return "/";
|
|
593
|
+
try {
|
|
594
|
+
const u = new URL(raw);
|
|
595
|
+
return u.pathname + u.search;
|
|
596
|
+
} catch {
|
|
597
|
+
return raw.charCodeAt(0) === 47 ? raw : "/";
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
//#endregion
|
|
601
|
+
export { RealtimeController, RealtimeController as default };
|