@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,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 };