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

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 (78) hide show
  1. package/README.md +79 -10
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +79 -12
  4. package/dist/nodefony/config/config.js +7 -0
  5. package/dist/nodefony/decorators/realtimeDecorators.js +125 -19
  6. package/dist/nodefony/interfaces/IRealtimeConnection.js +1 -0
  7. package/dist/nodefony/src/backplane/ClusterBackplane.js +32 -4
  8. package/dist/nodefony/src/backplane/RedisBackplane.js +143 -31
  9. package/dist/nodefony/src/backplane/controlOrder.js +57 -0
  10. package/dist/nodefony/src/backplane/envelope.js +113 -12
  11. package/dist/nodefony/src/cluster/ClusterProbeClient.js +2 -0
  12. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
  13. package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
  14. package/dist/nodefony/src/server/RealtimeConnection.js +110 -0
  15. package/dist/nodefony/src/server/RealtimeController.js +344 -85
  16. package/dist/nodefony/src/server/RealtimeHub.js +604 -61
  17. package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
  18. package/dist/nodefony/src/server/admissionBudget.js +176 -0
  19. package/dist/nodefony/src/server/channelPolicyRegistry.js +73 -0
  20. package/dist/nodefony/src/server/connectionAdmission.js +242 -0
  21. package/dist/nodefony/src/server/contractRoutes.js +85 -0
  22. package/dist/nodefony/src/server/deniedDetail.js +10 -3
  23. package/dist/nodefony/src/server/frameAdmission.js +168 -0
  24. package/dist/nodefony/src/server/subscribedAck.js +10 -1
  25. package/dist/nodefony/src/server/syslogUplink.js +3 -4
  26. package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
  27. package/dist/nodefony/src/service/RealtimeService.js +42 -1
  28. package/dist/nodefony/src/service/publicOrigin.js +40 -0
  29. package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
  30. package/dist/nodefony/testing/backplaneConformance.js +271 -0
  31. package/dist/nodefony/testing/index.js +21 -4
  32. package/dist/types/index.d.ts +34 -7
  33. package/dist/types/nodefony/config/config.d.ts +9 -0
  34. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +86 -6
  35. package/dist/types/nodefony/interfaces/IBackplane.d.ts +34 -50
  36. package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
  37. package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
  38. package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
  39. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
  40. package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
  41. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
  42. package/dist/types/nodefony/interfaces/index.d.ts +2 -1
  43. package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
  44. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +79 -16
  45. package/dist/types/nodefony/src/backplane/controlOrder.d.ts +52 -0
  46. package/dist/types/nodefony/src/backplane/envelope.d.ts +34 -0
  47. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +10 -0
  48. package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
  49. package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +17 -0
  50. package/dist/types/nodefony/src/server/RealtimeController.d.ts +93 -13
  51. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +321 -24
  52. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
  53. package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
  54. package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +34 -0
  55. package/dist/types/nodefony/src/server/connectionAdmission.d.ts +68 -0
  56. package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
  57. package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
  58. package/dist/types/nodefony/src/server/frameAdmission.d.ts +70 -0
  59. package/dist/types/nodefony/src/server/subscribedAck.d.ts +7 -0
  60. package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
  61. package/dist/types/nodefony/src/service/RealtimeService.d.ts +10 -2
  62. package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
  63. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
  64. package/dist/types/nodefony/testing/backplaneConformance.d.ts +66 -0
  65. package/dist/types/nodefony/testing/index.d.ts +17 -4
  66. package/docs/actions.md +442 -19
  67. package/docs/architecture.md +83 -8
  68. package/docs/cadence-et-contre-pression.md +87 -11
  69. package/docs/canaux-a-membres.md +267 -0
  70. package/docs/cas-d-usage.md +1702 -0
  71. package/docs/configuration.md +137 -17
  72. package/docs/cookbook-chat.md +333 -85
  73. package/docs/index.md +11 -1
  74. package/docs/observabilite.md +71 -14
  75. package/docs/protocole.md +133 -24
  76. package/docs/securite.md +294 -33
  77. package/docs/vocabulaire.md +13 -12
  78. package/package.json +14 -11
@@ -1,14 +1,20 @@
1
- import { isRecord } from "../guards.js";
2
- import { ANONYMOUS_REALTIME_TOKEN } from "./AnonymousRealtimeToken.js";
3
- import { getRealtimeHub } from "./RealtimeHub.js";
4
- import { DEFAULT_ACTION_POLICY, getRealtimeActions, getRealtimeChannelPolicies, getRealtimeChannels, getRealtimeInbound } from "../../decorators/realtimeDecorators.js";
5
1
  import { createSyslogUplinkHandler } from "./syslogUplink.js";
2
+ import { AUTHENTICATE_TIMED_OUT } from "./admissionBudget.js";
6
3
  import { WsConnectionTransport } from "../transport/WsConnectionTransport.js";
4
+ import { getRealtimeHub } from "./RealtimeHub.js";
5
+ import { ANONYMOUS_REALTIME_TOKEN } from "./AnonymousRealtimeToken.js";
6
+ import { DEFAULT_ACTION_POLICY, getRealtimeActions, getRealtimeChannels, getRealtimeInbound } from "../../decorators/realtimeDecorators.js";
7
7
  import { welcomeEnv } from "./welcomeEnv.js";
8
- import { subscribedAckFrame } from "./subscribedAck.js";
9
- import { deniedDetail } from "./deniedDetail.js";
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";
8
+ import { registerControllerPolicies } from "./channelPolicyRegistry.js";
9
+ import { welcomeIdentityOf } from "./welcomeIdentity.js";
10
+ import { isRecord } from "../guards.js";
11
+ import { imposedSubscribedAckFrame, subscribedAckFrame } from "./subscribedAck.js";
12
+ import { RealtimeConnection } from "./RealtimeConnection.js";
13
+ import { deniedDetail, explainsDenials } from "./deniedDetail.js";
14
+ import { ConnectionAdmission } from "./connectionAdmission.js";
15
+ import { admitByRule, gateFrame, releaseRuleAdmission, revokeAdmission } from "./frameAdmission.js";
16
+ import { BootConfigurationError, IDENTITY_DEAD_CLOSE_CODE, JsonRpcError, JsonRpcPeer, JsonRpcServerError, ORIGIN_REFUSED_CLOSE_CODE, PLATFORM_INBOUND, RequestContext, RpcEnvelope, RpcError, Scope, TRY_AGAIN_LATER_CLOSE_CODE, TransportState, identityHint, isPromise, negotiateFrameCodec, stripTrailingSlashes } from "nodefony";
17
+ import { isWebsocketContext, parseRequestTarget, readBackpressureOptions, toValidationFields } from "@nodefony/http";
12
18
  import { Controller, buildContextParamArgs, getParamArgsMeta, isFrameworkRouter } from "@nodefony/framework";
13
19
  // nodefony/src/server/RealtimeController.ts
14
20
  /**
@@ -79,6 +85,9 @@ function webSocketServerOf(ctx) {
79
85
  return server && "options" in server ? server : null;
80
86
  }
81
87
  let warnedUnenforcedPolicies = false;
88
+ /** Connexions déjà prévenues du flux brut refusé sous codec binaire — alloué au premier refus. */
89
+ let rawBinaryNoticed = null;
90
+ let warnedNoIdentity = false;
82
91
  /**
83
92
  * RealtimeController — base d'un endpoint WebSocket temps réel SERVEUR (JSON-RPC 2.0).
84
93
  *
@@ -124,6 +133,11 @@ var RealtimeController = class extends Controller {
124
133
  if (this.scope === "singleton") throw new BootConfigurationError(`Contrôleur temps réel « ${this.name} » déclaré @Scope("singleton") : un RealtimeController vit PAR CONNEXION — son pair JSON-RPC et notifyClient/requestClient désignent la connexion par l'instance. Retirer @Scope("singleton") : la portée d'un RealtimeController est request (une instance par connexion).`);
125
134
  }
126
135
  /**
136
+ * Le codec négocié au handshake (ADR-0022 §2), `null` hors WebSocket. Choisi par
137
+ * le constructeur : c'est la dernière fenêtre avant le `101`.
138
+ */
139
+ #codec;
140
+ /**
127
141
  * @param name - nom du service (celui de la classe, par convention).
128
142
  * @param context - le contexte de la connexion.
129
143
  * @throws BootConfigurationError si la classe se déclare singleton.
@@ -132,6 +146,28 @@ var RealtimeController = class extends Controller {
132
146
  new.target.assertScope();
133
147
  super(name, context);
134
148
  this.#connection = isWebsocketContext(context) ? context : null;
149
+ this.#codec = this.#connection !== null ? this.#negotiateCodec(this.#connection) : null;
150
+ }
151
+ /**
152
+ * Choisit le codec de la connexion d'après l'offre du handshake (ADR-0022 §2)
153
+ * et le confie à l'admission, qui le renverra dans le `101` (RFC 6455 §4.2.2 :
154
+ * le sous-protocole CHOISI, jamais le premier offert). Le contrôleur WS est
155
+ * construit AVANT l'acceptation (`HttpKernel.handleWebsocket`) : c'est la
156
+ * dernière fenêtre où ce choix atteint encore le client. Le client, lui, lit
157
+ * le codec dans `realtime:welcome`, jamais dans cet écho.
158
+ *
159
+ * Rien n'est retenu pour un client qui n'offre aucun sous-protocole — le cas
160
+ * de toute connexion d'avant les codecs. Un `forward` reconstruit une
161
+ * instance après l'acceptation : son choix, identique, est alors ignoré.
162
+ *
163
+ * @param ctx - le contexte de la connexion.
164
+ * @returns le codec à annoncer dans l'accueil, puis à employer.
165
+ */
166
+ #negotiateCodec(ctx) {
167
+ const offered = offeredSubprotocols(ctx.request?.headers["sec-websocket-protocol"]);
168
+ const codec = negotiateFrameCodec(getRealtimeHub().frameCodecs, offered);
169
+ if (offered.length > 0) ctx.admission?.selectSubprotocol(offered.includes(codec.protocol) ? codec.protocol : false);
170
+ return codec;
135
171
  }
136
172
  /**
137
173
  * La connexion WebSocket que sert CETTE instance — rétrécie UNE fois, ici,
@@ -188,15 +224,18 @@ var RealtimeController = class extends Controller {
188
224
  * surcharger ; défaut : **aucun** (sûr — un client ne peut rien pousser au serveur
189
225
  * tant qu'un canal n'est pas explicitement déclaré ici). Seam des backings entrants
190
226
  * (SIP, bridge). Cf {@link RealtimeInboundHandler} (params NON FIABLES).
227
+ *
228
+ * Typée par `Listen` (ce que le client émet, 3ᵉ générique) : un canal hors de
229
+ * la carte ne compile pas, et chaque handler reçoit la forme de SON canal.
191
230
  */
192
231
  realtimeInbound() {
193
232
  return {};
194
233
  }
195
234
  /**
196
- * Opt-in du **pont API** (« API souveraine », POC Ph.3) : `true` → la
235
+ * Opt-in du **pont API** (une action, deux transports) : `true` → la
197
236
  * connexion expose la méthode RPC `api.request {path}` qui re-route le path
198
237
  * porté par le message vers l'action controller correspondante — la MÊME
199
- * action que le GET REST (`socket.request("/nodefony/kernel/api/modules")`
238
+ * action que le GET REST (`socket.get("/nodefony/kernel/api/modules")`
200
239
  * côté client). Le pont n'atteint QUE les routes déclarant le transport
201
240
  * `WEBSOCKET` dans leurs `methods` (zéro bypass : une action dit à quels
202
241
  * transports elle répond). À surcharger ; défaut : `false` — aucune surface
@@ -239,7 +278,12 @@ var RealtimeController = class extends Controller {
239
278
  state?.transport.feed(message);
240
279
  return;
241
280
  }
242
- if (state !== void 0) this.receiveBinary(state.transport, message);
281
+ if (state === void 0) return;
282
+ if (state.binaryCodec !== null) {
283
+ state.transport.feed(message);
284
+ return;
285
+ }
286
+ this.receiveBinary(state.transport, message);
243
287
  }
244
288
  /**
245
289
  * Faute de câblage : `handleRealtime` reçu par une instance qui ne sert pas
@@ -292,11 +336,32 @@ var RealtimeController = class extends Controller {
292
336
  * client. Même contre-pression que les frames JSON-RPC.
293
337
  *
294
338
  * @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.
339
+ * Sur une connexion qui a négocié un codec BINAIRE, refusé (ADR-0022 §2) : le
340
+ * client y lirait ces octets comme une frame du codec. Des octets passent
341
+ * alors par un canal, avec ses portes.
342
+ *
343
+ * @returns `false` si le handshake n'est pas terminé, la connexion fermée, la
344
+ * trame jetée par la contre-pression, ou la connexion en codec binaire.
297
345
  */
298
346
  sendBinaryToClient(data) {
299
- return this.coldConnState()?.transport.sendBinary(data) ?? false;
347
+ const state = this.coldConnState();
348
+ if (state === void 0) return false;
349
+ if (state.binaryCodec !== null) {
350
+ this.noticeRawBinaryUnderCodec(state);
351
+ return false;
352
+ }
353
+ return state.transport.sendBinary(data);
354
+ }
355
+ /**
356
+ * Journalise, une fois par connexion, le flux brut refusé sous codec binaire.
357
+ *
358
+ * @param state - l'état de la connexion.
359
+ */
360
+ noticeRawBinaryUnderCodec(state) {
361
+ rawBinaryNoticed ??= /* @__PURE__ */ new WeakSet();
362
+ if (rawBinaryNoticed.has(state)) return;
363
+ rawBinaryNoticed.add(state);
364
+ this.log(`WS realtime : sendBinaryToClient refusé — la connexion parle ${state.binaryCodec?.protocol ?? "un codec binaire"}, ses trames binaires sont des frames du codec ; publier les octets sur un canal`, "WARNING");
300
365
  }
301
366
  /**
302
367
  * État realtime de CETTE connexion, `undefined` avant le welcome — pour les
@@ -320,6 +385,8 @@ var RealtimeController = class extends Controller {
320
385
  * Échecs (close WebSocket, codes plage applicative RFC 6455 §7.4.2) :
321
386
  * - Origin refusée → code 4003 (`forbidden`).
322
387
  * - `authenticate` throw → code 4001 (`unauthorized`).
388
+ * - `authenticate` sans réponse dans `authenticateTimeoutMs` → code 1013
389
+ * (`try again later`, ADR-0021 §S11) : le client recule puis revient.
323
390
  */
324
391
  async onHandshake(ctx) {
325
392
  if (ctx.__nfRealtime?.welcomed) return;
@@ -329,13 +396,19 @@ var RealtimeController = class extends Controller {
329
396
  const handshake = buildHandshakeFromContext(ctx);
330
397
  if (!hub.checkOrigin(handshake.origin)) {
331
398
  this.log(`WS realtime upgrade refused: Origin "${handshake.origin ?? "(missing)"}" not allowed`, "WARNING");
332
- conn.close(4003, "origin not allowed");
399
+ conn.close(ORIGIN_REFUSED_CLOSE_CODE, "origin not allowed");
333
400
  return;
334
401
  }
335
402
  const authenticator = hub.resolveAuthenticator(handshake);
336
403
  let token;
337
404
  if (authenticator?.supports(handshake)) try {
338
- token = await authenticator.authenticate(handshake);
405
+ const outcome = await hub.admissionBudget.authenticateWithin(authenticator.authenticate(handshake));
406
+ if (outcome === AUTHENTICATE_TIMED_OUT) {
407
+ if (hub.admissionBudget.shouldWarn("authenticate", performance.now())) this.log(`WS realtime auth (${authenticator.name}) sans réponse en ${hub.admissionBudget.authenticateTimeoutMs} ms (authenticateTimeoutMs) — connexion fermée en 1013, le client reviendra`, "WARNING");
408
+ conn.close(TRY_AGAIN_LATER_CLOSE_CODE, "try again later");
409
+ return;
410
+ }
411
+ token = outcome;
339
412
  authenticator.onSuccess?.(handshake, token);
340
413
  } catch (e) {
341
414
  const err = e instanceof Error ? e : new Error(String(e));
@@ -343,28 +416,29 @@ var RealtimeController = class extends Controller {
343
416
  authenticator.onFailure?.(handshake, err);
344
417
  } catch {}
345
418
  this.log(`WS realtime auth failed (${authenticator.name}): ${err.message}`, "WARNING");
346
- conn.close(4001, "unauthorized");
419
+ conn.close(IDENTITY_DEAD_CLOSE_CODE, "unauthorized");
347
420
  return;
348
421
  }
349
422
  else token = ANONYMOUS_REALTIME_TOKEN;
423
+ if (conn.readyState !== TransportState.OPEN) return;
350
424
  const transport = new WsConnectionTransport(conn, readBackpressureOptions(webSocketServerOf(ctx)));
425
+ const codecs = hub.frameCodecs;
426
+ const negotiated = this.#codec ?? codecs.text;
427
+ const binaryCodec = negotiated === codecs.binary ? negotiated : null;
428
+ const outbound = ctx.outboundTracer();
351
429
  const peerOptions = {
352
- send: (frame) => {
353
- let json;
354
- try {
355
- json = JSON.stringify(frame);
356
- } catch (e) {
357
- this.log(`WS realtime send: payload non sérialisable — ${e instanceof Error ? e.message : String(e)}`, "ERROR");
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"));
361
- }
362
- transport.send(json);
430
+ codec: codecs.text,
431
+ write: outbound === null ? (data) => transport.send(data) : (data, frame) => {
432
+ transport.send(data);
433
+ if (carriesId(frame)) outbound(data);
363
434
  },
364
435
  onNotification: (method, params) => this.onRealtimeNotification(ctx, method, params),
365
436
  onError: (context, err) => this.log(`${context}: ${err instanceof Error ? err.message : String(err)}`, "ERROR")
366
437
  };
367
- peerOptions.beforeDispatch = (frame) => hub.runAuthorizer(frame, peer);
438
+ peerOptions.beforeDispatch = (frame) => {
439
+ if (state.admitted?.[frame.method] === true) return true;
440
+ return gateFrame(state, frame, hub);
441
+ };
368
442
  peerOptions.onFrameAudit = (reason, frame, auditedPeer) => {
369
443
  if (reason !== "denied") return;
370
444
  this.log("WS realtime frame refused by authorizer", "WARNING");
@@ -373,11 +447,12 @@ var RealtimeController = class extends Controller {
373
447
  if (f.id !== void 0) return;
374
448
  const channel = f.method === "subscribe" ? channelParam(f.params) : typeof f.method === "string" ? f.method : void 0;
375
449
  if (channel !== void 0) {
376
- const hint = identityHint(this.kernel);
450
+ const unadmitted = f.method === channel && state.inbound !== null && Object.hasOwn(state.inbound, channel) && hub.resolveChannelAdmission(channel) !== null;
451
+ const hint = unadmitted ? null : identityHint(this.kernel);
377
452
  const denied = {
378
453
  channel,
379
454
  reason: "forbidden",
380
- ...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` + (hint === null ? "" : `. ${hint}`))
455
+ ...deniedDetail(this.kernel?.environment, unadmitted ? `« ${channel} » n'accepte d'entrée que des connexions que sa règle \`allow\` a admises : abonne-toi d'abord au canal (\`subscribe\`), puis pousse une fois l'abonnement acquitté` : `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` + (hint === null ? "" : `. ${hint}`))
381
456
  };
382
457
  auditedPeer.notify("realtime:denied", denied);
383
458
  }
@@ -387,9 +462,9 @@ var RealtimeController = class extends Controller {
387
462
  transport.onMessage((raw) => {
388
463
  let parsed;
389
464
  try {
390
- parsed = JSON.parse(raw);
465
+ parsed = peer.decode(raw);
391
466
  } catch {
392
- if (transport.discard("malformed") === 1) this.log("WS realtime : trame texte illisible (pas du JSON) reçue et écartée", "WARNING");
467
+ if (transport.discard("malformed") === 1) this.log(typeof raw === "string" ? "WS realtime : trame texte illisible (pas du JSON) reçue et écartée" : `WS realtime : trame binaire illisible pour ${peer.codec.protocol} reçue et écartée`, "WARNING");
393
468
  return;
394
469
  }
395
470
  peer.receive(parsed);
@@ -400,17 +475,14 @@ var RealtimeController = class extends Controller {
400
475
  };
401
476
  for (const [name, handler] of Object.entries(allActions)) {
402
477
  peer.register(name, handler);
403
- if (hub.resolveChannelPolicy(name) === null) hub.registerChannelPolicy(name, DEFAULT_ACTION_POLICY);
478
+ if (!hub.hasExactChannelPolicy(name)) hub.registerChannelPolicy(name, DEFAULT_ACTION_POLICY);
404
479
  }
405
480
  if (this.realtimeApiRequest()) peer.register("api.request", (params) => this.invokeApiRequest(ctx, params, peer));
406
481
  this._decoratedChannels = getRealtimeChannels(this);
407
- const channelPolicies = getRealtimeChannelPolicies(this);
408
- if (channelPolicies) {
409
- for (const [name, policy] of Object.entries(channelPolicies)) hub.registerChannelPolicy(name, policy);
410
- if (hub.hasUnenforcedChannelPolicies() && !warnedUnenforcedPolicies) {
411
- warnedUnenforcedPolicies = true;
412
- 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");
413
- }
482
+ registerControllerPolicies(hub, this.constructor, null);
483
+ if (!warnedUnenforcedPolicies && hub.hasUnenforcedChannelPolicies()) {
484
+ warnedUnenforcedPolicies = true;
485
+ 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");
414
486
  }
415
487
  const noticeSyslog = this.syslog;
416
488
  const noticeName = this.name;
@@ -447,14 +519,25 @@ var RealtimeController = class extends Controller {
447
519
  welcomed: true,
448
520
  peer,
449
521
  transport,
522
+ binaryCodec,
450
523
  channels: /* @__PURE__ */ new Map(),
451
- inbound
524
+ inbound,
525
+ admission: null,
526
+ admitted: null,
527
+ retained: null,
528
+ connection: null
452
529
  };
453
530
  ctx.__nfRealtime = state;
454
531
  ctx.once("onFinish", () => {
455
532
  const liveHub = getRealtimeHub();
456
533
  for (const [channel, sink] of state.channels) liveHub.unsubscribe(channel, sink);
457
534
  state.channels.clear();
535
+ state.connection?.close();
536
+ if (state.admission !== null) {
537
+ state.admission.close();
538
+ state.admission = null;
539
+ }
540
+ hub.untrackRetainedVerdicts(state);
458
541
  liveHub.unregisterConnection(transport);
459
542
  if (revocable) liveHub.unregisterRevocable(revocable);
460
543
  transport.fireClose();
@@ -470,15 +553,15 @@ var RealtimeController = class extends Controller {
470
553
  methods: peer.methods,
471
554
  subscriptionAcks: true,
472
555
  ...welcomeEnv(env),
473
- identity: {
474
- type: token.type,
475
- authenticated: token.isAuthenticated(),
476
- userIdentifier: token.getUserIdentifier(),
477
- roles: token.getRoles(),
478
- scopes: token.getScopes()
479
- }
556
+ identity: welcomeIdentityOf(token)
480
557
  };
558
+ if (binaryCodec !== null) welcome.codec = binaryCodec.protocol;
559
+ else if (peer.codec.bytesMarker !== void 0) welcome.bytes = peer.codec.bytesMarker;
481
560
  peer.notify("realtime:welcome", welcome);
561
+ if (binaryCodec !== null) {
562
+ peer.codec = binaryCodec;
563
+ ctx.frameCodec = binaryCodec;
564
+ }
482
565
  this.log("WS realtime client connected", "INFO");
483
566
  }
484
567
  /**
@@ -498,7 +581,7 @@ var RealtimeController = class extends Controller {
498
581
  const handler = state?.inbound?.[method];
499
582
  if (handler) {
500
583
  getRealtimeHub().recordInbound();
501
- handler(params, (payload) => state.peer.notify(method, payload));
584
+ handler(params, (payload) => state.peer.notify(method, payload), state.connection ?? this.connectionOf(ctx, state));
502
585
  }
503
586
  }
504
587
  /**
@@ -509,9 +592,109 @@ var RealtimeController = class extends Controller {
509
592
  * par connexion).
510
593
  */
511
594
  acknowledgeSubscription(state, channel) {
595
+ const connection = state.connection;
596
+ if (connection?.isImposing(channel) === true) {
597
+ state.transport.send(imposedSubscribedAckFrame(channel));
598
+ connection.settleImposed(channel, true);
599
+ return;
600
+ }
512
601
  state.transport.send(subscribedAckFrame(channel));
513
602
  }
514
- /** Abonne la connexion à un canal via le hub partagé (idempotent par connexion). */
603
+ /**
604
+ * Poignée publique de la connexion, créée au premier besoin (abonnement, trame entrante) puis gardée sur son état.
605
+ *
606
+ * Cold path : une fois par connexion. Les deux fermetures la relient à CE
607
+ * contrôleur, dont les fabriques servent ses canaux.
608
+ */
609
+ connectionOf(ctx, state) {
610
+ let connection = state.connection;
611
+ if (connection === null) {
612
+ const peer = state.peer;
613
+ connection = new RealtimeConnection({
614
+ impose: (channel) => this.imposeChannel(ctx, channel),
615
+ end: (channel, reason) => this.endChannel(ctx, channel, reason)
616
+ }, () => getRealtimeHub().getTokenForPeer(peer).getIdentity?.().id ?? null);
617
+ state.connection = connection;
618
+ }
619
+ return connection;
620
+ }
621
+ /**
622
+ * Abonnement imposé par le serveur (`RealtimeHub.subscribeConnection`) — la politique fixe par le verrou des trames, puis le chemin d'un `subscribe` du client, `allow` compris (ADR-0022 §6 décision 2).
623
+ *
624
+ * Le verdict est rendu à l'appelant par `RealtimeConnection.settleImposed` :
625
+ * à l'acquittement ({@link acknowledgeSubscription}) ou au refus
626
+ * ({@link refuseChannel}) — qui n'envoie alors rien au client, qui n'a rien
627
+ * demandé.
628
+ */
629
+ imposeChannel(ctx, channel) {
630
+ const state = ctx.__nfRealtime;
631
+ const connection = state?.connection;
632
+ if (!state || !connection) return;
633
+ if (state.channels.has(channel)) {
634
+ connection.settleImposed(channel, true);
635
+ return;
636
+ }
637
+ const frame = {
638
+ method: "subscribe",
639
+ params: { channel }
640
+ };
641
+ if (!getRealtimeHub().runAuthorizer(frame, state.peer)) {
642
+ connection.settleImposed(channel, false);
643
+ return;
644
+ }
645
+ this.startChannel(ctx, channel);
646
+ }
647
+ /**
648
+ * Met fin à l'abonnement de la connexion à un canal, sur décision du serveur, et le lui notifie (`realtime:closed`).
649
+ *
650
+ * Même chemin que l'`unsubscribe` du client (hub ET état de la connexion),
651
+ * jamais un second : retirer le seul sink du hub laisserait l'entrée de la
652
+ * connexion, et le `subscribe` suivant serait acquitté sans rien rouvrir.
653
+ * Retire aussi la marque « admise » et le verdict retenu de ce canal.
654
+ *
655
+ * @returns `false` si la connexion n'est pas abonnée à ce canal.
656
+ */
657
+ endChannel(ctx, channel, reason) {
658
+ const state = ctx.__nfRealtime;
659
+ if (!state || !this.leaveChannel(state, channel)) return false;
660
+ revokeAdmission(state, channel);
661
+ const closed = {
662
+ channel,
663
+ reason
664
+ };
665
+ state.peer.notify("realtime:closed", closed);
666
+ this.log(`WS ${reason} → ${channel}`, "DEBUG");
667
+ return true;
668
+ }
669
+ /**
670
+ * Retire la connexion d'un canal : hub, état de la connexion, admission de sa règle — le chemin UNIQUE d'une sortie, demandée par le client ou décidée par le serveur.
671
+ *
672
+ * @returns `false` si la connexion n'était pas abonnée.
673
+ */
674
+ leaveChannel(state, channel) {
675
+ const sink = state.channels.get(channel);
676
+ if (sink === void 0) return false;
677
+ getRealtimeHub().unsubscribe(channel, sink);
678
+ state.channels.delete(channel);
679
+ releaseRuleAdmission(state, channel);
680
+ return true;
681
+ }
682
+ /**
683
+ * Refus d'un abonnement : rendu à l'appelant serveur s'il s'agit d'une imposition, au client sinon — et aux deux quand le client avait aussi demandé ce canal.
684
+ */
685
+ refuseChannel(state, channel, reason, detail, retryAfterMs) {
686
+ const connection = state.connection;
687
+ if (connection !== null && !connection.settleImposed(channel, false)) return;
688
+ this.denyChannel(state, channel, reason, detail, retryAfterMs);
689
+ }
690
+ /**
691
+ * Abonne la connexion à un canal via le hub partagé (idempotent par connexion).
692
+ *
693
+ * Un canal dont la politique déclare `allow` passe d'abord par la file
694
+ * d'admission de la connexion ; tout autre canal s'ouvre tout de suite, sans
695
+ * rien lire de plus qu'aujourd'hui (une recherche de politique au `subscribe`,
696
+ * trame de contrôle rare).
697
+ */
515
698
  startChannel(ctx, channel) {
516
699
  if (!channel) return;
517
700
  const state = ctx.__nfRealtime;
@@ -520,20 +703,84 @@ var RealtimeController = class extends Controller {
520
703
  this.acknowledgeSubscription(state, channel);
521
704
  return;
522
705
  }
523
- const cap = getRealtimeHub().maxChannelsPerConnection;
524
- if (cap !== null && state.channels.size >= cap) {
525
- const denied = {
526
- channel,
527
- reason: "limit",
528
- ...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`)
529
- };
530
- state.peer.notify("realtime:denied", denied);
706
+ const admission = state.admission;
707
+ if (admission?.revive(channel) === true) {
708
+ state.connection?.markClientAsked(channel);
709
+ return;
710
+ }
711
+ const hub = getRealtimeHub();
712
+ const cap = hub.maxChannelsPerConnection;
713
+ if (cap !== null && state.channels.size + (admission === null ? 0 : admission.unsettled) >= cap) {
714
+ this.refuseChannel(state, channel, "limit", `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`);
531
715
  this.log(`WS subscribe refusé (cap ${cap} canaux/connexion atteint) → ${channel}`, "DEBUG");
532
716
  return;
533
717
  }
534
- const sink = (payload, serialized) => {
535
- if (serialized !== void 0) state.transport.send(serialized);
536
- else state.peer.notify(channel, payload);
718
+ const rule = hub.resolveChannelAdmission(channel);
719
+ if (rule === null) {
720
+ this.openChannel(ctx, state, channel);
721
+ return;
722
+ }
723
+ this.admitChannel(ctx, state, channel, rule, hub);
724
+ }
725
+ /**
726
+ * Soumet l'abonnement à la règle `allow` du canal — APRÈS le verrou des trames, qui a déjà jugé `authenticated`/`roles`/`scopes` (ADR-0022 §6, décision 1 ter, moment 2).
727
+ *
728
+ * `allow` s'applique même sans module de sécurité : il ne fait que retirer
729
+ * des abonnés, jamais en ajouter — l'ignorer ouvrirait `user:<id>` à tous.
730
+ * La file de la connexion n'est créée qu'à son premier canal à `allow`.
731
+ */
732
+ admitChannel(ctx, state, channel, rule, hub) {
733
+ let admission = state.admission;
734
+ if (admission === null) {
735
+ const who = hub.getTokenForPeer(state.peer).getIdentity?.();
736
+ if (who === void 0) {
737
+ this.refuseChannel(state, channel, "forbidden", "l'authentificateur de cette connexion ne produit pas d'identité d'admission (`getIdentity`) : aucun canal à `allow` ne lui est ouvert");
738
+ this.log(`WS subscribe refusé : jeton sans getIdentity sur un canal à allow → ${channel}`, warnedNoIdentity ? "DEBUG" : "WARNING");
739
+ warnedNoIdentity = true;
740
+ return;
741
+ }
742
+ admission = new ConnectionAdmission({
743
+ open: (ch) => {
744
+ this.openChannel(ctx, state, ch);
745
+ if (state.channels.has(ch)) admitByRule(state, ch);
746
+ },
747
+ deny: (ch, reason, detail, retryAfterMs) => {
748
+ this.refuseChannel(state, ch, reason, detail, retryAfterMs);
749
+ this.log(`WS subscribe refusé (${reason}) → ${ch}`, "DEBUG");
750
+ },
751
+ warn: (message) => this.log(message, "WARNING")
752
+ }, hub.admissionBudget, who, explainsDenials(this.kernel?.environment));
753
+ state.admission = admission;
754
+ }
755
+ admission.request(channel, rule);
756
+ }
757
+ /**
758
+ * Rend un refus d'abonnement observable (`realtime:denied`) — le détail ne part qu'hors production.
759
+ *
760
+ * @param detail - phrase pour le développeur ; `undefined` quand l'appelant
761
+ * ne l'a pas rédigée (production).
762
+ */
763
+ denyChannel(state, channel, reason, detail, retryAfterMs) {
764
+ const denied = {
765
+ channel,
766
+ reason,
767
+ ...detail === void 0 ? {} : deniedDetail(this.kernel?.environment, detail),
768
+ ...retryAfterMs === void 0 ? {} : { retryAfterMs }
769
+ };
770
+ state.peer.notify("realtime:denied", denied);
771
+ }
772
+ /** Ouvre l'abonnement au hub et l'acquitte — canal sans `allow`, ou admis par sa règle. */
773
+ openChannel(ctx, state, channel) {
774
+ const sink = (payload, frame) => {
775
+ if (frame !== void 0) {
776
+ const data = frame.encodeFor(state.peer.codec);
777
+ if (data !== void 0) {
778
+ state.transport.send(data);
779
+ return;
780
+ }
781
+ if (!frame.claimFailure(state.peer.codec)) return;
782
+ }
783
+ state.peer.notify(channel, payload);
537
784
  };
538
785
  let servedByPattern = false;
539
786
  const isServedByPattern = () => servedByPattern;
@@ -543,7 +790,7 @@ var RealtimeController = class extends Controller {
543
790
  const dispose = this.createRealtimeChannel(ch, publish);
544
791
  if (dispose !== null) servedByPattern = true;
545
792
  return dispose;
546
- }, (payload) => JSON.stringify(JsonRpcPeer.buildNotification(channel, payload)))) {
793
+ }, (payload) => JsonRpcPeer.buildNotification(channel, payload), this.connectionOf(ctx, state))) {
547
794
  state.channels.set(channel, sink);
548
795
  if (isServedByPattern()) getRealtimeHub().noticeUnguardedDynamicChannel(channel);
549
796
  this.acknowledgeSubscription(state, channel);
@@ -551,12 +798,7 @@ var RealtimeController = class extends Controller {
551
798
  return;
552
799
  }
553
800
  const floor = getRealtimeHub().isClosedBySystemFloor(channel);
554
- const denied = {
555
- channel,
556
- reason: floor ? "forbidden" : "unknown",
557
- ...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)`)
558
- };
559
- state.peer.notify("realtime:denied", denied);
801
+ this.refuseChannel(state, channel, floor ? "forbidden" : "unknown", 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)`);
560
802
  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");
561
803
  }
562
804
  /**
@@ -602,7 +844,7 @@ var RealtimeController = class extends Controller {
602
844
  };
603
845
  }
604
846
  /**
605
- * Handler de la méthode RPC `api.request {path}` — le **pont API souverain**
847
+ * Handler de la méthode RPC `api.request {path}` — le **pont socket**
606
848
  * (POC Ph.3) : résout le path porté par le message via le Router
607
849
  * (`cleanPathOverride` — l'URL de la connexion n'est jamais mutée) puis
608
850
  * exécute l'action SANS la rendre (`executeAction`) : la valeur nue est
@@ -628,8 +870,9 @@ var RealtimeController = class extends Controller {
628
870
  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);
629
871
  const router = ctx.router;
630
872
  if (!isFrameworkRouter(router)) throw new RpcError("api.request: router indisponible", JsonRpcServerError.DEFAULT, { status: 500 });
631
- const qIdx = path.indexOf("?");
632
- const pathname = qIdx === -1 ? path : path.slice(0, qIdx);
873
+ const target = parseRequestTarget(path);
874
+ if (target === null) throw new RpcError("api.request: params.path invalide", JsonRpcError.INVALID_PARAMS);
875
+ const pathname = stripTrailingSlashes(target.pathname);
633
876
  const frame = ctx.beginFrame(method, path);
634
877
  let store = null;
635
878
  try {
@@ -661,8 +904,8 @@ var RealtimeController = class extends Controller {
661
904
  if (!resolver.resolve) throw new RpcError(`api.request: not found ${pathname}`, JsonRpcServerError.DEFAULT, { status: 404 });
662
905
  if (frame) frame.resolver = resolver;
663
906
  frameStore.resolver = resolver;
664
- if (qIdx !== -1 && qIdx < path.length - 1) {
665
- const sp = new URLSearchParams(path.slice(qIdx + 1));
907
+ if (target.search.length > 1) {
908
+ const sp = new URLSearchParams(target.search.slice(1));
666
909
  const query = {};
667
910
  for (const [k, v] of sp) {
668
911
  const prev = query[k];
@@ -713,11 +956,11 @@ var RealtimeController = class extends Controller {
713
956
  }
714
957
  });
715
958
  frame?.finish(200);
716
- return frame ? new RpcEnvelope(result, { requestId: frame.requestId }) : result;
959
+ return frame?.profiled ? new RpcEnvelope(result, { requestId: frame.requestId }) : result;
717
960
  } catch (e) {
718
961
  const status = httpStatusOfFrameError(e);
719
962
  frame?.finish(status ?? 500, e);
720
- throw toFrameRpcError(e, status, frame?.requestId);
963
+ throw toFrameRpcError(e, status, frame?.profiled ? frame.requestId : void 0);
721
964
  } finally {
722
965
  if (store !== null) RequestContext.release(store);
723
966
  ctx.collectFrame(frame);
@@ -728,15 +971,23 @@ var RealtimeController = class extends Controller {
728
971
  if (!channel) return;
729
972
  const state = ctx.__nfRealtime;
730
973
  if (!state) return;
731
- const sink = state.channels.get(channel);
732
- if (sink) {
733
- getRealtimeHub().unsubscribe(channel, sink);
734
- state.channels.delete(channel);
974
+ if (this.leaveChannel(state, channel)) {
735
975
  this.log(`WS unsubscribe → ${channel}`, "DEBUG");
976
+ return;
736
977
  }
978
+ state.admission?.cancel(channel);
979
+ state.connection?.settleImposed(channel, false);
737
980
  }
738
981
  };
739
982
  /**
983
+ * Une frame JSON-RPC porte-t-elle un `id` — réponse ou requête, jamais une
984
+ * notification ? Même règle que `isNotification` du cœur (`id` absent), sur une
985
+ * frame non typée.
986
+ */
987
+ function carriesId(frame) {
988
+ return typeof frame === "object" && frame !== null && Reflect.get(frame, "id") !== void 0;
989
+ }
990
+ /**
740
991
  * Construit un {@link IRealtimeHandshake} immuable depuis le `WebsocketContext`
741
992
  * de @nodefony/http — DTO neutre passé aux authenticators réseau (zéro
742
993
  * dépendance security/http côté contrat). Cold path (1× par upgrade).
@@ -747,6 +998,18 @@ var RealtimeController = class extends Controller {
747
998
  * - `protocols` : la liste des sous-protocoles annoncés (`Sec-WebSocket-Protocol`,
748
999
  * csv séparé virgule selon RFC 6455 §4.1).
749
1000
  */
1001
+ /**
1002
+ * Les sous-protocoles offerts au handshake (`Sec-WebSocket-Protocol`, liste
1003
+ * séparée par des virgules, RFC 6455 §4.1), dans l'ordre de préférence du
1004
+ * client — la lecture unique du DTO du handshake et de la négociation du codec.
1005
+ *
1006
+ * @param raw - l'en-tête tel que Node le rend : chaîne, liste, ou absent.
1007
+ * @returns les noms, sans espaces ni entrées vides ; `[]` sans en-tête.
1008
+ */
1009
+ function offeredSubprotocols(raw) {
1010
+ if (raw === void 0) return [];
1011
+ return (typeof raw === "string" ? raw.split(",") : raw.flatMap((p) => p.split(","))).map((x) => x.trim()).filter((x) => x.length > 0);
1012
+ }
750
1013
  function buildHandshakeFromContext(ctx) {
751
1014
  const headers = ctx.request?.headers ?? {};
752
1015
  const cookies = /* @__PURE__ */ new Map();
@@ -755,11 +1018,7 @@ function buildHandshakeFromContext(ctx) {
755
1018
  const c = rawCookies[name];
756
1019
  if (c && typeof c.value === "string") cookies.set(name, c.value);
757
1020
  }
758
- const rawProto = headers["sec-websocket-protocol"];
759
- let protocols;
760
- if (Array.isArray(rawProto)) protocols = rawProto.flatMap((p) => p.split(",").map((x) => x.trim()));
761
- else if (typeof rawProto === "string") protocols = rawProto.split(",").map((x) => x.trim()).filter(Boolean);
762
- else protocols = [];
1021
+ const protocols = offeredSubprotocols(headers["sec-websocket-protocol"]);
763
1022
  return {
764
1023
  headers,
765
1024
  cookies,