@nodefony/realtime 10.0.0-beta.2 → 10.0.0-beta.4

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 (63) hide show
  1. package/README.md +2 -2
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  3. package/dist/index.js +18 -13
  4. package/dist/nodefony/config/config.js +7 -5
  5. package/dist/nodefony/config/configSchema.js +18 -0
  6. package/dist/nodefony/config/defineModuleConfig.js +43 -17
  7. package/dist/nodefony/decorators/realtimeDecorators.js +69 -19
  8. package/dist/nodefony/interfaces/IRealtimeConnectionState.js +1 -0
  9. package/dist/nodefony/src/backplane/ClusterBackplane.js +1 -2
  10. package/dist/nodefony/src/backplane/LoopbackBackplane.js +1 -2
  11. package/dist/nodefony/src/backplane/RedisBackplane.js +22 -6
  12. package/dist/nodefony/src/backplane/backplaneRegistry.js +1 -2
  13. package/dist/nodefony/src/backplane/bootNotices.js +42 -0
  14. package/dist/nodefony/src/backplane/envelope.js +1 -2
  15. package/dist/nodefony/src/backplane/originId.js +1 -2
  16. package/dist/nodefony/src/backplane/publishQueue.js +1 -5
  17. package/dist/nodefony/src/cluster/ClusterProbeClient.js +16 -6
  18. package/dist/nodefony/src/guards.js +40 -0
  19. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +2 -6
  20. package/dist/nodefony/src/server/RealtimeAdminApi.js +1 -2
  21. package/dist/nodefony/src/server/RealtimeController.js +226 -82
  22. package/dist/nodefony/src/server/RealtimeHub.js +32 -54
  23. package/dist/nodefony/src/server/ServerRealtimeSocket.js +30 -13
  24. package/dist/nodefony/src/server/deniedDetail.js +1 -2
  25. package/dist/nodefony/src/server/subscribedAck.js +29 -0
  26. package/dist/nodefony/src/server/syslogUplink.js +5 -4
  27. package/dist/nodefony/src/server/welcomeEnv.js +1 -2
  28. package/dist/nodefony/src/service/RealtimeService.js +4 -4
  29. package/dist/nodefony/src/transport/WsConnectionTransport.js +42 -2
  30. package/dist/nodefony/testing/index.js +118 -5
  31. package/dist/types/index.d.ts +1 -0
  32. package/dist/types/nodefony/config/configSchema.d.ts +12 -0
  33. package/dist/types/nodefony/config/defineModuleConfig.d.ts +29 -6
  34. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +19 -2
  35. package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +40 -0
  36. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +1 -1
  37. package/dist/types/nodefony/interfaces/IRealtimeHandshake.d.ts +1 -1
  38. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +13 -0
  39. package/dist/types/nodefony/interfaces/index.d.ts +1 -0
  40. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +7 -0
  41. package/dist/types/nodefony/src/backplane/bootNotices.d.ts +25 -0
  42. package/dist/types/nodefony/src/guards.d.ts +32 -0
  43. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +13 -0
  44. package/dist/types/nodefony/src/server/RealtimeController.d.ts +82 -4
  45. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +22 -16
  46. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +18 -8
  47. package/dist/types/nodefony/src/server/subscribedAck.d.ts +23 -0
  48. package/dist/types/nodefony/src/service/RealtimeService.d.ts +1 -1
  49. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +26 -1
  50. package/dist/types/nodefony/testing/index.d.ts +8 -3
  51. package/docs/actions.md +129 -45
  52. package/docs/architecture.md +143 -135
  53. package/docs/cadence-et-contre-pression.md +643 -0
  54. package/docs/configuration.md +126 -97
  55. package/docs/cookbook-chat.md +50 -43
  56. package/docs/index.md +63 -38
  57. package/docs/observabilite.md +58 -56
  58. package/docs/protocole.md +110 -76
  59. package/docs/securite.md +128 -117
  60. package/docs/vocabulaire.md +133 -129
  61. package/package.json +17 -13
  62. package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
  63. package/docs/couverture-bancs.md +0 -139
@@ -1,5 +1,15 @@
1
+ import { isRecord } from "../guards.js";
1
2
  import { CLUSTER_PROBE_CTL_KIND, CLUSTER_PROBE_KIND, CLUSTER_PROBE_SNAPSHOT_KIND, RichProcessProbe, isClusterMessage, isClusterProbeEnrich, readOrmRich } from "nodefony";
2
- //#region nodefony/src/cluster/ClusterProbeClient.ts
3
+ // nodefony/src/cluster/ClusterProbeClient.ts
4
+ /**
5
+ * Vrai si la valeur est une liste de santés d'instance. Le snapshot vient du
6
+ * master du cluster par IPC — un process de la MÊME application, pas le
7
+ * réseau : on vérifie la forme qui sert de clé (`instanceId`), le reste est
8
+ * la sonde du même code.
9
+ */
10
+ function isRealtimeHealthList(value) {
11
+ return Array.isArray(value) && value.every((h) => isRecord(h) && typeof h.instanceId === "string");
12
+ }
3
13
  /** Transport de production : `process.send` + `process.on("message")`. */
4
14
  const processProbeTransport = {
5
15
  send(report) {
@@ -213,10 +223,11 @@ var ClusterProbeClient = class {
213
223
  return;
214
224
  }
215
225
  if (!isClusterMessage(msg) || msg.kind !== CLUSTER_PROBE_SNAPSHOT_KIND) return;
216
- const snap = msg;
217
- if (Array.isArray(snap.instances)) {
218
- this.#lastInstances = snap.instances;
219
- this.#snapTs = typeof snap.ts === "number" ? snap.ts : Date.now();
226
+ if (!isRecord(msg)) return;
227
+ const { ts, instances } = msg;
228
+ if (isRealtimeHealthList(instances)) {
229
+ this.#lastInstances = instances;
230
+ this.#snapTs = typeof ts === "number" ? ts : Date.now();
220
231
  }
221
232
  }
222
233
  /** Vue POD agrégée, ou `null` tant qu'aucun snapshot n'est reçu (cold start). */
@@ -280,5 +291,4 @@ function clusterProbeInstance(pid) {
280
291
  if (!health) return null;
281
292
  return health.instances.find((i) => i.process?.pid === pid || i.instanceId === String(pid)) ?? null;
282
293
  }
283
- //#endregion
284
294
  export { ClusterProbeClient, ClusterProbeClient as default, clusterProbeHealth, clusterProbeInstance, clusterProbeRequestEnrich, mergeClusterHealth, processProbeTransport, setClusterProbeClient };
@@ -0,0 +1,40 @@
1
+ // nodefony/src/guards.ts
2
+ /**
3
+ * Gardes de type internes au paquet — elles remplacent une conversion (`as`)
4
+ * par une vérification faite à l'exécution, pour qu'une valeur d'une autre
5
+ * forme soit écartée au lieu de traverser le code sous un type qui ment.
6
+ *
7
+ * Non exportées par le paquet : ce sont des détails d'implémentation.
8
+ */
9
+ /**
10
+ * Vrai si la valeur est un objet non nul : ses propriétés se lisent alors,
11
+ * typées `unknown`, et chacune se vérifie avant usage.
12
+ *
13
+ * @param value - valeur venue du réseau, d'une métadonnée ou du conteneur
14
+ * @returns `true` pour tout objet (tableau compris), `false` pour `null` et les primitives
15
+ */
16
+ function isRecord(value) {
17
+ return typeof value === "object" && value !== null;
18
+ }
19
+ /**
20
+ * Vrai si la valeur est une fonction. Toute fonction JavaScript s'appelle avec
21
+ * n'importe quels arguments et rend une valeur inconnue : la signature annoncée
22
+ * ne promet rien de plus que ce que `typeof` a vérifié.
23
+ *
24
+ * @param value - valeur lue par son nom (métadonnée, propriété dynamique)
25
+ * @returns `true` si la valeur est appelable
26
+ */
27
+ function isCallable(value) {
28
+ return typeof value === "function";
29
+ }
30
+ /**
31
+ * Vrai si la valeur se comporte comme une promesse (transport asynchrone) —
32
+ * un client Redis rend une promesse, un faux transport de test rend `void`.
33
+ *
34
+ * @param value - retour d'un transport
35
+ * @returns `true` si la valeur porte une méthode `then`
36
+ */
37
+ function isThenable(value) {
38
+ return isRecord(value) && typeof value.then === "function";
39
+ }
40
+ export { isCallable, isRecord, isThenable };
@@ -1,4 +1,4 @@
1
- //#region nodefony/src/server/AnonymousRealtimeToken.ts
1
+ // nodefony/src/server/AnonymousRealtimeToken.ts
2
2
  /**
3
3
  * Token realtime ANONYME — fallback Zero Trust quand aucun matcher
4
4
  * `IRealtimeAuthenticatorMatcher` ne capture le handshake.
@@ -12,7 +12,6 @@
12
12
  * token avec `isAuthenticated() === false` et appliqueront leur politique
13
13
  * (Zero Trust : zone protégée → frame refusée par `beforeDispatch`).
14
14
  */
15
- const anonymousAttributes = Object.freeze({});
16
15
  const ANONYMOUS_REALTIME_TOKEN = Object.freeze({
17
16
  type: "anonymous",
18
17
  getUserIdentifier() {
@@ -27,9 +26,6 @@ const ANONYMOUS_REALTIME_TOKEN = Object.freeze({
27
26
  getScopes() {
28
27
  return [];
29
28
  },
30
- getAttribute(key) {
31
- return anonymousAttributes[key];
32
- }
29
+ getAttribute(_key) {}
33
30
  });
34
- //#endregion
35
31
  export { ANONYMOUS_REALTIME_TOKEN, ANONYMOUS_REALTIME_TOKEN as default };
@@ -2,7 +2,7 @@ import { getRealtimeHub } from "./RealtimeHub.js";
2
2
  import { listBackplaneDrivers } from "../backplane/backplaneRegistry.js";
3
3
  import { clusterProbeHealth } from "../cluster/ClusterProbeClient.js";
4
4
  import { Nodefony, ProcessProbe, readOrmHealth } from "nodefony";
5
- //#region nodefony/src/server/RealtimeAdminApi.ts
5
+ // nodefony/src/server/RealtimeAdminApi.ts
6
6
  const processProbe = new ProcessProbe();
7
7
  /**
8
8
  * Producteur `IAdminApi` de la **socket Nodefony** — exposé sous
@@ -83,5 +83,4 @@ function createRealtimeAdminApi() {
83
83
  adminEndpoints: () => endpoints
84
84
  };
85
85
  }
86
- //#endregion
87
86
  export { buildOwnHealth, buildRealtimeHealth, createRealtimeAdminApi };
@@ -1,14 +1,21 @@
1
+ import { isRecord } from "../guards.js";
1
2
  import { ANONYMOUS_REALTIME_TOKEN } from "./AnonymousRealtimeToken.js";
2
3
  import { getRealtimeHub } from "./RealtimeHub.js";
3
4
  import { DEFAULT_ACTION_POLICY, getRealtimeActions, getRealtimeChannelPolicies, getRealtimeChannels, getRealtimeInbound } from "../../decorators/realtimeDecorators.js";
4
5
  import { createSyslogUplinkHandler } from "./syslogUplink.js";
5
6
  import { WsConnectionTransport } from "../transport/WsConnectionTransport.js";
6
7
  import { welcomeEnv } from "./welcomeEnv.js";
8
+ import { subscribedAckFrame } from "./subscribedAck.js";
7
9
  import { deniedDetail } from "./deniedDetail.js";
8
- import { BootConfigurationError, JsonRpcPeer, PLATFORM_INBOUND, RequestContext, RpcEnvelope, RpcError, Scope, identityHint } from "nodefony";
9
- import { readBackpressureOptions } from "@nodefony/http";
10
- import { Controller } from "@nodefony/framework";
11
- //#region nodefony/src/server/RealtimeController.ts
10
+ import { BootConfigurationError, JsonRpcError, JsonRpcPeer, JsonRpcServerError, PLATFORM_INBOUND, RequestContext, RpcEnvelope, RpcError, Scope, identityHint, isJsonRpcId, isPromise, jsonRpcFailure } from "nodefony";
11
+ import { isWebsocketContext, readBackpressureOptions, toValidationFields } from "@nodefony/http";
12
+ import { Controller, buildContextParamArgs, getParamArgsMeta, isFrameworkRouter } from "@nodefony/framework";
13
+ // nodefony/src/server/RealtimeController.ts
14
+ /**
15
+ * Une action RPC n'a ni variables de route ni URL : `@Param` et `@Query` y
16
+ * lisent cet objet vide.
17
+ */
18
+ const NO_ROUTE_PARAMS = Object.freeze({});
12
19
  /**
13
20
  * Statut HTTP-équivalent d'une erreur survenue pendant une invocation du pont.
14
21
  *
@@ -22,11 +29,12 @@ import { Controller } from "@nodefony/framework";
22
29
  */
23
30
  function httpStatusOfFrameError(e) {
24
31
  if (e instanceof RpcError) {
25
- const status = e.data?.status;
32
+ const status = isRecord(e.data) ? e.data.status : void 0;
26
33
  if (typeof status === "number") return status;
27
- return e.code === -32602 ? 400 : null;
34
+ return e.code === JsonRpcError.INVALID_PARAMS ? 400 : null;
28
35
  }
29
- const code = e.code;
36
+ if (e instanceof Error && toValidationFields(e) !== null) return 422;
37
+ const code = isRecord(e) ? e.code : void 0;
30
38
  return typeof code === "number" && code >= 400 && code <= 599 ? code : null;
31
39
  }
32
40
  /**
@@ -40,7 +48,7 @@ function httpStatusOfFrameError(e) {
40
48
  function toFrameRpcError(e, status, requestId) {
41
49
  if (e instanceof RpcError) {
42
50
  if (!requestId) return e;
43
- const data = typeof e.data === "object" && e.data !== null ? {
51
+ const data = isRecord(e.data) ? {
44
52
  ...e.data,
45
53
  requestId
46
54
  } : { requestId };
@@ -49,10 +57,27 @@ function toFrameRpcError(e, status, requestId) {
49
57
  if (status !== null) {
50
58
  const data = { status };
51
59
  if (requestId) data.requestId = requestId;
52
- return new RpcError(e instanceof Error ? e.message : String(e), -32e3, data);
60
+ if (status === 422 && e instanceof Error) {
61
+ const fields = toValidationFields(e);
62
+ if (fields !== null) data.fields = fields;
63
+ }
64
+ return new RpcError(e instanceof Error ? e.message : String(e), JsonRpcServerError.DEFAULT, data);
53
65
  }
54
66
  return e;
55
67
  }
68
+ /** Le canal nommé par les params d'un `subscribe`/`unsubscribe` — entrée réseau. */
69
+ function channelParam(params) {
70
+ return isRecord(params) && typeof params.channel === "string" ? params.channel : void 0;
71
+ }
72
+ /**
73
+ * Le serveur WebSocket qui sert la connexion, ou `null` — lu une fois par
74
+ * poignée de main pour ses réglages de contre-pression. Un `WebSocketServer`
75
+ * porte toujours ses `options` ; aucun serveur HTTP n'en déclare.
76
+ */
77
+ function webSocketServerOf(ctx) {
78
+ const server = ctx.server;
79
+ return server && "options" in server ? server : null;
80
+ }
56
81
  let warnedUnenforcedPolicies = false;
57
82
  /**
58
83
  * RealtimeController — base d'un endpoint WebSocket temps réel SERVEUR (JSON-RPC 2.0).
@@ -62,7 +87,7 @@ let warnedUnenforcedPolicies = false;
62
87
  * — écrit UNE fois, partagé par tous les modules. Un contrôleur concret ne déclare que
63
88
  * son métier : {@link createRealtimeChannel} (providers de canaux) + {@link realtimeActions}.
64
89
  *
65
- * Chaque connexion compose un `JsonRpcPeer` (le MÊME que `RealtimeClient` côté navigateur
90
+ * Chaque connexion compose un `JsonRpcPeer` (le MÊME que `NodefonySocket` côté navigateur
66
91
  * — symétrie isomorphe) branché sur un {@link WsConnectionTransport}.
67
92
  *
68
93
  * Usage : le sous-classe garde sa route WS et délègue —
@@ -106,8 +131,20 @@ var RealtimeController = class extends Controller {
106
131
  constructor(name, context) {
107
132
  new.target.assertScope();
108
133
  super(name, context);
134
+ this.#connection = isWebsocketContext(context) ? context : null;
109
135
  }
110
136
  /**
137
+ * La connexion WebSocket que sert CETTE instance — rétrécie UNE fois, ici,
138
+ * plutôt qu'à chaque frame.
139
+ *
140
+ * Sûr parce que l'instance est liée à sa connexion pour toute sa vie : la
141
+ * portée `singleton` est refusée au constructeur, et le contexte n'est posé
142
+ * qu'à la construction. `null` si la classe sert aussi une route HTTP et que
143
+ * cette instance en est issue. Le chemin de chaque frame lit ce champ au lieu
144
+ * de l'accesseur `context` : une lecture de champ, sans appel.
145
+ */
146
+ #connection;
147
+ /**
111
148
  * Map des factories de canaux EXACT, lue depuis les décorateurs `@RealtimeChannel`
112
149
  * au handshake (cold-path) et mémoïsée par instance. `null` = pas de décorateur
113
150
  * sur cette classe → la base court-circuite la lecture metadata par frame.
@@ -179,17 +216,17 @@ var RealtimeController = class extends Controller {
179
216
  * est structurel, pas un choix : c'est au client d'attendre `realtime:welcome`
180
217
  * avant de pousser.
181
218
  *
182
- * ⚠️ Cette phrase a longtemps ajouté « ce que `RealtimeClient` fait nativement ».
219
+ * ⚠️ Cette phrase a longtemps ajouté « ce que le client fait nativement ».
183
220
  * C'était FAUX : le client rejouait ses abonnements dès l'ouverture de la socket,
184
221
  * si bien qu'un `subscribe` posé avant le démarrage et TOUS ceux d'après une
185
222
  * reconnexion étaient perdus ici même, sans un mot des deux côtés. Un contrat écrit
186
223
  * d'un seul côté du fil n'est pas tenu ; il l'est désormais par
187
- * `RealtimeClient.replaySubscriptions` et les deux cas « la fenêtre où le serveur
188
- * écoute (welcome) » de `RealtimeClientCoverage.test.ts`.
224
+ * `NodefonySocket.replaySubscriptions` et les deux cas « la fenêtre où le serveur
225
+ * écoute (welcome) » de `NodefonySocketCoverage.test.ts`.
189
226
  */
190
227
  handleRealtime(message) {
191
- const ctx = this.context;
192
- if (!ctx) return;
228
+ const ctx = this.#connection;
229
+ if (ctx === null) throw this.outsideWebsocketError();
193
230
  if (message == null) {
194
231
  this.onHandshake(ctx).catch((e) => {
195
232
  this.log(`WS realtime handshake failed: ${e instanceof Error ? e.message : String(e)}`, "ERROR");
@@ -197,7 +234,28 @@ var RealtimeController = class extends Controller {
197
234
  });
198
235
  return;
199
236
  }
200
- ctx.__nfRealtime?.transport.feed(message.toString());
237
+ const state = ctx.__nfRealtime;
238
+ if (typeof message === "string") {
239
+ state?.transport.feed(message);
240
+ return;
241
+ }
242
+ if (state !== void 0) this.receiveBinary(state.transport, message);
243
+ }
244
+ /**
245
+ * Faute de câblage : `handleRealtime` reçu par une instance qui ne sert pas
246
+ * une connexion WebSocket — route sans `methods: ["WEBSOCKET"]`, ou faux
247
+ * contexte de test sans `type`. Levée plutôt que passée sous silence : le
248
+ * client attendrait un welcome qui ne vient jamais. Chemin froid.
249
+ */
250
+ outsideWebsocketError() {
251
+ return /* @__PURE__ */ new TypeError(`${this.constructor.name}.handleRealtime : cette instance ne sert pas une connexion WebSocket (contexte de type « ${String(this.context?.type)} »). La route doit déclarer requirements: { methods: ["WEBSOCKET"] } ; un faux contexte de test déclare type: "websocket".`);
252
+ }
253
+ receiveBinary(transport, data) {
254
+ if (this.onRealtimeBinary !== void 0) {
255
+ this.onRealtimeBinary(data);
256
+ return;
257
+ }
258
+ if (transport.discard("binary") === 1) this.log("WS realtime : trame binaire reçue et écartée — le protocole est du JSON-RPC texte ; pour la lire, surcharger onRealtimeBinary()", "WARNING");
201
259
  }
202
260
  /**
203
261
  * **L1 — duplex serveur→client.** Émet une REQUÊTE vers le client de CETTE
@@ -215,9 +273,9 @@ var RealtimeController = class extends Controller {
215
273
  * @throws (rejet) si le handshake n'est pas terminé (aucun peer encore).
216
274
  */
217
275
  requestClient(method, params, timeoutMs) {
218
- const state = this.context.__nfRealtime;
276
+ const state = this.coldConnState();
219
277
  if (!state) return Promise.reject(/* @__PURE__ */ new Error("requestClient: connexion realtime non établie (welcome non émis)"));
220
- return state.peer.request(method, params, timeoutMs);
278
+ return state.peer.requestAs(method, params, timeoutMs);
221
279
  }
222
280
  /**
223
281
  * Notification CIBLÉE serveur→client (sans réponse) vers CETTE connexion, hors
@@ -226,7 +284,27 @@ var RealtimeController = class extends Controller {
226
284
  * handshake n'est pas terminé.
227
285
  */
228
286
  notifyClient(method, params) {
229
- this.context.__nfRealtime?.peer.notify(method, params);
287
+ this.#connection?.__nfRealtime?.peer.notify(method, params);
288
+ }
289
+ /**
290
+ * Émet une trame BINAIRE vers CETTE connexion, sans la sérialiser — pendant
291
+ * sortant de {@link onRealtimeBinary}, et de `NodefonySocket.sendBinary` côté
292
+ * client. Même contre-pression que les frames JSON-RPC.
293
+ *
294
+ * @param data - les octets à émettre (`Uint8Array`, donc un `Buffer` aussi).
295
+ * @returns `false` si le handshake n'est pas terminé, la connexion fermée ou
296
+ * la trame jetée par la contre-pression.
297
+ */
298
+ sendBinaryToClient(data) {
299
+ return this.coldConnState()?.transport.sendBinary(data) ?? false;
300
+ }
301
+ /**
302
+ * État realtime de CETTE connexion, `undefined` avant le welcome — pour les
303
+ * chemins FROIDS (requête sortante, binaire). `notifyClient` et la réception
304
+ * gardent leur lecture en ligne : ils sont sur le chemin de chaque frame.
305
+ */
306
+ coldConnState() {
307
+ return this.#connection?.__nfRealtime;
230
308
  }
231
309
  /**
232
310
  * Handshake : seams sécurité #4 (Origin RFC 6455 §10.2) + #2 (authenticator
@@ -244,8 +322,7 @@ var RealtimeController = class extends Controller {
244
322
  * - `authenticate` throw → code 4001 (`unauthorized`).
245
323
  */
246
324
  async onHandshake(ctx) {
247
- const holder = ctx;
248
- if (holder.__nfRealtime?.welcomed) return;
325
+ if (ctx.__nfRealtime?.welcomed) return;
249
326
  const conn = ctx.connection;
250
327
  if (!conn) return;
251
328
  const hub = getRealtimeHub();
@@ -270,7 +347,7 @@ var RealtimeController = class extends Controller {
270
347
  return;
271
348
  }
272
349
  else token = ANONYMOUS_REALTIME_TOKEN;
273
- const transport = new WsConnectionTransport(conn, readBackpressureOptions(ctx.server ?? null));
350
+ const transport = new WsConnectionTransport(conn, readBackpressureOptions(webSocketServerOf(ctx)));
274
351
  const peerOptions = {
275
352
  send: (frame) => {
276
353
  let json;
@@ -278,16 +355,9 @@ var RealtimeController = class extends Controller {
278
355
  json = JSON.stringify(frame);
279
356
  } catch (e) {
280
357
  this.log(`WS realtime send: payload non sérialisable — ${e instanceof Error ? e.message : String(e)}`, "ERROR");
281
- const id = frame.id;
282
- if (id === void 0) return;
283
- json = JSON.stringify({
284
- jsonrpc: "2.0",
285
- id,
286
- error: {
287
- code: -32603,
288
- message: "internal error: non-serializable payload"
289
- }
290
- });
358
+ const id = isRecord(frame) ? frame.id : void 0;
359
+ if (!isJsonRpcId(id)) return;
360
+ json = JSON.stringify(jsonRpcFailure(id, JsonRpcError.INTERNAL_ERROR, "internal error: non-serializable payload"));
291
361
  }
292
362
  transport.send(json);
293
363
  },
@@ -298,9 +368,10 @@ var RealtimeController = class extends Controller {
298
368
  peerOptions.onFrameAudit = (reason, frame, auditedPeer) => {
299
369
  if (reason !== "denied") return;
300
370
  this.log("WS realtime frame refused by authorizer", "WARNING");
371
+ if (!isRecord(frame)) return;
301
372
  const f = frame;
302
373
  if (f.id !== void 0) return;
303
- const channel = f.method === "subscribe" ? typeof f.params?.channel === "string" ? f.params.channel : void 0 : typeof f.method === "string" ? f.method : void 0;
374
+ const channel = f.method === "subscribe" ? channelParam(f.params) : typeof f.method === "string" ? f.method : void 0;
304
375
  if (channel !== void 0) {
305
376
  const hint = identityHint(this.kernel);
306
377
  const denied = {
@@ -318,12 +389,13 @@ var RealtimeController = class extends Controller {
318
389
  try {
319
390
  parsed = JSON.parse(raw);
320
391
  } catch {
392
+ if (transport.discard("malformed") === 1) this.log("WS realtime : trame texte illisible (pas du JSON) reçue et écartée", "WARNING");
321
393
  return;
322
394
  }
323
395
  peer.receive(parsed);
324
396
  });
325
397
  const allActions = {
326
- ...getRealtimeActions(this),
398
+ ...getRealtimeActions(this, this.actionParamsWrapper(ctx)),
327
399
  ...this.realtimeActions()
328
400
  };
329
401
  for (const [name, handler] of Object.entries(allActions)) {
@@ -378,7 +450,7 @@ var RealtimeController = class extends Controller {
378
450
  channels: /* @__PURE__ */ new Map(),
379
451
  inbound
380
452
  };
381
- holder.__nfRealtime = state;
453
+ ctx.__nfRealtime = state;
382
454
  ctx.once("onFinish", () => {
383
455
  const liveHub = getRealtimeHub();
384
456
  for (const [channel, sink] of state.channels) liveHub.unsubscribe(channel, sink);
@@ -396,6 +468,7 @@ var RealtimeController = class extends Controller {
396
468
  protocol: "jsonrpc-2.0",
397
469
  channels: announcedChannels,
398
470
  methods: peer.methods,
471
+ subscriptionAcks: true,
399
472
  ...welcomeEnv(env),
400
473
  identity: {
401
474
  type: token.type,
@@ -414,11 +487,11 @@ var RealtimeController = class extends Controller {
414
487
  */
415
488
  onRealtimeNotification(ctx, method, params) {
416
489
  if (method === "subscribe") {
417
- this.startChannel(ctx, params?.channel);
490
+ this.startChannel(ctx, channelParam(params));
418
491
  return;
419
492
  }
420
493
  if (method === "unsubscribe") {
421
- this.stopChannel(ctx, params?.channel);
494
+ this.stopChannel(ctx, channelParam(params));
422
495
  return;
423
496
  }
424
497
  const state = ctx.__nfRealtime;
@@ -428,11 +501,25 @@ var RealtimeController = class extends Controller {
428
501
  handler(params, (payload) => state.peer.notify(method, payload));
429
502
  }
430
503
  }
504
+ /**
505
+ * Acquitte un abonnement ACCEPTÉ (`realtime:subscribed`) — le seul signal
506
+ * qui dise au client à partir de quand le flux du canal lui est livré.
507
+ *
508
+ * Un refus n'y passe jamais : il a `realtime:denied`. Cold path (1×/canal et
509
+ * par connexion).
510
+ */
511
+ acknowledgeSubscription(state, channel) {
512
+ state.transport.send(subscribedAckFrame(channel));
513
+ }
431
514
  /** Abonne la connexion à un canal via le hub partagé (idempotent par connexion). */
432
515
  startChannel(ctx, channel) {
433
516
  if (!channel) return;
434
517
  const state = ctx.__nfRealtime;
435
- if (!state || state.channels.has(channel)) return;
518
+ if (!state) return;
519
+ if (state.channels.has(channel)) {
520
+ this.acknowledgeSubscription(state, channel);
521
+ return;
522
+ }
436
523
  const cap = getRealtimeHub().maxChannelsPerConnection;
437
524
  if (cap !== null && state.channels.size >= cap) {
438
525
  const denied = {
@@ -459,6 +546,7 @@ var RealtimeController = class extends Controller {
459
546
  }, (payload) => JSON.stringify(JsonRpcPeer.buildNotification(channel, payload)))) {
460
547
  state.channels.set(channel, sink);
461
548
  if (isServedByPattern()) getRealtimeHub().noticeUnguardedDynamicChannel(channel);
549
+ this.acknowledgeSubscription(state, channel);
462
550
  this.log(`WS subscribe → ${channel}`, "DEBUG");
463
551
  return;
464
552
  }
@@ -472,6 +560,48 @@ var RealtimeController = class extends Controller {
472
560
  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");
473
561
  }
474
562
  /**
563
+ * Enveloppe des actions `@RealtimeAction` dont un paramètre est décoré
564
+ * (`@Body`, `@CurrentUser`…) : l'action reçoit ses arguments comme une route,
565
+ * résolus par la fonction du framework et non par une copie.
566
+ *
567
+ * L'identité ne change PAS : un message est déjà traité dans la bulle
568
+ * `RequestContext` du handshake (`WebsocketContext` lie son écouteur par
569
+ * `AsyncResource.bind`), où le pare-feu a posé `user` et `token`. La bulle
570
+ * ouverte ici HÉRITE de ce store et n'y ajoute que la charge (`body`, que
571
+ * `@Body` lit dans l'ALS comme pour `api.request`) — une écriture
572
+ * `RequestContext.set()` dans l'action reste ainsi propre à l'appel.
573
+ *
574
+ * Une action sans paramètre décoré est rendue telle quelle : rien ne change
575
+ * pour elle, ni comportement ni coût. Les métadonnées se lisent ICI, une fois
576
+ * par connexion.
577
+ *
578
+ * @param ctx - le contexte de la connexion
579
+ * @returns l'enveloppe passée à {@link getRealtimeActions}
580
+ */
581
+ actionParamsWrapper(ctx) {
582
+ const proto = Reflect.getPrototypeOf(this);
583
+ return (method, propertyKey) => {
584
+ if (proto === null) return method;
585
+ const metas = getParamArgsMeta(proto, propertyKey);
586
+ if (metas === null) return method;
587
+ return (params) => {
588
+ const store = {
589
+ requestId: ctx.requestId,
590
+ ...RequestContext.get(),
591
+ body: params
592
+ };
593
+ const result = RequestContext.run(store, () => method(...buildContextParamArgs(metas, ctx, NO_ROUTE_PARAMS, NO_ROUTE_PARAMS)));
594
+ if (isPromise(result)) {
595
+ const release = () => RequestContext.release(store);
596
+ result.then(release, release);
597
+ return result;
598
+ }
599
+ RequestContext.release(store);
600
+ return result;
601
+ };
602
+ };
603
+ }
604
+ /**
475
605
  * Handler de la méthode RPC `api.request {path}` — le **pont API souverain**
476
606
  * (POC Ph.3) : résout le path porté par le message via le Router
477
607
  * (`cleanPathOverride` — l'URL de la connexion n'est jamais mutée) puis
@@ -491,77 +621,91 @@ var RealtimeController = class extends Controller {
491
621
  * Tout autre throw de l'action reste opaque (`-32603`, Zero Trust).
492
622
  */
493
623
  async invokeApiRequest(ctx, params, peer) {
494
- const p = params;
624
+ const p = isRecord(params) ? params : void 0;
495
625
  const path = p?.path;
496
- if (typeof path !== "string" || path.charCodeAt(0) !== 47) throw new RpcError("api.request: params.path invalide", -32602);
626
+ if (typeof path !== "string" || path.charCodeAt(0) !== 47) throw new RpcError("api.request: params.path invalide", JsonRpcError.INVALID_PARAMS);
497
627
  const method = typeof p?.method === "string" ? p.method.toUpperCase() : "GET";
498
- if (method !== "GET" && method !== "POST" && method !== "PUT" && method !== "PATCH" && method !== "DELETE") throw new RpcError(`api.request: méthode ${method} non supportée`, -32602);
628
+ if (method !== "GET" && method !== "POST" && method !== "PUT" && method !== "PATCH" && method !== "DELETE") throw new RpcError(`api.request: méthode ${method} non supportée`, JsonRpcError.INVALID_PARAMS);
499
629
  const router = ctx.router;
500
- if (!router) throw new RpcError("api.request: router indisponible", -32e3, { status: 500 });
630
+ if (!isFrameworkRouter(router)) throw new RpcError("api.request: router indisponible", JsonRpcServerError.DEFAULT, { status: 500 });
501
631
  const qIdx = path.indexOf("?");
502
632
  const pathname = qIdx === -1 ? path : path.slice(0, qIdx);
503
633
  const frame = ctx.beginFrame(method, path);
634
+ let store = null;
504
635
  try {
505
- frame?.phaseStart("resolve");
506
- let resolver;
507
- try {
508
- resolver = router.resolve(ctx, pathname, method === "GET" ? void 0 : method);
509
- } finally {
510
- frame?.phaseEnd("resolve");
511
- }
512
- if (!resolver.resolve) throw new RpcError(`api.request: not found ${pathname}`, -32e3, { status: 404 });
513
- if (frame) frame.resolver = resolver;
514
- if (qIdx !== -1 && qIdx < path.length - 1) {
515
- const sp = new URLSearchParams(path.slice(qIdx + 1));
516
- const query = {};
517
- for (const [k, v] of sp) {
518
- const prev = query[k];
519
- if (prev === void 0) query[k] = v;
520
- else if (Array.isArray(prev)) prev.push(v);
521
- else query[k] = [prev, v];
522
- }
523
- resolver.queryOverride = query;
524
- }
525
636
  const token = getRealtimeHub().getTokenForPeer(peer);
526
- if (token.isValid) {
527
- let valid;
528
- frame?.phaseStart("identity");
529
- try {
530
- valid = await token.isValid();
531
- } catch {
532
- valid = false;
533
- } finally {
534
- frame?.phaseEnd("identity");
535
- }
536
- if (!valid) throw new RpcError("api.request: identité de session expirée ou invalide", -32e3, { status: 401 });
537
- }
538
637
  const renderSink = {};
539
- const result = await RequestContext.run({
540
- requestId: ctx.requestId,
638
+ const frameStore = {
639
+ requestId: frame?.requestId ?? ctx.requestId,
541
640
  scheme: ctx.scheme,
542
641
  token,
543
642
  user: token.getAttribute("user"),
544
- userId: token.getUserIdentifier(),
545
643
  context: ctx,
546
- resolver,
644
+ resolver: void 0,
547
645
  scope: ctx.container instanceof Scope ? ctx.container : void 0,
548
646
  body: p?.body,
549
647
  idempotencyKey: typeof p?.idempotencyKey === "string" ? p.idempotencyKey : void 0,
550
648
  renderSink,
551
649
  invocation: frame ?? void 0,
552
650
  queries: frame?.profilerQueries ?? void 0
553
- }, async () => {
651
+ };
652
+ store = frameStore;
653
+ const result = await RequestContext.run(frameStore, async () => {
654
+ frame?.phaseStart("resolve");
655
+ let resolver;
656
+ try {
657
+ resolver = router.resolve(ctx, pathname, method === "GET" ? void 0 : method);
658
+ } finally {
659
+ frame?.phaseEnd("resolve");
660
+ }
661
+ if (!resolver.resolve) throw new RpcError(`api.request: not found ${pathname}`, JsonRpcServerError.DEFAULT, { status: 404 });
662
+ if (frame) frame.resolver = resolver;
663
+ frameStore.resolver = resolver;
664
+ if (qIdx !== -1 && qIdx < path.length - 1) {
665
+ const sp = new URLSearchParams(path.slice(qIdx + 1));
666
+ const query = {};
667
+ for (const [k, v] of sp) {
668
+ const prev = query[k];
669
+ if (prev === void 0) query[k] = v;
670
+ else if (Array.isArray(prev)) prev.push(v);
671
+ else query[k] = [prev, v];
672
+ }
673
+ resolver.queryOverride = query;
674
+ }
675
+ if (token.isValid) {
676
+ let valid;
677
+ frame?.phaseStart("identity");
678
+ try {
679
+ valid = await token.isValid();
680
+ if (valid && token.confirmAccount) valid = await token.confirmAccount();
681
+ } catch {
682
+ valid = false;
683
+ } finally {
684
+ frame?.phaseEnd("identity");
685
+ }
686
+ if (!valid) throw new RpcError("api.request: identité de session expirée ou invalide", JsonRpcServerError.DEFAULT, { status: 401 });
687
+ }
554
688
  frame?.phaseStart("action");
555
689
  try {
556
690
  const { result: actionResult } = await resolver.executeActionGuarded(void 0, true);
557
691
  const raw = await Promise.resolve(actionResult);
558
692
  if (renderSink.body !== void 0) {
559
693
  const text = Buffer.isBuffer(renderSink.body) ? renderSink.body.toString("utf8") : renderSink.body;
694
+ let body;
560
695
  try {
561
- return JSON.parse(text);
696
+ body = JSON.parse(text);
562
697
  } catch {
563
- return text;
698
+ body = text;
699
+ }
700
+ const status = renderSink.status;
701
+ if (status !== void 0 && status >= 400) {
702
+ const error = isRecord(body) ? body.error : void 0;
703
+ throw new RpcError(typeof error === "string" ? error : `api.request: HTTP ${status}`, JsonRpcServerError.DEFAULT, {
704
+ status,
705
+ body
706
+ });
564
707
  }
708
+ return body;
565
709
  }
566
710
  return raw;
567
711
  } finally {
@@ -575,6 +719,7 @@ var RealtimeController = class extends Controller {
575
719
  frame?.finish(status ?? 500, e);
576
720
  throw toFrameRpcError(e, status, frame?.requestId);
577
721
  } finally {
722
+ if (store !== null) RequestContext.release(store);
578
723
  ctx.collectFrame(frame);
579
724
  }
580
725
  }
@@ -639,5 +784,4 @@ function handshakePath(ctx) {
639
784
  return raw.charCodeAt(0) === 47 ? raw : "/";
640
785
  }
641
786
  }
642
- //#endregion
643
787
  export { RealtimeController, RealtimeController as default };