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

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 (51) hide show
  1. package/README.md +29 -10
  2. package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  3. package/dist/index.js +4 -6
  4. package/dist/nodefony/config/config.js +1 -2
  5. package/dist/nodefony/config/configSchema.js +1 -2
  6. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  7. package/dist/nodefony/decorators/realtimeDecorators.js +1 -2
  8. package/dist/nodefony/interfaces/IRealtimeConnectionState.js +1 -0
  9. package/dist/nodefony/src/backplane/ClusterBackplane.js +1 -2
  10. package/dist/nodefony/src/backplane/LoopbackBackplane.js +1 -2
  11. package/dist/nodefony/src/backplane/RedisBackplane.js +1 -2
  12. package/dist/nodefony/src/backplane/backplaneRegistry.js +1 -2
  13. package/dist/nodefony/src/backplane/bootNotices.js +1 -2
  14. package/dist/nodefony/src/backplane/envelope.js +1 -2
  15. package/dist/nodefony/src/backplane/originId.js +1 -2
  16. package/dist/nodefony/src/backplane/publishQueue.js +0 -1
  17. package/dist/nodefony/src/cluster/ClusterProbeClient.js +1 -2
  18. package/dist/nodefony/src/guards.js +1 -2
  19. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +1 -2
  20. package/dist/nodefony/src/server/RealtimeAdminApi.js +1 -2
  21. package/dist/nodefony/src/server/RealtimeController.js +158 -84
  22. package/dist/nodefony/src/server/RealtimeHub.js +3 -3
  23. package/dist/nodefony/src/server/ServerRealtimeSocket.js +18 -9
  24. package/dist/nodefony/src/server/deniedDetail.js +1 -2
  25. package/dist/nodefony/src/server/subscribedAck.js +29 -0
  26. package/dist/nodefony/src/server/syslogUplink.js +1 -2
  27. package/dist/nodefony/src/server/welcomeEnv.js +1 -2
  28. package/dist/nodefony/src/service/RealtimeService.js +1 -2
  29. package/dist/nodefony/src/transport/WsConnectionTransport.js +42 -2
  30. package/dist/nodefony/testing/index.js +77 -4
  31. package/dist/types/index.d.ts +1 -0
  32. package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +40 -0
  33. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +13 -0
  34. package/dist/types/nodefony/interfaces/index.d.ts +1 -0
  35. package/dist/types/nodefony/src/server/RealtimeController.d.ts +60 -2
  36. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +18 -8
  37. package/dist/types/nodefony/src/server/subscribedAck.d.ts +23 -0
  38. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +26 -1
  39. package/dist/types/nodefony/testing/index.d.ts +11 -4
  40. package/docs/actions.md +67 -44
  41. package/docs/architecture.md +125 -119
  42. package/docs/cadence-et-contre-pression.md +31 -22
  43. package/docs/configuration.md +106 -82
  44. package/docs/cookbook-chat.md +56 -34
  45. package/docs/index.md +35 -24
  46. package/docs/observabilite.md +49 -49
  47. package/docs/protocole.md +91 -68
  48. package/docs/securite.md +135 -115
  49. package/docs/vocabulaire.md +117 -114
  50. package/package.json +10 -10
  51. package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
@@ -5,11 +5,12 @@ import { DEFAULT_ACTION_POLICY, getRealtimeActions, getRealtimeChannelPolicies,
5
5
  import { createSyslogUplinkHandler } from "./syslogUplink.js";
6
6
  import { WsConnectionTransport } from "../transport/WsConnectionTransport.js";
7
7
  import { welcomeEnv } from "./welcomeEnv.js";
8
+ import { subscribedAckFrame } from "./subscribedAck.js";
8
9
  import { deniedDetail } from "./deniedDetail.js";
9
10
  import { BootConfigurationError, JsonRpcError, JsonRpcPeer, JsonRpcServerError, PLATFORM_INBOUND, RequestContext, RpcEnvelope, RpcError, Scope, identityHint, isJsonRpcId, isPromise, jsonRpcFailure } from "nodefony";
10
- import { readBackpressureOptions } from "@nodefony/http";
11
- import { Controller, buildContextParamArgs, getParamArgsMeta } from "@nodefony/framework";
12
- //#region nodefony/src/server/RealtimeController.ts
11
+ import { isWebsocketContext, readBackpressureOptions, toValidationFields } from "@nodefony/http";
12
+ import { Controller, buildContextParamArgs, getParamArgsMeta, isFrameworkRouter } from "@nodefony/framework";
13
+ // nodefony/src/server/RealtimeController.ts
13
14
  /**
14
15
  * Une action RPC n'a ni variables de route ni URL : `@Param` et `@Query` y
15
16
  * lisent cet objet vide.
@@ -32,6 +33,7 @@ function httpStatusOfFrameError(e) {
32
33
  if (typeof status === "number") return status;
33
34
  return e.code === JsonRpcError.INVALID_PARAMS ? 400 : null;
34
35
  }
36
+ if (e instanceof Error && toValidationFields(e) !== null) return 422;
35
37
  const code = isRecord(e) ? e.code : void 0;
36
38
  return typeof code === "number" && code >= 400 && code <= 599 ? code : null;
37
39
  }
@@ -55,6 +57,10 @@ function toFrameRpcError(e, status, requestId) {
55
57
  if (status !== null) {
56
58
  const data = { status };
57
59
  if (requestId) data.requestId = requestId;
60
+ if (status === 422 && e instanceof Error) {
61
+ const fields = toValidationFields(e);
62
+ if (fields !== null) data.fields = fields;
63
+ }
58
64
  return new RpcError(e instanceof Error ? e.message : String(e), JsonRpcServerError.DEFAULT, data);
59
65
  }
60
66
  return e;
@@ -125,8 +131,20 @@ var RealtimeController = class extends Controller {
125
131
  constructor(name, context) {
126
132
  new.target.assertScope();
127
133
  super(name, context);
134
+ this.#connection = isWebsocketContext(context) ? context : null;
128
135
  }
129
136
  /**
137
+ * La connexion WebSocket que sert CETTE instance — rétrécie UNE fois, ici,
138
+ * plutôt qu'à chaque frame.
139
+ *
140
+ * Sûr parce que l'instance est liée à sa connexion pour toute sa vie : la
141
+ * portée `singleton` est refusée au constructeur, et le contexte n'est posé
142
+ * qu'à la construction. `null` si la classe sert aussi une route HTTP et que
143
+ * cette instance en est issue. Le chemin de chaque frame lit ce champ au lieu
144
+ * de l'accesseur `context` : une lecture de champ, sans appel.
145
+ */
146
+ #connection;
147
+ /**
130
148
  * Map des factories de canaux EXACT, lue depuis les décorateurs `@RealtimeChannel`
131
149
  * au handshake (cold-path) et mémoïsée par instance. `null` = pas de décorateur
132
150
  * sur cette classe → la base court-circuite la lecture metadata par frame.
@@ -175,7 +193,7 @@ var RealtimeController = class extends Controller {
175
193
  return {};
176
194
  }
177
195
  /**
178
- * Opt-in du **pont API** (« API souveraine », POC Ph.3) : `true` → la
196
+ * Opt-in du **pont API** (une action, deux transports) : `true` → la
179
197
  * connexion expose la méthode RPC `api.request {path}` qui re-route le path
180
198
  * porté par le message vers l'action controller correspondante — la MÊME
181
199
  * action que le GET REST (`socket.request("/nodefony/kernel/api/modules")`
@@ -207,8 +225,8 @@ var RealtimeController = class extends Controller {
207
225
  * écoute (welcome) » de `NodefonySocketCoverage.test.ts`.
208
226
  */
209
227
  handleRealtime(message) {
210
- const ctx = this.context;
211
- if (!ctx) return;
228
+ const ctx = this.#connection;
229
+ if (ctx === null) throw this.outsideWebsocketError();
212
230
  if (message == null) {
213
231
  this.onHandshake(ctx).catch((e) => {
214
232
  this.log(`WS realtime handshake failed: ${e instanceof Error ? e.message : String(e)}`, "ERROR");
@@ -216,7 +234,28 @@ var RealtimeController = class extends Controller {
216
234
  });
217
235
  return;
218
236
  }
219
- ctx.__nfRealtime?.transport.feed(message.toString());
237
+ const state = ctx.__nfRealtime;
238
+ if (typeof message === "string") {
239
+ state?.transport.feed(message);
240
+ return;
241
+ }
242
+ if (state !== void 0) this.receiveBinary(state.transport, message);
243
+ }
244
+ /**
245
+ * Faute de câblage : `handleRealtime` reçu par une instance qui ne sert pas
246
+ * une connexion WebSocket — route sans `methods: ["WEBSOCKET"]`, ou faux
247
+ * contexte de test sans `type`. Levée plutôt que passée sous silence : le
248
+ * client attendrait un welcome qui ne vient jamais. Chemin froid.
249
+ */
250
+ outsideWebsocketError() {
251
+ return /* @__PURE__ */ new TypeError(`${this.constructor.name}.handleRealtime : cette instance ne sert pas une connexion WebSocket (contexte de type « ${String(this.context?.type)} »). La route doit déclarer requirements: { methods: ["WEBSOCKET"] } ; un faux contexte de test déclare type: "websocket".`);
252
+ }
253
+ receiveBinary(transport, data) {
254
+ if (this.onRealtimeBinary !== void 0) {
255
+ this.onRealtimeBinary(data);
256
+ return;
257
+ }
258
+ if (transport.discard("binary") === 1) this.log("WS realtime : trame binaire reçue et écartée — le protocole est du JSON-RPC texte ; pour la lire, surcharger onRealtimeBinary()", "WARNING");
220
259
  }
221
260
  /**
222
261
  * **L1 — duplex serveur→client.** Émet une REQUÊTE vers le client de CETTE
@@ -234,9 +273,9 @@ var RealtimeController = class extends Controller {
234
273
  * @throws (rejet) si le handshake n'est pas terminé (aucun peer encore).
235
274
  */
236
275
  requestClient(method, params, timeoutMs) {
237
- const state = this.context.__nfRealtime;
276
+ const state = this.coldConnState();
238
277
  if (!state) return Promise.reject(/* @__PURE__ */ new Error("requestClient: connexion realtime non établie (welcome non émis)"));
239
- return state.peer.request(method, params, timeoutMs);
278
+ return state.peer.requestAs(method, params, timeoutMs);
240
279
  }
241
280
  /**
242
281
  * Notification CIBLÉE serveur→client (sans réponse) vers CETTE connexion, hors
@@ -245,7 +284,27 @@ var RealtimeController = class extends Controller {
245
284
  * handshake n'est pas terminé.
246
285
  */
247
286
  notifyClient(method, params) {
248
- this.context.__nfRealtime?.peer.notify(method, params);
287
+ this.#connection?.__nfRealtime?.peer.notify(method, params);
288
+ }
289
+ /**
290
+ * Émet une trame BINAIRE vers CETTE connexion, sans la sérialiser — pendant
291
+ * sortant de {@link onRealtimeBinary}, et de `NodefonySocket.sendBinary` côté
292
+ * client. Même contre-pression que les frames JSON-RPC.
293
+ *
294
+ * @param data - les octets à émettre (`Uint8Array`, donc un `Buffer` aussi).
295
+ * @returns `false` si le handshake n'est pas terminé, la connexion fermée ou
296
+ * la trame jetée par la contre-pression.
297
+ */
298
+ sendBinaryToClient(data) {
299
+ return this.coldConnState()?.transport.sendBinary(data) ?? false;
300
+ }
301
+ /**
302
+ * État realtime de CETTE connexion, `undefined` avant le welcome — pour les
303
+ * chemins FROIDS (requête sortante, binaire). `notifyClient` et la réception
304
+ * gardent leur lecture en ligne : ils sont sur le chemin de chaque frame.
305
+ */
306
+ coldConnState() {
307
+ return this.#connection?.__nfRealtime;
249
308
  }
250
309
  /**
251
310
  * Handshake : seams sécurité #4 (Origin RFC 6455 §10.2) + #2 (authenticator
@@ -263,8 +322,7 @@ var RealtimeController = class extends Controller {
263
322
  * - `authenticate` throw → code 4001 (`unauthorized`).
264
323
  */
265
324
  async onHandshake(ctx) {
266
- const holder = ctx;
267
- if (holder.__nfRealtime?.welcomed) return;
325
+ if (ctx.__nfRealtime?.welcomed) return;
268
326
  const conn = ctx.connection;
269
327
  if (!conn) return;
270
328
  const hub = getRealtimeHub();
@@ -331,6 +389,7 @@ var RealtimeController = class extends Controller {
331
389
  try {
332
390
  parsed = JSON.parse(raw);
333
391
  } catch {
392
+ if (transport.discard("malformed") === 1) this.log("WS realtime : trame texte illisible (pas du JSON) reçue et écartée", "WARNING");
334
393
  return;
335
394
  }
336
395
  peer.receive(parsed);
@@ -391,7 +450,7 @@ var RealtimeController = class extends Controller {
391
450
  channels: /* @__PURE__ */ new Map(),
392
451
  inbound
393
452
  };
394
- holder.__nfRealtime = state;
453
+ ctx.__nfRealtime = state;
395
454
  ctx.once("onFinish", () => {
396
455
  const liveHub = getRealtimeHub();
397
456
  for (const [channel, sink] of state.channels) liveHub.unsubscribe(channel, sink);
@@ -409,6 +468,7 @@ var RealtimeController = class extends Controller {
409
468
  protocol: "jsonrpc-2.0",
410
469
  channels: announcedChannels,
411
470
  methods: peer.methods,
471
+ subscriptionAcks: true,
412
472
  ...welcomeEnv(env),
413
473
  identity: {
414
474
  type: token.type,
@@ -441,11 +501,25 @@ var RealtimeController = class extends Controller {
441
501
  handler(params, (payload) => state.peer.notify(method, payload));
442
502
  }
443
503
  }
504
+ /**
505
+ * Acquitte un abonnement ACCEPTÉ (`realtime:subscribed`) — le seul signal
506
+ * qui dise au client à partir de quand le flux du canal lui est livré.
507
+ *
508
+ * Un refus n'y passe jamais : il a `realtime:denied`. Cold path (1×/canal et
509
+ * par connexion).
510
+ */
511
+ acknowledgeSubscription(state, channel) {
512
+ state.transport.send(subscribedAckFrame(channel));
513
+ }
444
514
  /** Abonne la connexion à un canal via le hub partagé (idempotent par connexion). */
445
515
  startChannel(ctx, channel) {
446
516
  if (!channel) return;
447
517
  const state = ctx.__nfRealtime;
448
- if (!state || state.channels.has(channel)) return;
518
+ if (!state) return;
519
+ if (state.channels.has(channel)) {
520
+ this.acknowledgeSubscription(state, channel);
521
+ return;
522
+ }
449
523
  const cap = getRealtimeHub().maxChannelsPerConnection;
450
524
  if (cap !== null && state.channels.size >= cap) {
451
525
  const denied = {
@@ -472,6 +546,7 @@ var RealtimeController = class extends Controller {
472
546
  }, (payload) => JSON.stringify(JsonRpcPeer.buildNotification(channel, payload)))) {
473
547
  state.channels.set(channel, sink);
474
548
  if (isServedByPattern()) getRealtimeHub().noticeUnguardedDynamicChannel(channel);
549
+ this.acknowledgeSubscription(state, channel);
475
550
  this.log(`WS subscribe → ${channel}`, "DEBUG");
476
551
  return;
477
552
  }
@@ -527,7 +602,7 @@ var RealtimeController = class extends Controller {
527
602
  };
528
603
  }
529
604
  /**
530
- * Handler de la méthode RPC `api.request {path}` — le **pont API souverain**
605
+ * Handler de la méthode RPC `api.request {path}` — le **pont socket**
531
606
  * (POC Ph.3) : résout le path porté par le message via le Router
532
607
  * (`cleanPathOverride` — l'URL de la connexion n'est jamais mutée) puis
533
608
  * exécute l'action SANS la rendre (`executeAction`) : la valeur nue est
@@ -552,52 +627,21 @@ var RealtimeController = class extends Controller {
552
627
  const method = typeof p?.method === "string" ? p.method.toUpperCase() : "GET";
553
628
  if (method !== "GET" && method !== "POST" && method !== "PUT" && method !== "PATCH" && method !== "DELETE") throw new RpcError(`api.request: méthode ${method} non supportée`, JsonRpcError.INVALID_PARAMS);
554
629
  const router = ctx.router;
555
- if (!router) throw new RpcError("api.request: router indisponible", JsonRpcServerError.DEFAULT, { status: 500 });
630
+ if (!isFrameworkRouter(router)) throw new RpcError("api.request: router indisponible", JsonRpcServerError.DEFAULT, { status: 500 });
556
631
  const qIdx = path.indexOf("?");
557
632
  const pathname = qIdx === -1 ? path : path.slice(0, qIdx);
558
633
  const frame = ctx.beginFrame(method, path);
634
+ let store = null;
559
635
  try {
560
- frame?.phaseStart("resolve");
561
- let resolver;
562
- try {
563
- resolver = router.resolve(ctx, pathname, method === "GET" ? void 0 : method);
564
- } finally {
565
- frame?.phaseEnd("resolve");
566
- }
567
- if (!resolver.resolve) throw new RpcError(`api.request: not found ${pathname}`, JsonRpcServerError.DEFAULT, { status: 404 });
568
- if (frame) frame.resolver = resolver;
569
- if (qIdx !== -1 && qIdx < path.length - 1) {
570
- const sp = new URLSearchParams(path.slice(qIdx + 1));
571
- const query = {};
572
- for (const [k, v] of sp) {
573
- const prev = query[k];
574
- if (prev === void 0) query[k] = v;
575
- else if (Array.isArray(prev)) prev.push(v);
576
- else query[k] = [prev, v];
577
- }
578
- resolver.queryOverride = query;
579
- }
580
636
  const token = getRealtimeHub().getTokenForPeer(peer);
581
- if (token.isValid) {
582
- let valid;
583
- frame?.phaseStart("identity");
584
- try {
585
- valid = await token.isValid();
586
- } catch {
587
- valid = false;
588
- } finally {
589
- frame?.phaseEnd("identity");
590
- }
591
- if (!valid) throw new RpcError("api.request: identité de session expirée ou invalide", JsonRpcServerError.DEFAULT, { status: 401 });
592
- }
593
637
  const renderSink = {};
594
- const store = {
595
- requestId: ctx.requestId,
638
+ const frameStore = {
639
+ requestId: frame?.requestId ?? ctx.requestId,
596
640
  scheme: ctx.scheme,
597
641
  token,
598
642
  user: token.getAttribute("user"),
599
643
  context: ctx,
600
- resolver,
644
+ resolver: void 0,
601
645
  scope: ctx.container instanceof Scope ? ctx.container : void 0,
602
646
  body: p?.body,
603
647
  idempotencyKey: typeof p?.idempotencyKey === "string" ? p.idempotencyKey : void 0,
@@ -605,46 +649,77 @@ var RealtimeController = class extends Controller {
605
649
  invocation: frame ?? void 0,
606
650
  queries: frame?.profilerQueries ?? void 0
607
651
  };
608
- let result;
609
- try {
610
- result = await RequestContext.run(store, async () => {
611
- frame?.phaseStart("action");
652
+ store = frameStore;
653
+ const result = await RequestContext.run(frameStore, async () => {
654
+ frame?.phaseStart("resolve");
655
+ let resolver;
656
+ try {
657
+ resolver = router.resolve(ctx, pathname, method === "GET" ? void 0 : method);
658
+ } finally {
659
+ frame?.phaseEnd("resolve");
660
+ }
661
+ if (!resolver.resolve) throw new RpcError(`api.request: not found ${pathname}`, JsonRpcServerError.DEFAULT, { status: 404 });
662
+ if (frame) frame.resolver = resolver;
663
+ frameStore.resolver = resolver;
664
+ if (qIdx !== -1 && qIdx < path.length - 1) {
665
+ const sp = new URLSearchParams(path.slice(qIdx + 1));
666
+ const query = {};
667
+ for (const [k, v] of sp) {
668
+ const prev = query[k];
669
+ if (prev === void 0) query[k] = v;
670
+ else if (Array.isArray(prev)) prev.push(v);
671
+ else query[k] = [prev, v];
672
+ }
673
+ resolver.queryOverride = query;
674
+ }
675
+ if (token.isValid) {
676
+ let valid;
677
+ frame?.phaseStart("identity");
612
678
  try {
613
- const { result: actionResult } = await resolver.executeActionGuarded(void 0, true);
614
- const raw = await Promise.resolve(actionResult);
615
- if (renderSink.body !== void 0) {
616
- const text = Buffer.isBuffer(renderSink.body) ? renderSink.body.toString("utf8") : renderSink.body;
617
- let body;
618
- try {
619
- body = JSON.parse(text);
620
- } catch {
621
- body = text;
622
- }
623
- const status = renderSink.status;
624
- if (status !== void 0 && status >= 400) {
625
- const error = isRecord(body) ? body.error : void 0;
626
- throw new RpcError(typeof error === "string" ? error : `api.request: HTTP ${status}`, JsonRpcServerError.DEFAULT, {
627
- status,
628
- body
629
- });
630
- }
631
- return body;
632
- }
633
- return raw;
679
+ valid = await token.isValid();
680
+ if (valid && token.confirmAccount) valid = await token.confirmAccount();
681
+ } catch {
682
+ valid = false;
634
683
  } finally {
635
- frame?.phaseEnd("action");
684
+ frame?.phaseEnd("identity");
636
685
  }
637
- });
638
- } finally {
639
- RequestContext.release(store);
640
- }
686
+ if (!valid) throw new RpcError("api.request: identité de session expirée ou invalide", JsonRpcServerError.DEFAULT, { status: 401 });
687
+ }
688
+ frame?.phaseStart("action");
689
+ try {
690
+ const { result: actionResult } = await resolver.executeActionGuarded(void 0, true);
691
+ const raw = await Promise.resolve(actionResult);
692
+ if (renderSink.body !== void 0) {
693
+ const text = Buffer.isBuffer(renderSink.body) ? renderSink.body.toString("utf8") : renderSink.body;
694
+ let body;
695
+ try {
696
+ body = JSON.parse(text);
697
+ } catch {
698
+ body = text;
699
+ }
700
+ const status = renderSink.status;
701
+ if (status !== void 0 && status >= 400) {
702
+ const error = isRecord(body) ? body.error : void 0;
703
+ throw new RpcError(typeof error === "string" ? error : `api.request: HTTP ${status}`, JsonRpcServerError.DEFAULT, {
704
+ status,
705
+ body
706
+ });
707
+ }
708
+ return body;
709
+ }
710
+ return raw;
711
+ } finally {
712
+ frame?.phaseEnd("action");
713
+ }
714
+ });
641
715
  frame?.finish(200);
642
- return frame ? new RpcEnvelope(result, { requestId: frame.requestId }) : result;
716
+ return frame?.profiled ? new RpcEnvelope(result, { requestId: frame.requestId }) : result;
643
717
  } catch (e) {
644
718
  const status = httpStatusOfFrameError(e);
645
719
  frame?.finish(status ?? 500, e);
646
- throw toFrameRpcError(e, status, frame?.requestId);
720
+ throw toFrameRpcError(e, status, frame?.profiled ? frame.requestId : void 0);
647
721
  } finally {
722
+ if (store !== null) RequestContext.release(store);
648
723
  ctx.collectFrame(frame);
649
724
  }
650
725
  }
@@ -709,5 +784,4 @@ function handshakePath(ctx) {
709
784
  return raw.charCodeAt(0) === 47 ? raw : "/";
710
785
  }
711
786
  }
712
- //#endregion
713
787
  export { RealtimeController, RealtimeController as default };
@@ -1,7 +1,7 @@
1
1
  import { LoopbackBackplane } from "../backplane/LoopbackBackplane.js";
2
2
  import { ANONYMOUS_REALTIME_TOKEN } from "./AnonymousRealtimeToken.js";
3
3
  import { IDENTITY_REFRESH_CLOSE_CODE, NODEFONY_CHANNEL_NAMESPACE, REVOCATION_REVALIDATE_MS, RevocationWatch, isPlatformChannel } from "nodefony";
4
- //#region nodefony/src/server/RealtimeHub.ts
4
+ // nodefony/src/server/RealtimeHub.ts
5
5
  /**
6
6
  * Seuil d'alerte slow-consumer (octets de `bufferedAmount`). Au-delà, la connexion
7
7
  * est comptée comme « lente » par la sonde (PAS encore de drop/close — la sonde MESURE
@@ -789,9 +789,10 @@ var RealtimeHub = class RealtimeHub {
789
789
  * @param channel - le canal servi dynamiquement, cité en exemple dans l'alerte.
790
790
  */
791
791
  noticeUnguardedDynamicChannel(channel) {
792
+ if (isReservedSystemChannel(channel)) return;
792
793
  if (!this.hasChannelPolicies()) return;
793
794
  if (this.resolveChannelPolicy(channel) !== null) return;
794
- this.#notifyOnce("unguarded-dynamic-channel", `le canal "${channel}" est servi par une fabrique DYNAMIQUE et n'est couvert par AUCUNE politique déclarée, alors que ce module en déclare pour d'autres canaux. Un canal dérivé (suffixe de cadence, forage, identifiant) n'hérite pas de la politique de son parent : un nom exact ne garde que lui-même. Déclarer un MOTIF pour garder la famille — @RealtimeChannel("${channelFamily(channel)}", { … }) — ou porter la vérification dans la fabrique elle-même.`);
795
+ this.#notifyOnce("unguarded-dynamic-channel", `le canal "${channel}" n'a aucune règle d'accès : tout client connecté peut s'y abonner. Il est produit par createRealtimeChannel(), et une règle déclarée sur un nom exact ne couvre pas ses variantes (suffixe de cadence, identifiant). Pour le protéger : @RealtimeChannel("${channelFamily(channel)}", { roles: [ … ] }), ou vérifier les droits dans createRealtimeChannel().`);
795
796
  }
796
797
  /**
797
798
  * Enregistre la **factory d'un canal système** (plateforme) — un module bas
@@ -921,5 +922,4 @@ let _hub = null;
921
922
  function getRealtimeHub() {
922
923
  return _hub ??= new RealtimeHub();
923
924
  }
924
- //#endregion
925
925
  export { RESERVED_SYSTEM_PREFIXES, REVOCATION_REVALIDATE_MS, RealtimeHub, RealtimeHub as default, SLOW_CONSUMER_BYTES, channelFamily, getRealtimeHub, isReservedSystemChannel };
@@ -1,5 +1,5 @@
1
1
  import { getRealtimeHub } from "./RealtimeHub.js";
2
- //#region nodefony/src/server/ServerRealtimeSocket.ts
2
+ // nodefony/src/server/ServerRealtimeSocket.ts
3
3
  /**
4
4
  * **L4 — façade serveur « la socket Nodefony ».** Un handle SERVEUR qui implémente
5
5
  * le contrat ISOMORPHE {@link IRealtimeSocket} au-dessus du {@link RealtimeHub} : un
@@ -17,8 +17,9 @@ import { getRealtimeHub } from "./RealtimeHub.js";
17
17
  * l'état créé est passif, et c'est la fabrique du controller qui tranchera quand
18
18
  * une connexion cliente le demandera. Un service n'invente donc jamais un canal
19
19
  * pour les autres.
20
- * - `request` : **non supporté** — un handle au-dessus du hub n'a PAS de pair unique
21
- * (le hub est multi-clients). Pour un RPC serveur→client 1-1, utiliser
20
+ * - `request` : **non supporté, et déprécié** — un handle au-dessus du hub n'a PAS de
21
+ * pair unique (le hub est multi-clients), et le contrat commun `IRealtimeSocket` ne
22
+ * le promet plus. Pour un RPC serveur→client 1-1, utiliser
22
23
  * `RealtimeController.requestClient` (par connexion, L1).
23
24
  *
24
25
  * Lazy : aucune structure allouée tant que le service n'`on`/`subscribe`/`publish` pas.
@@ -72,11 +73,17 @@ var ServerRealtimeSocket = class {
72
73
  sinks.delete(c);
73
74
  }
74
75
  }
75
- /** Branche un handler de réception sur un canal. Renvoie un `dispose`. */
76
+ /**
77
+ * Branche un handler de réception sur un canal. Renvoie un `dispose`.
78
+ *
79
+ * Le handler typé par `Listen` se range sans conversion sous
80
+ * `RealtimeHandler`, qui porte la frontière du contrat typé : il reçoit ce
81
+ * que le hub publie, dont `Listen` décrit la forme sans la valider.
82
+ */
76
83
  on(channel, handler) {
77
84
  return this.#addHandler(channel, handler);
78
85
  }
79
- /** Retire un handler d'un canal. */
86
+ /** Retire un handler d'un canal — même frontière que {@link on}. */
80
87
  off(channel, handler) {
81
88
  this.#removeHandler(channel, handler);
82
89
  }
@@ -96,9 +103,12 @@ var ServerRealtimeSocket = class {
96
103
  this.#handlers?.get(channel)?.delete(handler);
97
104
  }
98
105
  /**
99
- * **Non supporté.** Un handle au-dessus du hub n'a pas de pair unique
100
- * (multi-clients). Pour un RPC serveur→client 1-1, utiliser
101
- * `RealtimeController.requestClient` (par connexion, L1).
106
+ * **Non supporté** : rejette toujours. Un handle au-dessus du hub n'a pas de pair
107
+ * unique (multi-clients), et le contrat commun `IRealtimeSocket` ne promet plus
108
+ * `request`. Comportement inchangé pour toute la série 10.
109
+ *
110
+ * @deprecated Retrait en 11.0. Pour un RPC serveur→client 1-1, utiliser
111
+ * `RealtimeController.requestClient` (par connexion, L1).
102
112
  */
103
113
  request(_method, _params, _timeoutMs) {
104
114
  return Promise.reject(/* @__PURE__ */ new Error("ServerRealtimeSocket.request: pas de pair unique côté hub (multi-clients). Pour un RPC serveur→client 1-1, utiliser RealtimeController.requestClient."));
@@ -174,5 +184,4 @@ var ServerRealtimeSocket = class {
174
184
  function serverSocket() {
175
185
  return new ServerRealtimeSocket(getRealtimeHub());
176
186
  }
177
- //#endregion
178
187
  export { ServerRealtimeSocket, ServerRealtimeSocket as default, serverSocket };
@@ -1,4 +1,4 @@
1
- //#region nodefony/src/server/deniedDetail.ts
1
+ // nodefony/src/server/deniedDetail.ts
2
2
  /**
3
3
  * Le détail d'un `realtime:denied` — dit au développeur, tu à l'utilisateur.
4
4
  *
@@ -25,5 +25,4 @@ function deniedDetail(env, detail) {
25
25
  if (!env || env === "production") return {};
26
26
  return { detail };
27
27
  }
28
- //#endregion
29
28
  export { deniedDetail };
@@ -0,0 +1,29 @@
1
+ // nodefony/src/server/subscribedAck.ts
2
+ /**
3
+ * Trame d'acquittement d'abonnement — `realtime:subscribed`, écrite d'un bloc.
4
+ *
5
+ * Tout ce qui précède le nom du canal ne dépend que du protocole : il est
6
+ * calculé une fois au chargement du module. Seul le canal se sérialise par
7
+ * trame. On évite ainsi l'enveloppe allouée et le `JSON.stringify` générique
8
+ * de `JsonRpcPeer.notify` — mesuré à ~0,5 µs par acquittement, soit l'essentiel
9
+ * du surcoût d'un `subscribe` acquitté.
10
+ *
11
+ * ⚠️ C'est une SECONDE écriture du format de notification : son égalité avec
12
+ * `JsonRpcPeer.buildNotification` est verrouillée par `subscribedAck.test.ts`,
13
+ * sur des noms de canal hostiles (guillemets, barres obliques inverses,
14
+ * caractères de contrôle, Unicode).
15
+ *
16
+ * @internal
17
+ */
18
+ /** Le préfixe invariant de la trame, jusqu'à la valeur `channel` exclue. */
19
+ const SUBSCRIBED_FRAME_PREFIX = "{\"jsonrpc\":\"2.0\",\"method\":\"realtime:subscribed\",\"params\":{\"channel\":";
20
+ /**
21
+ * Sérialise la notification `realtime:subscribed { channel }`.
22
+ *
23
+ * @param channel - le canal acquitté (entrée réseau : échappé par `JSON.stringify`).
24
+ * @returns la trame JSON-RPC 2.0 prête à écrire sur le transport.
25
+ */
26
+ function subscribedAckFrame(channel) {
27
+ return SUBSCRIBED_FRAME_PREFIX + JSON.stringify(channel) + "}}";
28
+ }
29
+ export { subscribedAckFrame };
@@ -1,6 +1,6 @@
1
1
  import { isRecord } from "../guards.js";
2
2
  import { BROWSER_ORIGIN, Pdu } from "nodefony";
3
- //#region nodefony/src/server/syslogUplink.ts
3
+ // nodefony/src/server/syslogUplink.ts
4
4
  /**
5
5
  * Réception des journaux **remontés par un navigateur** : le pendant serveur du
6
6
  * transport client (`nodefony/client` → `installSyslogUplink`).
@@ -103,5 +103,4 @@ function createSyslogUplinkHandler(opts) {
103
103
  }
104
104
  };
105
105
  }
106
- //#endregion
107
106
  export { BROWSER_ORIGIN, MAX_CLIENT_SEVERITY, createSyslogUplinkHandler };
@@ -1,4 +1,4 @@
1
- //#region nodefony/src/server/welcomeEnv.ts
1
+ // nodefony/src/server/welcomeEnv.ts
2
2
  /**
3
3
  * Le mode d'exécution annoncé — ou non — dans `realtime:welcome`.
4
4
  *
@@ -21,5 +21,4 @@ function welcomeEnv(env) {
21
21
  if (!env || env === "production") return {};
22
22
  return { env };
23
23
  }
24
- //#endregion
25
24
  export { welcomeEnv };
@@ -2,7 +2,7 @@ import { isRealtimeConfig } from "../../config/defineModuleConfig.js";
2
2
  import { RESERVED_SYSTEM_PREFIXES, getRealtimeHub } from "../server/RealtimeHub.js";
3
3
  import { applyDeclaredBroadcastPrefixes } from "../../decorators/realtimeDecorators.js";
4
4
  import { Service } from "nodefony";
5
- //#region nodefony/src/service/RealtimeService.ts
5
+ // nodefony/src/service/RealtimeService.ts
6
6
  const serviceName = "realtimeService";
7
7
  /**
8
8
  * Façade DI publique de la **socket Nodefony** côté serveur.
@@ -238,5 +238,4 @@ function buildOriginGuard(config) {
238
238
  return allowSet.has(origin);
239
239
  };
240
240
  }
241
- //#endregion
242
241
  export { RealtimeService, RealtimeService as default };
@@ -1,6 +1,6 @@
1
1
  import { TransportState } from "nodefony";
2
2
  import { decideSend } from "@nodefony/http";
3
- //#region nodefony/src/transport/WsConnectionTransport.ts
3
+ // nodefony/src/transport/WsConnectionTransport.ts
4
4
  /**
5
5
  * Contre-pression WS — **la règle vit dans `@nodefony/http`**, ce transport ne
6
6
  * fait que l'appliquer (`decideSend`). Deux implémentations de la même
@@ -31,6 +31,8 @@ var WsConnectionTransport = class {
31
31
  _bytesSent = 0;
32
32
  _messagesSent = 0;
33
33
  _dropped = 0;
34
+ _binaryDiscarded = 0;
35
+ _malformedDiscarded = 0;
34
36
  _max;
35
37
  _policy;
36
38
  _closeAfterDrops;
@@ -51,6 +53,45 @@ var WsConnectionTransport = class {
51
53
  this._messagesSent += 1;
52
54
  this.conn.send(raw, () => {});
53
55
  }
56
+ /**
57
+ * Émet des octets tels quels — trame binaire RFC 6455, hors JSON-RPC (#617).
58
+ * Même contre-pression (`decideSend`, la règle unique) et mêmes compteurs que
59
+ * {@link send}. Écrite à part, et non en factorisant `send` : `send` est le
60
+ * chemin de CHAQUE frame JSON-RPC, et n'y paie pas un appel de plus.
61
+ *
62
+ * @param data - les octets à émettre.
63
+ * @returns `true` si la trame est partie, `false` si la connexion était
64
+ * fermée ou la contre-pression l'a jetée.
65
+ */
66
+ sendBinary(data) {
67
+ if (this.conn.readyState !== TransportState.OPEN) return false;
68
+ if (decideSend(this.conn, this._max, this._policy, this._closeAfterDrops) !== "send") {
69
+ this._dropped += 1;
70
+ return false;
71
+ }
72
+ this._bytesSent += data.byteLength;
73
+ this._messagesSent += 1;
74
+ this.conn.send(data, () => {});
75
+ return true;
76
+ }
77
+ /**
78
+ * Compte une trame ENTRANTE écartée et rend le cumul de sa nature — `1` dit
79
+ * « première », le seul instant où l'appelant journalise.
80
+ *
81
+ * @param kind - `binary` (sans lecteur) ou `malformed` (texte non JSON).
82
+ * @returns le cumul de cette nature, après incrément.
83
+ */
84
+ discard(kind) {
85
+ return kind === "binary" ? ++this._binaryDiscarded : ++this._malformedDiscarded;
86
+ }
87
+ /** Cumul de trames BINAIRES reçues et écartées faute de lecteur — monotone. */
88
+ get binaryDiscarded() {
89
+ return this._binaryDiscarded;
90
+ }
91
+ /** Cumul de trames TEXTE reçues et écartées parce que non JSON — monotone. */
92
+ get malformedDiscarded() {
93
+ return this._malformedDiscarded;
94
+ }
54
95
  close(code, reason) {
55
96
  this.conn.close(code, reason);
56
97
  }
@@ -90,5 +131,4 @@ var WsConnectionTransport = class {
90
131
  this._onClose?.(code, reason);
91
132
  }
92
133
  };
93
- //#endregion
94
134
  export { WsConnectionTransport, WsConnectionTransport as default };