@nodefony/realtime 10.0.0-beta.5 → 10.0.0-beta.7

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 (96) hide show
  1. package/README.md +55 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +91 -12
  4. package/dist/nodefony/config/config.js +7 -0
  5. package/dist/nodefony/decorators/realtimeDecorators.js +341 -43
  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 +146 -41
  9. package/dist/nodefony/src/backplane/controlOrder.js +86 -0
  10. package/dist/nodefony/src/backplane/envelope.js +505 -27
  11. package/dist/nodefony/src/backplane/ingress.js +93 -0
  12. package/dist/nodefony/src/backplane/presenceMessage.js +205 -0
  13. package/dist/nodefony/src/backplane/publishQueue.js +97 -3
  14. package/dist/nodefony/src/cluster/ClusterProbeClient.js +2 -0
  15. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
  16. package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
  17. package/dist/nodefony/src/server/RealtimeConnection.js +135 -0
  18. package/dist/nodefony/src/server/RealtimeController.js +409 -107
  19. package/dist/nodefony/src/server/RealtimeHub.js +1158 -111
  20. package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
  21. package/dist/nodefony/src/server/admissionBudget.js +176 -0
  22. package/dist/nodefony/src/server/channelPolicyRegistry.js +95 -0
  23. package/dist/nodefony/src/server/channelPolicyResolution.js +191 -0
  24. package/dist/nodefony/src/server/channelPresence.js +488 -0
  25. package/dist/nodefony/src/server/connectionAdmission.js +242 -0
  26. package/dist/nodefony/src/server/contractRoutes.js +75 -0
  27. package/dist/nodefony/src/server/deniedDetail.js +10 -3
  28. package/dist/nodefony/src/server/frameAdmission.js +160 -0
  29. package/dist/nodefony/src/server/presenceBus.js +486 -0
  30. package/dist/nodefony/src/server/realtimeShutdown.js +31 -0
  31. package/dist/nodefony/src/server/remotePresence.js +541 -0
  32. package/dist/nodefony/src/server/subscribedAck.js +13 -1
  33. package/dist/nodefony/src/server/syslogUplink.js +3 -4
  34. package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
  35. package/dist/nodefony/src/service/RealtimeService.js +51 -1
  36. package/dist/nodefony/src/service/publicOrigin.js +40 -0
  37. package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
  38. package/dist/nodefony/testing/backplaneConformance.js +347 -0
  39. package/dist/nodefony/testing/index.js +43 -12
  40. package/dist/nodefony/testing/presenceClient.js +54 -0
  41. package/dist/types/index.d.ts +39 -7
  42. package/dist/types/nodefony/config/config.d.ts +9 -0
  43. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +198 -17
  44. package/dist/types/nodefony/interfaces/IBackplane.d.ts +46 -51
  45. package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
  46. package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
  47. package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
  48. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
  49. package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
  50. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
  51. package/dist/types/nodefony/interfaces/index.d.ts +2 -1
  52. package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
  53. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +89 -18
  54. package/dist/types/nodefony/src/backplane/controlOrder.d.ts +75 -0
  55. package/dist/types/nodefony/src/backplane/envelope.d.ts +77 -0
  56. package/dist/types/nodefony/src/backplane/ingress.d.ts +48 -0
  57. package/dist/types/nodefony/src/backplane/presenceMessage.d.ts +150 -0
  58. package/dist/types/nodefony/src/backplane/publishQueue.d.ts +25 -1
  59. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +10 -0
  60. package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
  61. package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +20 -0
  62. package/dist/types/nodefony/src/server/RealtimeController.d.ts +110 -12
  63. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +448 -42
  64. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
  65. package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
  66. package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +41 -0
  67. package/dist/types/nodefony/src/server/channelPolicyResolution.d.ts +112 -0
  68. package/dist/types/nodefony/src/server/channelPresence.d.ts +155 -0
  69. package/dist/types/nodefony/src/server/connectionAdmission.d.ts +68 -0
  70. package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
  71. package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
  72. package/dist/types/nodefony/src/server/frameAdmission.d.ts +66 -0
  73. package/dist/types/nodefony/src/server/presenceBus.d.ts +112 -0
  74. package/dist/types/nodefony/src/server/realtimeShutdown.d.ts +21 -0
  75. package/dist/types/nodefony/src/server/remotePresence.d.ts +151 -0
  76. package/dist/types/nodefony/src/server/subscribedAck.d.ts +10 -0
  77. package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
  78. package/dist/types/nodefony/src/service/RealtimeService.d.ts +17 -2
  79. package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
  80. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
  81. package/dist/types/nodefony/testing/backplaneConformance.d.ts +78 -0
  82. package/dist/types/nodefony/testing/index.d.ts +13 -2
  83. package/dist/types/nodefony/testing/presenceClient.d.ts +20 -0
  84. package/docs/actions.md +420 -18
  85. package/docs/architecture.md +101 -11
  86. package/docs/cadence-et-contre-pression.md +87 -11
  87. package/docs/canaux-a-membres.md +277 -0
  88. package/docs/cas-d-usage.md +1703 -0
  89. package/docs/configuration.md +175 -19
  90. package/docs/cookbook-chat.md +701 -224
  91. package/docs/index.md +11 -1
  92. package/docs/observabilite.md +97 -21
  93. package/docs/protocole.md +142 -33
  94. package/docs/securite.md +347 -41
  95. package/docs/vocabulaire.md +13 -12
  96. package/package.json +16 -13
@@ -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 { getRealtimeActions, getRealtimeChannels, getRealtimeInbound, patternChannelFactory } 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 { 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
  /**
@@ -17,6 +23,12 @@ import { Controller, buildContextParamArgs, getParamArgsMeta, isFrameworkRouter
17
23
  */
18
24
  const NO_ROUTE_PARAMS = Object.freeze({});
19
25
  /**
26
+ * Préfixe de la ligne `ERROR` écrite quand la poignée de main lève (socket fermée
27
+ * en 1011). Le harnais de test la relit pour citer l'exception : le client, lui,
28
+ * ne reçoit que « internal error » — rien d'interne ne sort par la socket.
29
+ */
30
+ const HANDSHAKE_FAILED_LOG = "WS realtime handshake failed";
31
+ /**
20
32
  * Statut HTTP-équivalent d'une erreur survenue pendant une invocation du pont.
21
33
  *
22
34
  * UNE seule lecture pour les trois sources possibles — la `RpcError` déjà mappée
@@ -38,6 +50,24 @@ function httpStatusOfFrameError(e) {
38
50
  return typeof code === "number" && code >= 400 && code <= 599 ? code : null;
39
51
  }
40
52
  /**
53
+ * L'attente d'un `Retry-After` (RFC 9110 §10.2.3), en secondes : un délai
54
+ * (`120`) tel quel, une date HTTP ramenée à l'écart avec `now` (jamais
55
+ * négatif). Une valeur illisible, ou absente, ne dit rien : `null`.
56
+ *
57
+ * @param value - l'en-tête posé par l'action, s'il l'a été
58
+ * @param now - l'instant de référence d'une date HTTP, en ms (défaut : l'horloge,
59
+ * lue seulement pour une date)
60
+ * @returns les secondes à attendre, ou `null`
61
+ */
62
+ function retryAfterSeconds(value, now) {
63
+ if (value === void 0) return null;
64
+ const text = value.trim();
65
+ if (/^\d+$/u.test(text)) return Number(text);
66
+ if (!/[A-Za-z]/u.test(text)) return null;
67
+ const at = Date.parse(text);
68
+ return Number.isNaN(at) ? null : Math.max(0, Math.ceil((at - (now ?? Date.now())) / 1e3));
69
+ }
70
+ /**
41
71
  * Erreur renvoyée au pair pour une invocation en échec.
42
72
  *
43
73
  * Un refus est une RÉPONSE (statut exposé, symétrie d'un `fetch`) ; une panne
@@ -45,18 +75,18 @@ function httpStatusOfFrameError(e) {
45
75
  * du profil est joint aux refus : le client peut alors radiographier son propre
46
76
  * refus (le 403 `@IsGranted` devient pédagogique au lieu d'être un mur).
47
77
  */
48
- function toFrameRpcError(e, status, requestId) {
78
+ function toFrameRpcError(e, status, requestId, retryAfter = null) {
49
79
  if (e instanceof RpcError) {
50
- if (!requestId) return e;
51
- const data = isRecord(e.data) ? {
52
- ...e.data,
53
- requestId
54
- } : { requestId };
80
+ if (!requestId && retryAfter === null) return e;
81
+ const data = isRecord(e.data) ? { ...e.data } : {};
82
+ if (requestId) data.requestId = requestId;
83
+ if (retryAfter !== null) data.retryAfter = retryAfter;
55
84
  return new RpcError(e.message, e.code, data);
56
85
  }
57
86
  if (status !== null) {
58
87
  const data = { status };
59
88
  if (requestId) data.requestId = requestId;
89
+ if (retryAfter !== null) data.retryAfter = retryAfter;
60
90
  if (status === 422 && e instanceof Error) {
61
91
  const fields = toValidationFields(e);
62
92
  if (fields !== null) data.fields = fields;
@@ -79,6 +109,9 @@ function webSocketServerOf(ctx) {
79
109
  return server && "options" in server ? server : null;
80
110
  }
81
111
  let warnedUnenforcedPolicies = false;
112
+ /** Connexions déjà prévenues du flux brut refusé sous codec binaire — alloué au premier refus. */
113
+ let rawBinaryNoticed = null;
114
+ let warnedNoIdentity = false;
82
115
  /**
83
116
  * RealtimeController — base d'un endpoint WebSocket temps réel SERVEUR (JSON-RPC 2.0).
84
117
  *
@@ -124,6 +157,11 @@ var RealtimeController = class extends Controller {
124
157
  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
158
  }
126
159
  /**
160
+ * Le codec négocié au handshake (ADR-0022 §2), `null` hors WebSocket. Choisi par
161
+ * le constructeur : c'est la dernière fenêtre avant le `101`.
162
+ */
163
+ #codec;
164
+ /**
127
165
  * @param name - nom du service (celui de la classe, par convention).
128
166
  * @param context - le contexte de la connexion.
129
167
  * @throws BootConfigurationError si la classe se déclare singleton.
@@ -132,6 +170,28 @@ var RealtimeController = class extends Controller {
132
170
  new.target.assertScope();
133
171
  super(name, context);
134
172
  this.#connection = isWebsocketContext(context) ? context : null;
173
+ this.#codec = this.#connection !== null ? this.#negotiateCodec(this.#connection) : null;
174
+ }
175
+ /**
176
+ * Choisit le codec de la connexion d'après l'offre du handshake (ADR-0022 §2)
177
+ * et le confie à l'admission, qui le renverra dans le `101` (RFC 6455 §4.2.2 :
178
+ * le sous-protocole CHOISI, jamais le premier offert). Le contrôleur WS est
179
+ * construit AVANT l'acceptation (`HttpKernel.handleWebsocket`) : c'est la
180
+ * dernière fenêtre où ce choix atteint encore le client. Le client, lui, lit
181
+ * le codec dans `realtime:welcome`, jamais dans cet écho.
182
+ *
183
+ * Rien n'est retenu pour un client qui n'offre aucun sous-protocole — le cas
184
+ * de toute connexion d'avant les codecs. Un `forward` reconstruit une
185
+ * instance après l'acceptation : son choix, identique, est alors ignoré.
186
+ *
187
+ * @param ctx - le contexte de la connexion.
188
+ * @returns le codec à annoncer dans l'accueil, puis à employer.
189
+ */
190
+ #negotiateCodec(ctx) {
191
+ const offered = offeredSubprotocols(ctx.request?.headers["sec-websocket-protocol"]);
192
+ const codec = negotiateFrameCodec(getRealtimeHub().frameCodecs, offered);
193
+ if (offered.length > 0) ctx.admission?.selectSubprotocol(offered.includes(codec.protocol) ? codec.protocol : false);
194
+ return codec;
135
195
  }
136
196
  /**
137
197
  * La connexion WebSocket que sert CETTE instance — rétrécie UNE fois, ici,
@@ -188,6 +248,9 @@ var RealtimeController = class extends Controller {
188
248
  * surcharger ; défaut : **aucun** (sûr — un client ne peut rien pousser au serveur
189
249
  * tant qu'un canal n'est pas explicitement déclaré ici). Seam des backings entrants
190
250
  * (SIP, bridge). Cf {@link RealtimeInboundHandler} (params NON FIABLES).
251
+ *
252
+ * Typée par `Listen` (ce que le client émet, 3ᵉ générique) : un canal hors de
253
+ * la carte ne compile pas, et chaque handler reçoit la forme de SON canal.
191
254
  */
192
255
  realtimeInbound() {
193
256
  return {};
@@ -196,7 +259,7 @@ var RealtimeController = class extends Controller {
196
259
  * Opt-in du **pont API** (une action, deux transports) : `true` → la
197
260
  * connexion expose la méthode RPC `api.request {path}` qui re-route le path
198
261
  * porté par le message vers l'action controller correspondante — la MÊME
199
- * action que le GET REST (`socket.request("/nodefony/kernel/api/modules")`
262
+ * action que le GET REST (`socket.get("/nodefony/kernel/api/modules")`
200
263
  * côté client). Le pont n'atteint QUE les routes déclarant le transport
201
264
  * `WEBSOCKET` dans leurs `methods` (zéro bypass : une action dit à quels
202
265
  * transports elle répond). À surcharger ; défaut : `false` — aucune surface
@@ -229,7 +292,7 @@ var RealtimeController = class extends Controller {
229
292
  if (ctx === null) throw this.outsideWebsocketError();
230
293
  if (message == null) {
231
294
  this.onHandshake(ctx).catch((e) => {
232
- this.log(`WS realtime handshake failed: ${e instanceof Error ? e.message : String(e)}`, "ERROR");
295
+ this.log(`${HANDSHAKE_FAILED_LOG}: ${e instanceof Error ? e.message : String(e)}`, "ERROR");
233
296
  ctx.connection?.close(1011, "internal error");
234
297
  });
235
298
  return;
@@ -239,7 +302,12 @@ var RealtimeController = class extends Controller {
239
302
  state?.transport.feed(message);
240
303
  return;
241
304
  }
242
- if (state !== void 0) this.receiveBinary(state.transport, message);
305
+ if (state === void 0) return;
306
+ if (state.binaryCodec !== null) {
307
+ state.transport.feed(message);
308
+ return;
309
+ }
310
+ this.receiveBinary(state.transport, message);
243
311
  }
244
312
  /**
245
313
  * Faute de câblage : `handleRealtime` reçu par une instance qui ne sert pas
@@ -292,11 +360,32 @@ var RealtimeController = class extends Controller {
292
360
  * client. Même contre-pression que les frames JSON-RPC.
293
361
  *
294
362
  * @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.
363
+ * Sur une connexion qui a négocié un codec BINAIRE, refusé (ADR-0022 §2) : le
364
+ * client y lirait ces octets comme une frame du codec. Des octets passent
365
+ * alors par un canal, avec ses portes.
366
+ *
367
+ * @returns `false` si le handshake n'est pas terminé, la connexion fermée, la
368
+ * trame jetée par la contre-pression, ou la connexion en codec binaire.
297
369
  */
298
370
  sendBinaryToClient(data) {
299
- return this.coldConnState()?.transport.sendBinary(data) ?? false;
371
+ const state = this.coldConnState();
372
+ if (state === void 0) return false;
373
+ if (state.binaryCodec !== null) {
374
+ this.noticeRawBinaryUnderCodec(state);
375
+ return false;
376
+ }
377
+ return state.transport.sendBinary(data);
378
+ }
379
+ /**
380
+ * Journalise, une fois par connexion, le flux brut refusé sous codec binaire.
381
+ *
382
+ * @param state - l'état de la connexion.
383
+ */
384
+ noticeRawBinaryUnderCodec(state) {
385
+ rawBinaryNoticed ??= /* @__PURE__ */ new WeakSet();
386
+ if (rawBinaryNoticed.has(state)) return;
387
+ rawBinaryNoticed.add(state);
388
+ 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
389
  }
301
390
  /**
302
391
  * État realtime de CETTE connexion, `undefined` avant le welcome — pour les
@@ -320,6 +409,8 @@ var RealtimeController = class extends Controller {
320
409
  * Échecs (close WebSocket, codes plage applicative RFC 6455 §7.4.2) :
321
410
  * - Origin refusée → code 4003 (`forbidden`).
322
411
  * - `authenticate` throw → code 4001 (`unauthorized`).
412
+ * - `authenticate` sans réponse dans `authenticateTimeoutMs` → code 1013
413
+ * (`try again later`, ADR-0021 §S11) : le client recule puis revient.
323
414
  */
324
415
  async onHandshake(ctx) {
325
416
  if (ctx.__nfRealtime?.welcomed) return;
@@ -329,13 +420,19 @@ var RealtimeController = class extends Controller {
329
420
  const handshake = buildHandshakeFromContext(ctx);
330
421
  if (!hub.checkOrigin(handshake.origin)) {
331
422
  this.log(`WS realtime upgrade refused: Origin "${handshake.origin ?? "(missing)"}" not allowed`, "WARNING");
332
- conn.close(4003, "origin not allowed");
423
+ conn.close(ORIGIN_REFUSED_CLOSE_CODE, "origin not allowed");
333
424
  return;
334
425
  }
335
426
  const authenticator = hub.resolveAuthenticator(handshake);
336
427
  let token;
337
428
  if (authenticator?.supports(handshake)) try {
338
- token = await authenticator.authenticate(handshake);
429
+ const outcome = await hub.admissionBudget.authenticateWithin(authenticator.authenticate(handshake));
430
+ if (outcome === AUTHENTICATE_TIMED_OUT) {
431
+ 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");
432
+ conn.close(TRY_AGAIN_LATER_CLOSE_CODE, "try again later");
433
+ return;
434
+ }
435
+ token = outcome;
339
436
  authenticator.onSuccess?.(handshake, token);
340
437
  } catch (e) {
341
438
  const err = e instanceof Error ? e : new Error(String(e));
@@ -343,41 +440,44 @@ var RealtimeController = class extends Controller {
343
440
  authenticator.onFailure?.(handshake, err);
344
441
  } catch {}
345
442
  this.log(`WS realtime auth failed (${authenticator.name}): ${err.message}`, "WARNING");
346
- conn.close(4001, "unauthorized");
443
+ conn.close(IDENTITY_DEAD_CLOSE_CODE, "unauthorized");
347
444
  return;
348
445
  }
349
446
  else token = ANONYMOUS_REALTIME_TOKEN;
447
+ if (conn.readyState !== TransportState.OPEN) return;
350
448
  const transport = new WsConnectionTransport(conn, readBackpressureOptions(webSocketServerOf(ctx)));
449
+ const codecs = hub.frameCodecs;
450
+ const negotiated = this.#codec ?? codecs.text;
451
+ const binaryCodec = negotiated === codecs.binary ? negotiated : null;
452
+ const outbound = ctx.outboundTracer();
351
453
  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);
454
+ codec: codecs.text,
455
+ write: outbound === null ? (data) => transport.send(data) : (data, frame) => {
456
+ transport.send(data);
457
+ if (carriesId(frame)) outbound(data);
363
458
  },
364
459
  onNotification: (method, params) => this.onRealtimeNotification(ctx, method, params),
365
460
  onError: (context, err) => this.log(`${context}: ${err instanceof Error ? err.message : String(err)}`, "ERROR")
366
461
  };
367
- peerOptions.beforeDispatch = (frame) => hub.runAuthorizer(frame, peer);
462
+ peerOptions.beforeDispatch = (frame) => {
463
+ if (state.admitted?.[frame.method] === true) return true;
464
+ return gateFrame(state, frame, hub);
465
+ };
368
466
  peerOptions.onFrameAudit = (reason, frame, auditedPeer) => {
369
467
  if (reason !== "denied") return;
370
- this.log("WS realtime frame refused by authorizer", "WARNING");
468
+ const explained = hub.explainDenial(frame, state.peer);
469
+ this.log(explained === null ? "WS realtime frame refused by authorizer" : `WS realtime frame refused by authorizer: ${explained}`, "WARNING");
371
470
  if (!isRecord(frame)) return;
372
471
  const f = frame;
373
472
  if (f.id !== void 0) return;
374
473
  const channel = f.method === "subscribe" ? channelParam(f.params) : typeof f.method === "string" ? f.method : void 0;
375
474
  if (channel !== void 0) {
376
- const hint = identityHint(this.kernel);
475
+ const unadmitted = f.method === channel && state.inbound !== null && Object.hasOwn(state.inbound, channel) && hub.resolveChannelAdmission(channel) !== null;
476
+ const hint = unadmitted || explained !== null ? null : identityHint(this.kernel);
377
477
  const denied = {
378
478
  channel,
379
479
  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}`))
480
+ ...deniedDetail(this.kernel?.environment, explained ?? (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
481
  };
382
482
  auditedPeer.notify("realtime:denied", denied);
383
483
  }
@@ -387,9 +487,9 @@ var RealtimeController = class extends Controller {
387
487
  transport.onMessage((raw) => {
388
488
  let parsed;
389
489
  try {
390
- parsed = JSON.parse(raw);
490
+ parsed = peer.decode(raw);
391
491
  } catch {
392
- if (transport.discard("malformed") === 1) this.log("WS realtime : trame texte illisible (pas du JSON) reçue et écartée", "WARNING");
492
+ 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
493
  return;
394
494
  }
395
495
  peer.receive(parsed);
@@ -400,32 +500,21 @@ var RealtimeController = class extends Controller {
400
500
  };
401
501
  for (const [name, handler] of Object.entries(allActions)) {
402
502
  peer.register(name, handler);
403
- if (hub.resolveChannelPolicy(name) === null) hub.registerChannelPolicy(name, DEFAULT_ACTION_POLICY);
503
+ hub.declareActionDefault(name);
404
504
  }
405
- if (this.realtimeApiRequest()) peer.register("api.request", (params) => this.invokeApiRequest(ctx, params, peer));
505
+ if (this.realtimeApiRequest()) peer.register("api.request", (params) => this.invokeApiRequest(ctx, params, token));
406
506
  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
- }
507
+ registerControllerPolicies(hub, this.constructor, null);
508
+ if (!warnedUnenforcedPolicies && hub.hasUnenforcedChannelPolicies()) {
509
+ warnedUnenforcedPolicies = true;
510
+ 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
511
  }
415
512
  const noticeSyslog = this.syslog;
416
513
  const noticeName = this.name;
417
514
  hub.onPlatformNotice((message, severity) => {
418
515
  noticeSyslog?.log(message, severity, noticeName);
419
516
  });
420
- hub.registerConnection(transport);
421
517
  let revocable = null;
422
- if (typeof token.isValid === "function") {
423
- revocable = {
424
- token,
425
- close: (code, reason) => conn.close(code, reason)
426
- };
427
- hub.registerRevocable(revocable);
428
- }
429
518
  const broadcast = this.realtimeBroadcastChannels();
430
519
  for (const channel of broadcast) hub.markBroadcastChannel(channel);
431
520
  const decoratedInbound = getRealtimeInbound(this);
@@ -447,20 +536,39 @@ var RealtimeController = class extends Controller {
447
536
  welcomed: true,
448
537
  peer,
449
538
  transport,
539
+ binaryCodec,
450
540
  channels: /* @__PURE__ */ new Map(),
451
- inbound
541
+ inbound,
542
+ admission: null,
543
+ admitted: null,
544
+ retained: null,
545
+ connection: null
452
546
  };
453
547
  ctx.__nfRealtime = state;
454
548
  ctx.once("onFinish", () => {
455
549
  const liveHub = getRealtimeHub();
456
550
  for (const [channel, sink] of state.channels) liveHub.unsubscribe(channel, sink);
457
551
  state.channels.clear();
552
+ state.connection?.close();
553
+ if (state.admission !== null) {
554
+ state.admission.close();
555
+ state.admission = null;
556
+ }
557
+ hub.untrackRetainedVerdicts(state);
458
558
  liveHub.unregisterConnection(transport);
459
559
  if (revocable) liveHub.unregisterRevocable(revocable);
460
560
  transport.fireClose();
461
561
  peer.dispose("ws closed");
462
562
  this.log("WS realtime client disconnected — cleanup done", "INFO");
463
563
  });
564
+ hub.registerConnection(transport);
565
+ if (typeof token.isValid === "function") {
566
+ revocable = {
567
+ token,
568
+ close: (code, reason) => conn.close(code, reason)
569
+ };
570
+ hub.registerRevocable(revocable);
571
+ }
464
572
  const announcedChannels = [...this._decoratedChannels ? Object.keys(this._decoratedChannels) : [], ...this.realtimeChannels()].filter((channel) => hub.probeChannel(channel, peer));
465
573
  const env = this.kernel?.environment;
466
574
  const welcome = {
@@ -470,15 +578,15 @@ var RealtimeController = class extends Controller {
470
578
  methods: peer.methods,
471
579
  subscriptionAcks: true,
472
580
  ...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
- }
581
+ identity: welcomeIdentityOf(token)
480
582
  };
583
+ if (binaryCodec !== null) welcome.codec = binaryCodec.protocol;
584
+ else if (peer.codec.bytesMarker !== void 0) welcome.bytes = peer.codec.bytesMarker;
481
585
  peer.notify("realtime:welcome", welcome);
586
+ if (binaryCodec !== null) {
587
+ peer.codec = binaryCodec;
588
+ ctx.frameCodec = binaryCodec;
589
+ }
482
590
  this.log("WS realtime client connected", "INFO");
483
591
  }
484
592
  /**
@@ -498,7 +606,7 @@ var RealtimeController = class extends Controller {
498
606
  const handler = state?.inbound?.[method];
499
607
  if (handler) {
500
608
  getRealtimeHub().recordInbound();
501
- handler(params, (payload) => state.peer.notify(method, payload));
609
+ handler(params, (payload) => state.peer.notify(method, payload), state.connection ?? this.connectionOf(ctx, state));
502
610
  }
503
611
  }
504
612
  /**
@@ -509,9 +617,117 @@ var RealtimeController = class extends Controller {
509
617
  * par connexion).
510
618
  */
511
619
  acknowledgeSubscription(state, channel) {
512
- state.transport.send(subscribedAckFrame(channel));
620
+ const connection = state.connection;
621
+ const imposed = connection?.isImposing(channel) === true;
622
+ if (state.binaryCodec !== null) state.peer.notify("realtime:subscribed", imposed ? {
623
+ channel,
624
+ imposed: true
625
+ } : { channel });
626
+ else state.transport.send(imposed ? imposedSubscribedAckFrame(channel) : subscribedAckFrame(channel));
627
+ if (imposed) connection.settleImposed(channel, true);
628
+ if (connection !== null) getRealtimeHub().presenceSubscribed(channel, connection);
629
+ }
630
+ /**
631
+ * Poignée publique de la connexion, créée au premier besoin (abonnement, trame entrante) puis gardée sur son état.
632
+ *
633
+ * Cold path : une fois par connexion. Les deux fermetures la relient à CE
634
+ * contrôleur, dont les fabriques servent ses canaux.
635
+ */
636
+ connectionOf(ctx, state) {
637
+ let connection = state.connection;
638
+ if (connection === null) {
639
+ const peer = state.peer;
640
+ connection = new RealtimeConnection({
641
+ impose: (channel) => this.imposeChannel(ctx, channel),
642
+ end: (channel, reason) => this.endChannel(ctx, channel, reason),
643
+ send: (frame) => {
644
+ const data = frame.encodeFor(peer.codec);
645
+ if (data !== void 0) state.transport.send(data);
646
+ else if (frame.claimFailure(peer.codec)) this.log("trame partagée non encodable : non envoyée", "ERROR");
647
+ }
648
+ }, () => getRealtimeHub().getTokenForPeer(peer).getIdentity?.() ?? null);
649
+ state.connection = connection;
650
+ }
651
+ return connection;
513
652
  }
514
- /** Abonne la connexion à un canal via le hub partagé (idempotent par connexion). */
653
+ /**
654
+ * 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).
655
+ *
656
+ * Le verdict est rendu à l'appelant par `RealtimeConnection.settleImposed` :
657
+ * à l'acquittement ({@link acknowledgeSubscription}) ou au refus
658
+ * ({@link refuseChannel}) — qui n'envoie alors rien au client, qui n'a rien
659
+ * demandé.
660
+ */
661
+ imposeChannel(ctx, channel) {
662
+ const state = ctx.__nfRealtime;
663
+ const connection = state?.connection;
664
+ if (!state || !connection) return;
665
+ if (state.channels.has(channel)) {
666
+ connection.settleImposed(channel, true);
667
+ return;
668
+ }
669
+ const frame = {
670
+ method: "subscribe",
671
+ params: { channel }
672
+ };
673
+ if (!getRealtimeHub().runAuthorizer(frame, state.peer)) {
674
+ connection.settleImposed(channel, false);
675
+ return;
676
+ }
677
+ this.startChannel(ctx, channel);
678
+ }
679
+ /**
680
+ * Met fin à l'abonnement de la connexion à un canal, sur décision du serveur, et le lui notifie (`realtime:closed`).
681
+ *
682
+ * Même chemin que l'`unsubscribe` du client (hub ET état de la connexion),
683
+ * jamais un second : retirer le seul sink du hub laisserait l'entrée de la
684
+ * connexion, et le `subscribe` suivant serait acquitté sans rien rouvrir.
685
+ * Retire aussi la marque « admise » et le verdict retenu de ce canal.
686
+ *
687
+ * @returns `false` si la connexion n'est pas abonnée à ce canal.
688
+ */
689
+ endChannel(ctx, channel, reason) {
690
+ const state = ctx.__nfRealtime;
691
+ if (!state || !this.leaveChannel(state, channel)) return false;
692
+ revokeAdmission(state, channel);
693
+ const closed = {
694
+ channel,
695
+ reason
696
+ };
697
+ state.peer.notify("realtime:closed", closed);
698
+ this.log(`WS ${reason} → ${channel}`, "DEBUG");
699
+ return true;
700
+ }
701
+ /**
702
+ * 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.
703
+ *
704
+ * @returns `false` si la connexion n'était pas abonnée.
705
+ */
706
+ leaveChannel(state, channel) {
707
+ const sink = state.channels.get(channel);
708
+ if (sink === void 0) return false;
709
+ const hub = getRealtimeHub();
710
+ hub.unsubscribe(channel, sink);
711
+ state.channels.delete(channel);
712
+ releaseRuleAdmission(state, channel, hub);
713
+ return true;
714
+ }
715
+ /**
716
+ * 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.
717
+ */
718
+ refuseChannel(state, channel, reason, detail, retryAfterMs) {
719
+ const connection = state.connection;
720
+ if (connection !== null && !connection.settleImposed(channel, false)) return;
721
+ this.denyChannel(state, channel, reason, detail, retryAfterMs);
722
+ }
723
+ /**
724
+ * Abonne la connexion à un canal via le hub partagé (idempotent par connexion).
725
+ *
726
+ * Un canal dont la politique déclare `allow` passe d'abord par la file
727
+ * d'admission de la connexion ; tout autre canal s'ouvre tout de suite, sans
728
+ * rien lire de plus qu'aujourd'hui (une recherche de politique au `subscribe`,
729
+ * trame de contrôle rare).
730
+ */
515
731
  startChannel(ctx, channel) {
516
732
  if (!channel) return;
517
733
  const state = ctx.__nfRealtime;
@@ -520,30 +736,97 @@ var RealtimeController = class extends Controller {
520
736
  this.acknowledgeSubscription(state, channel);
521
737
  return;
522
738
  }
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);
739
+ const admission = state.admission;
740
+ if (admission?.revive(channel) === true) {
741
+ state.connection?.markClientAsked(channel);
742
+ return;
743
+ }
744
+ const hub = getRealtimeHub();
745
+ const cap = hub.maxChannelsPerConnection;
746
+ if (cap !== null && state.channels.size + (admission === null ? 0 : admission.unsettled) >= cap) {
747
+ 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
748
  this.log(`WS subscribe refusé (cap ${cap} canaux/connexion atteint) → ${channel}`, "DEBUG");
532
749
  return;
533
750
  }
534
- const sink = (payload, serialized) => {
535
- if (serialized !== void 0) state.transport.send(serialized);
536
- else state.peer.notify(channel, payload);
751
+ const rule = hub.resolveChannelAdmission(channel);
752
+ if (rule === null) {
753
+ this.openChannel(ctx, state, channel);
754
+ return;
755
+ }
756
+ this.admitChannel(ctx, state, channel, rule, hub);
757
+ }
758
+ /**
759
+ * 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).
760
+ *
761
+ * `allow` s'applique même sans module de sécurité : il ne fait que retirer
762
+ * des abonnés, jamais en ajouter — l'ignorer ouvrirait `user:<id>` à tous.
763
+ * La file de la connexion n'est créée qu'à son premier canal à `allow`.
764
+ */
765
+ admitChannel(ctx, state, channel, rule, hub) {
766
+ let admission = state.admission;
767
+ if (admission === null) {
768
+ const who = hub.getTokenForPeer(state.peer).getIdentity?.();
769
+ if (who === void 0) {
770
+ 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");
771
+ this.log(`WS subscribe refusé : jeton sans getIdentity sur un canal à allow → ${channel}`, warnedNoIdentity ? "DEBUG" : "WARNING");
772
+ warnedNoIdentity = true;
773
+ return;
774
+ }
775
+ admission = new ConnectionAdmission({
776
+ open: (ch) => this.openChannel(ctx, state, ch),
777
+ deny: (ch, reason, detail, retryAfterMs) => {
778
+ this.refuseChannel(state, ch, reason, detail, retryAfterMs);
779
+ this.log(`WS subscribe refusé (${reason}) → ${ch}`, "DEBUG");
780
+ },
781
+ warn: (message) => this.log(message, "WARNING")
782
+ }, hub.admissionBudget, who, explainsDenials(this.kernel?.environment));
783
+ state.admission = admission;
784
+ }
785
+ admission.request(channel, rule);
786
+ }
787
+ /**
788
+ * Rend un refus d'abonnement observable (`realtime:denied`) — le détail ne part qu'hors production.
789
+ *
790
+ * @param detail - phrase pour le développeur ; `undefined` quand l'appelant
791
+ * ne l'a pas rédigée (production).
792
+ */
793
+ denyChannel(state, channel, reason, detail, retryAfterMs) {
794
+ const denied = {
795
+ channel,
796
+ reason,
797
+ ...detail === void 0 ? {} : deniedDetail(this.kernel?.environment, detail),
798
+ ...retryAfterMs === void 0 ? {} : { retryAfterMs }
799
+ };
800
+ state.peer.notify("realtime:denied", denied);
801
+ }
802
+ /** Ouvre l'abonnement au hub et l'acquitte — canal sans `allow`, ou admis par sa règle. */
803
+ openChannel(ctx, state, channel) {
804
+ const sink = (payload, frame) => {
805
+ if (frame !== void 0) {
806
+ const data = frame.encodeFor(state.peer.codec);
807
+ if (data !== void 0) {
808
+ state.transport.send(data);
809
+ return;
810
+ }
811
+ if (!frame.claimFailure(state.peer.codec)) return;
812
+ }
813
+ state.peer.notify(channel, payload);
537
814
  };
538
815
  let servedByPattern = false;
539
816
  const isServedByPattern = () => servedByPattern;
540
817
  if (getRealtimeHub().subscribeClient(channel, sink, (ch, publish) => {
541
818
  const decFactory = this._decoratedChannels?.[ch];
542
819
  if (decFactory) return decFactory(ch, publish);
820
+ if (this.createRealtimeChannel === BASE_CREATE_CHANNEL) {
821
+ const byPattern = patternChannelFactory(this, this._decoratedChannels, ch);
822
+ if (byPattern === null) return null;
823
+ servedByPattern = true;
824
+ return byPattern(ch, publish);
825
+ }
543
826
  const dispose = this.createRealtimeChannel(ch, publish);
544
827
  if (dispose !== null) servedByPattern = true;
545
828
  return dispose;
546
- }, (payload) => JSON.stringify(JsonRpcPeer.buildNotification(channel, payload)))) {
829
+ }, (payload) => JsonRpcPeer.buildNotification(channel, payload), this.connectionOf(ctx, state))) {
547
830
  state.channels.set(channel, sink);
548
831
  if (isServedByPattern()) getRealtimeHub().noticeUnguardedDynamicChannel(channel);
549
832
  this.acknowledgeSubscription(state, channel);
@@ -551,12 +834,7 @@ var RealtimeController = class extends Controller {
551
834
  return;
552
835
  }
553
836
  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);
837
+ 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
838
  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
839
  }
562
840
  /**
@@ -617,10 +895,11 @@ var RealtimeController = class extends Controller {
617
895
  * mutualisé tant que Ph.6 n'a pas tranché).
618
896
  *
619
897
  * Erreurs → {@link RpcError} (`data.status` = statut HTTP équivalent) :
620
- * `-32602` params invalides, 404 path inconnu, 408 timeout d'action…
898
+ * `-32602` params invalides, 404 path inconnu, 408 timeout d'action… Un
899
+ * `Retry-After` posé par l'action part en `data.retryAfter` (secondes).
621
900
  * Tout autre throw de l'action reste opaque (`-32603`, Zero Trust).
622
901
  */
623
- async invokeApiRequest(ctx, params, peer) {
902
+ async invokeApiRequest(ctx, params, token) {
624
903
  const p = isRecord(params) ? params : void 0;
625
904
  const path = p?.path;
626
905
  if (typeof path !== "string" || path.charCodeAt(0) !== 47) throw new RpcError("api.request: params.path invalide", JsonRpcError.INVALID_PARAMS);
@@ -628,13 +907,13 @@ var RealtimeController = class extends Controller {
628
907
  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
908
  const router = ctx.router;
630
909
  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);
910
+ const target = parseRequestTarget(path);
911
+ if (target === null) throw new RpcError("api.request: params.path invalide", JsonRpcError.INVALID_PARAMS);
912
+ const pathname = stripTrailingSlashes(target.pathname);
633
913
  const frame = ctx.beginFrame(method, path);
634
914
  let store = null;
915
+ const renderSink = {};
635
916
  try {
636
- const token = getRealtimeHub().getTokenForPeer(peer);
637
- const renderSink = {};
638
917
  const frameStore = {
639
918
  requestId: frame?.requestId ?? ctx.requestId,
640
919
  scheme: ctx.scheme,
@@ -654,17 +933,18 @@ var RealtimeController = class extends Controller {
654
933
  frame?.phaseStart("resolve");
655
934
  let resolver;
656
935
  try {
657
- resolver = router.resolve(ctx, pathname, method === "GET" ? void 0 : method);
936
+ resolver = router.resolve(ctx, pathname, method);
658
937
  } finally {
659
938
  frame?.phaseEnd("resolve");
660
939
  }
661
940
  if (!resolver.resolve) throw new RpcError(`api.request: not found ${pathname}`, JsonRpcServerError.DEFAULT, { status: 404 });
662
941
  if (frame) frame.resolver = resolver;
663
942
  frameStore.resolver = resolver;
664
- if (qIdx !== -1 && qIdx < path.length - 1) {
665
- const sp = new URLSearchParams(path.slice(qIdx + 1));
943
+ if (target.search.length > 1) {
944
+ const sp = new URLSearchParams(target.search.slice(1));
666
945
  const query = {};
667
946
  for (const [k, v] of sp) {
947
+ if (Object.hasOwn(Object.prototype, k)) continue;
668
948
  const prev = query[k];
669
949
  if (prev === void 0) query[k] = v;
670
950
  else if (Array.isArray(prev)) prev.push(v);
@@ -688,7 +968,7 @@ var RealtimeController = class extends Controller {
688
968
  frame?.phaseStart("action");
689
969
  try {
690
970
  const { result: actionResult } = await resolver.executeActionGuarded(void 0, true);
691
- const raw = await Promise.resolve(actionResult);
971
+ const raw = await actionResult;
692
972
  if (renderSink.body !== void 0) {
693
973
  const text = Buffer.isBuffer(renderSink.body) ? renderSink.body.toString("utf8") : renderSink.body;
694
974
  let body;
@@ -717,7 +997,7 @@ var RealtimeController = class extends Controller {
717
997
  } catch (e) {
718
998
  const status = httpStatusOfFrameError(e);
719
999
  frame?.finish(status ?? 500, e);
720
- throw toFrameRpcError(e, status, frame?.profiled ? frame.requestId : void 0);
1000
+ throw toFrameRpcError(e, status, frame?.profiled ? frame.requestId : void 0, status === null ? null : retryAfterSeconds(renderSink.retryAfter));
721
1001
  } finally {
722
1002
  if (store !== null) RequestContext.release(store);
723
1003
  ctx.collectFrame(frame);
@@ -728,15 +1008,29 @@ var RealtimeController = class extends Controller {
728
1008
  if (!channel) return;
729
1009
  const state = ctx.__nfRealtime;
730
1010
  if (!state) return;
731
- const sink = state.channels.get(channel);
732
- if (sink) {
733
- getRealtimeHub().unsubscribe(channel, sink);
734
- state.channels.delete(channel);
1011
+ if (this.leaveChannel(state, channel)) {
735
1012
  this.log(`WS unsubscribe → ${channel}`, "DEBUG");
1013
+ return;
736
1014
  }
1015
+ state.admission?.cancel(channel);
1016
+ state.connection?.settleImposed(channel, false);
737
1017
  }
738
1018
  };
739
1019
  /**
1020
+ * La fabrique par défaut de la base (`null` partout) : une classe qui la garde
1021
+ * ne surcharge pas `createRealtimeChannel`, et ses motifs décorés servent alors
1022
+ * les noms qu'ils couvrent. Lue une fois, comparée par référence au `subscribe`.
1023
+ */
1024
+ const BASE_CREATE_CHANNEL = Reflect.get(RealtimeController.prototype, "createRealtimeChannel");
1025
+ /**
1026
+ * Une frame JSON-RPC porte-t-elle un `id` — réponse ou requête, jamais une
1027
+ * notification ? Même règle que `isNotification` du cœur (`id` absent), sur une
1028
+ * frame non typée.
1029
+ */
1030
+ function carriesId(frame) {
1031
+ return typeof frame === "object" && frame !== null && Reflect.get(frame, "id") !== void 0;
1032
+ }
1033
+ /**
740
1034
  * Construit un {@link IRealtimeHandshake} immuable depuis le `WebsocketContext`
741
1035
  * de @nodefony/http — DTO neutre passé aux authenticators réseau (zéro
742
1036
  * dépendance security/http côté contrat). Cold path (1× par upgrade).
@@ -747,6 +1041,18 @@ var RealtimeController = class extends Controller {
747
1041
  * - `protocols` : la liste des sous-protocoles annoncés (`Sec-WebSocket-Protocol`,
748
1042
  * csv séparé virgule selon RFC 6455 §4.1).
749
1043
  */
1044
+ /**
1045
+ * Les sous-protocoles offerts au handshake (`Sec-WebSocket-Protocol`, liste
1046
+ * séparée par des virgules, RFC 6455 §4.1), dans l'ordre de préférence du
1047
+ * client — la lecture unique du DTO du handshake et de la négociation du codec.
1048
+ *
1049
+ * @param raw - l'en-tête tel que Node le rend : chaîne, liste, ou absent.
1050
+ * @returns les noms, sans espaces ni entrées vides ; `[]` sans en-tête.
1051
+ */
1052
+ function offeredSubprotocols(raw) {
1053
+ if (raw === void 0) return [];
1054
+ return (typeof raw === "string" ? raw.split(",") : raw.flatMap((p) => p.split(","))).map((x) => x.trim()).filter((x) => x.length > 0);
1055
+ }
750
1056
  function buildHandshakeFromContext(ctx) {
751
1057
  const headers = ctx.request?.headers ?? {};
752
1058
  const cookies = /* @__PURE__ */ new Map();
@@ -755,11 +1061,7 @@ function buildHandshakeFromContext(ctx) {
755
1061
  const c = rawCookies[name];
756
1062
  if (c && typeof c.value === "string") cookies.set(name, c.value);
757
1063
  }
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 = [];
1064
+ const protocols = offeredSubprotocols(headers["sec-websocket-protocol"]);
763
1065
  return {
764
1066
  headers,
765
1067
  cookies,
@@ -784,4 +1086,4 @@ function handshakePath(ctx) {
784
1086
  return raw.charCodeAt(0) === 47 ? raw : "/";
785
1087
  }
786
1088
  }
787
- export { RealtimeController, RealtimeController as default };
1089
+ export { HANDSHAKE_FAILED_LOG, RealtimeController, RealtimeController as default, retryAfterSeconds };