@nodefony/realtime 10.0.0-alpha.9 → 10.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -99,7 +99,8 @@ function openBackplaneEnvelope(raw, secret) {
99
99
  if (!secret) return msg;
100
100
  const provided = parsed[SEAL_FIELD];
101
101
  if (typeof provided !== "string") return null;
102
- const expected = seal(JSON.stringify(msg.originId), JSON.stringify(msg.channel), JSON.stringify(msg.payload) ?? "null", secret);
102
+ const payloadText = JSON.stringify(msg.payload);
103
+ const expected = seal(JSON.stringify(msg.originId), JSON.stringify(msg.channel), payloadText ?? "null", secret);
103
104
  const a = Buffer.from(provided, "base64url");
104
105
  const b = Buffer.from(expected, "base64url");
105
106
  if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
@@ -136,7 +136,7 @@ var ClusterProbeClient = class {
136
136
  #report(buildOwn) {
137
137
  const payload = buildOwn();
138
138
  if (this.#richEnabled) {
139
- if (this.#richProbe === null) this.#richProbe = new RichProcessProbe();
139
+ this.#richProbe ??= new RichProcessProbe();
140
140
  payload.rich = this.#richProbe.read();
141
141
  }
142
142
  if (this.#ormRich !== null) payload.ormRich = this.#ormRich;
@@ -5,7 +5,7 @@ import { createSyslogUplinkHandler } from "./syslogUplink.js";
5
5
  import { WsConnectionTransport } from "../transport/WsConnectionTransport.js";
6
6
  import { welcomeEnv } from "./welcomeEnv.js";
7
7
  import { deniedDetail } from "./deniedDetail.js";
8
- import { JsonRpcPeer, PLATFORM_INBOUND, RequestContext, RpcEnvelope, RpcError, identityHint } from "nodefony";
8
+ import { BootConfigurationError, JsonRpcPeer, PLATFORM_INBOUND, RequestContext, RpcEnvelope, RpcError, Scope, identityHint } from "nodefony";
9
9
  import { readBackpressureOptions } from "@nodefony/http";
10
10
  import { Controller } from "@nodefony/framework";
11
11
  //#region nodefony/src/server/RealtimeController.ts
@@ -77,6 +77,36 @@ let warnedUnenforcedPolicies = false;
77
77
  * d'action n'est payé que sur une requête `id`. C'est aussi le seam du backplane Redis.
78
78
  */
79
79
  var RealtimeController = class extends Controller {
80
+ /**
81
+ * Une instance PAR CONNEXION, par construction — le défaut `singleton` des
82
+ * contrôleurs ne s'applique pas ici. Le pair JSON-RPC de la connexion capture
83
+ * l'instance du handshake, et {@link notifyClient} / {@link requestClient}
84
+ * désignent « CETTE connexion » par l'instance : partagée, ils liraient
85
+ * l'ALS de celui qui ÉMET (un écouteur du hub, un minuteur), donc une autre
86
+ * connexion, ou aucune. Cf {@link assertScope}.
87
+ */
88
+ static scope = "request";
89
+ /**
90
+ * Refuse qu'une sous-classe se déclare singleton — au démarrage
91
+ * (`Router.setController`), et au constructeur pour qui construit par `new`
92
+ * hors du routeur (harnais de test). Sans ce refus, la socket s'ouvrirait
93
+ * sans jamais émettre `realtime:welcome` : une panne indiscernable d'un
94
+ * incident réseau.
95
+ *
96
+ * @throws BootConfigurationError si la classe se déclare `@Scope("singleton")`.
97
+ */
98
+ static assertScope() {
99
+ 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).`);
100
+ }
101
+ /**
102
+ * @param name - nom du service (celui de la classe, par convention).
103
+ * @param context - le contexte de la connexion.
104
+ * @throws BootConfigurationError si la classe se déclare singleton.
105
+ */
106
+ constructor(name, context) {
107
+ new.target.assertScope();
108
+ super(name, context);
109
+ }
80
110
  /**
81
111
  * Map des factories de canaux EXACT, lue depuis les décorateurs `@RealtimeChannel`
82
112
  * au handshake (cold-path) et mémoïsée par instance. `null` = pas de décorateur
@@ -161,7 +191,10 @@ var RealtimeController = class extends Controller {
161
191
  const ctx = this.context;
162
192
  if (!ctx) return;
163
193
  if (message == null) {
164
- this.onHandshake(ctx);
194
+ this.onHandshake(ctx).catch((e) => {
195
+ this.log(`WS realtime handshake failed: ${e instanceof Error ? e.message : String(e)}`, "ERROR");
196
+ ctx.connection?.close(1011, "internal error");
197
+ });
165
198
  return;
166
199
  }
167
200
  ctx.__nfRealtime?.transport.feed(message.toString());
@@ -224,7 +257,7 @@ var RealtimeController = class extends Controller {
224
257
  }
225
258
  const authenticator = hub.resolveAuthenticator(handshake);
226
259
  let token;
227
- if (authenticator !== null && authenticator.supports(handshake)) try {
260
+ if (authenticator?.supports(handshake)) try {
228
261
  token = await authenticator.authenticate(handshake);
229
262
  authenticator.onSuccess?.(handshake, token);
230
263
  } catch (e) {
@@ -301,13 +334,17 @@ var RealtimeController = class extends Controller {
301
334
  this._decoratedChannels = getRealtimeChannels(this);
302
335
  const channelPolicies = getRealtimeChannelPolicies(this);
303
336
  if (channelPolicies) {
304
- for (const name in channelPolicies) hub.registerChannelPolicy(name, channelPolicies[name]);
337
+ for (const [name, policy] of Object.entries(channelPolicies)) hub.registerChannelPolicy(name, policy);
305
338
  if (hub.hasUnenforcedChannelPolicies() && !warnedUnenforcedPolicies) {
306
339
  warnedUnenforcedPolicies = true;
307
340
  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");
308
341
  }
309
342
  }
310
- hub.onPlatformNotice((message, severity) => this.log(message, severity));
343
+ const noticeSyslog = this.syslog;
344
+ const noticeName = this.name;
345
+ hub.onPlatformNotice((message, severity) => {
346
+ noticeSyslog?.log(message, severity, noticeName);
347
+ });
311
348
  hub.registerConnection(transport);
312
349
  let revocable = null;
313
350
  if (typeof token.isValid === "function") {
@@ -318,7 +355,7 @@ var RealtimeController = class extends Controller {
318
355
  hub.registerRevocable(revocable);
319
356
  }
320
357
  const broadcast = this.realtimeBroadcastChannels();
321
- for (let i = 0; i < broadcast.length; i++) hub.markBroadcastChannel(broadcast[i]);
358
+ for (const channel of broadcast) hub.markBroadcastChannel(channel);
322
359
  const decoratedInbound = getRealtimeInbound(this);
323
360
  const overrideInbound = this.realtimeInbound();
324
361
  const inboundMap = {
@@ -326,7 +363,7 @@ var RealtimeController = class extends Controller {
326
363
  ...overrideInbound
327
364
  };
328
365
  const clientLogs = hub.clientLogsLimits;
329
- if (clientLogs !== null && inboundMap[PLATFORM_INBOUND.syslogUplink] === void 0) {
366
+ if (clientLogs !== null && !Object.hasOwn(inboundMap, PLATFORM_INBOUND.syslogUplink)) {
330
367
  const syslog = this.syslog;
331
368
  if (syslog) inboundMap[PLATFORM_INBOUND.syslogUplink] = createSyslogUplinkHandler({
332
369
  syslog,
@@ -342,7 +379,7 @@ var RealtimeController = class extends Controller {
342
379
  inbound
343
380
  };
344
381
  holder.__nfRealtime = state;
345
- ctx.once?.("onFinish", () => {
382
+ ctx.once("onFinish", () => {
346
383
  const liveHub = getRealtimeHub();
347
384
  for (const [channel, sink] of state.channels) liveHub.unsubscribe(channel, sink);
348
385
  state.channels.clear();
@@ -412,6 +449,7 @@ var RealtimeController = class extends Controller {
412
449
  else state.peer.notify(channel, payload);
413
450
  };
414
451
  let servedByPattern = false;
452
+ const isServedByPattern = () => servedByPattern;
415
453
  if (getRealtimeHub().subscribeClient(channel, sink, (ch, publish) => {
416
454
  const decFactory = this._decoratedChannels?.[ch];
417
455
  if (decFactory) return decFactory(ch, publish);
@@ -420,7 +458,7 @@ var RealtimeController = class extends Controller {
420
458
  return dispose;
421
459
  }, (payload) => JSON.stringify(JsonRpcPeer.buildNotification(channel, payload)))) {
422
460
  state.channels.set(channel, sink);
423
- if (servedByPattern) getRealtimeHub().noticeUnguardedDynamicChannel(channel);
461
+ if (isServedByPattern()) getRealtimeHub().noticeUnguardedDynamicChannel(channel);
424
462
  this.log(`WS subscribe → ${channel}`, "DEBUG");
425
463
  return;
426
464
  }
@@ -505,6 +543,8 @@ var RealtimeController = class extends Controller {
505
543
  user: token.getAttribute("user"),
506
544
  userId: token.getUserIdentifier(),
507
545
  context: ctx,
546
+ resolver,
547
+ scope: ctx.container instanceof Scope ? ctx.container : void 0,
508
548
  body: p?.body,
509
549
  idempotencyKey: typeof p?.idempotencyKey === "string" ? p.idempotencyKey : void 0,
510
550
  renderSink,
@@ -542,7 +582,8 @@ var RealtimeController = class extends Controller {
542
582
  stopChannel(ctx, channel) {
543
583
  if (!channel) return;
544
584
  const state = ctx.__nfRealtime;
545
- const sink = state?.channels.get(channel);
585
+ if (!state) return;
586
+ const sink = state.channels.get(channel);
546
587
  if (sink) {
547
588
  getRealtimeHub().unsubscribe(channel, sink);
548
589
  state.channels.delete(channel);
@@ -564,7 +605,7 @@ var RealtimeController = class extends Controller {
564
605
  function buildHandshakeFromContext(ctx) {
565
606
  const headers = ctx.request?.headers ?? {};
566
607
  const cookies = /* @__PURE__ */ new Map();
567
- const rawCookies = ctx.cookies ?? {};
608
+ const rawCookies = ctx.cookies;
568
609
  for (const name in rawCookies) {
569
610
  const c = rawCookies[name];
570
611
  if (c && typeof c.value === "string") cookies.set(name, c.value);
@@ -273,14 +273,15 @@ var RealtimeHub = class RealtimeHub {
273
273
  * et retire le canal (libère timers/listeners). No-op si non abonné.
274
274
  */
275
275
  unsubscribe(channel, sink) {
276
- const st = this.#channels?.get(channel);
277
- if (!st) return;
276
+ const channels = this.#channels;
277
+ const st = channels?.get(channel);
278
+ if (!channels || !st) return;
278
279
  st.sinks.delete(sink);
279
280
  if (st.sinks.size === 0) {
280
281
  try {
281
282
  st.dispose?.();
282
283
  } catch {}
283
- this.#channels.delete(channel);
284
+ channels.delete(channel);
284
285
  }
285
286
  }
286
287
  /**
@@ -356,7 +357,7 @@ var RealtimeHub = class RealtimeHub {
356
357
  #isBroadcast(channel) {
357
358
  const prefixes = this.#broadcastPrefixes;
358
359
  if (prefixes === null) return false;
359
- for (let i = 0; i < prefixes.length; i++) if (channel.startsWith(prefixes[i])) return true;
360
+ for (const prefix of prefixes) if (channel.startsWith(prefix)) return true;
360
361
  return false;
361
362
  }
362
363
  /**
@@ -372,7 +373,11 @@ var RealtimeHub = class RealtimeHub {
372
373
  setBackplane(backplane) {
373
374
  this.#backplane = backplane;
374
375
  backplane.onMessage((msg) => this.#admitFromBackplane(msg));
375
- backplane.start();
376
+ const started = backplane.start();
377
+ if (started instanceof Promise) started.catch((err) => {
378
+ if (this.#notice === null) throw err instanceof Error ? err : new Error(String(err));
379
+ this.#notifyOnce("backplane:start", `realtime: démarrage du backplane en échec — les autres pairs ne recevront rien (${err instanceof Error ? err.message : String(err)})`);
380
+ });
376
381
  return backplane;
377
382
  }
378
383
  /**
@@ -433,7 +438,7 @@ var RealtimeHub = class RealtimeHub {
433
438
  this.#revalidateTimer = setInterval(() => {
434
439
  this.revalidateRevocable();
435
440
  }, REVOCATION_REVALIDATE_MS);
436
- this.#revalidateTimer.unref?.();
441
+ this.#revalidateTimer.unref();
437
442
  }
438
443
  }
439
444
  /**
@@ -557,7 +562,7 @@ var RealtimeHub = class RealtimeHub {
557
562
  useAuthenticator(matcher, authenticator) {
558
563
  const list = this.#authenticators ??= [];
559
564
  const compiled = compileMatcher(matcher);
560
- for (let i = 0; i < list.length; i++) if (list[i].authenticator === authenticator) return;
565
+ for (const entry of list) if (entry.authenticator === authenticator) return;
561
566
  list.push({
562
567
  matcher: compiled,
563
568
  authenticator
@@ -571,10 +576,7 @@ var RealtimeHub = class RealtimeHub {
571
576
  resolveAuthenticator(handshake) {
572
577
  const list = this.#authenticators;
573
578
  if (list === null) return null;
574
- for (let i = 0; i < list.length; i++) {
575
- const entry = list[i];
576
- if (entry.matcher.match(handshake)) return entry.authenticator;
577
- }
579
+ for (const entry of list) if (entry.matcher.match(handshake)) return entry.authenticator;
578
580
  return null;
579
581
  }
580
582
  /**
@@ -781,7 +783,7 @@ var RealtimeHub = class RealtimeHub {
781
783
  if (exact !== void 0) return exact;
782
784
  const patterns = this.#channelPolicyPatterns;
783
785
  if (patterns === null) return null;
784
- for (let i = 0; i < patterns.length; i++) if (patterns[i].re.test(name)) return patterns[i].policy;
786
+ for (const pattern of patterns) if (pattern.re.test(name)) return pattern.policy;
785
787
  return null;
786
788
  }
787
789
  /** Des politiques de canal sont-elles déclarées (au moins une) ? */
@@ -57,17 +57,19 @@ var ServerRealtimeSocket = class {
57
57
  /** Désabonne (ref-compté) : coupe le sink du hub au DERNIER consommateur. */
58
58
  unsubscribe(channel) {
59
59
  const c = channel;
60
- const cur = this.#subs?.get(c);
61
- if (!cur) return;
60
+ const subs = this.#subs;
61
+ const cur = subs?.get(c);
62
+ if (subs === null || cur === void 0 || cur === 0) return;
62
63
  if (cur > 1) {
63
- this.#subs.set(c, cur - 1);
64
+ subs.set(c, cur - 1);
64
65
  return;
65
66
  }
66
- this.#subs.delete(c);
67
- const sink = this.#sinks?.get(c);
68
- if (sink) {
67
+ subs.delete(c);
68
+ const sinks = this.#sinks;
69
+ const sink = sinks?.get(c);
70
+ if (sinks && sink) {
69
71
  this.hub.unsubscribe(c, sink);
70
- this.#sinks.delete(c);
72
+ sinks.delete(c);
71
73
  }
72
74
  }
73
75
  /** Branche un handler de réception sur un canal. Renvoie un `dispose`. */
@@ -54,8 +54,9 @@ var RealtimeService = class extends Service {
54
54
  * par la Module class). Aucun warn — c'est le cas commun.
55
55
  */
56
56
  async init(_module) {
57
- this.#config = this.module.config;
58
- if (!this.#config) throw new Error(`${serviceName}: realtimeConfig absente (this.module.config vide) — la Module class doit la valider à onKernelRegister`);
57
+ const config = this.module.config;
58
+ if (!config) throw new Error(`${serviceName}: realtimeConfig absente (this.module.config vide) — la Module class doit la valider à onKernelRegister`);
59
+ this.#config = config;
59
60
  if (!this.#config.enabled) {
60
61
  this.log("realtime enabled:false → service inerte (hub non câblé)", "INFO");
61
62
  return this;
@@ -227,10 +228,10 @@ var RealtimeService = class extends Service {
227
228
  * `allowList` (pas de wildcard).
228
229
  */
229
230
  function buildOriginGuard(config) {
230
- const c = config.csrf?.checkOrigin;
231
- if (!c || c.enabled !== true) return null;
232
- const allowSet = new Set(c.allowList ?? []);
233
- const allowMissing = c.allowMissingOrigin === true;
231
+ const c = config.csrf.checkOrigin;
232
+ if (!c.enabled) return null;
233
+ const allowSet = new Set(c.allowList);
234
+ const allowMissing = c.allowMissingOrigin;
234
235
  return (origin) => {
235
236
  if (origin === void 0 || origin === "") return allowMissing;
236
237
  return allowSet.has(origin);
@@ -98,11 +98,13 @@ function createRealtimeHarness(create, options = {}) {
98
98
  hub.onPlatformNotice((message) => {
99
99
  notices.push(message);
100
100
  });
101
- if (!await waitFor(() => received.some((f) => f.method === "realtime:welcome"), 10)) {
101
+ await waitFor(() => received.some((f) => f.method === "realtime:welcome"), 10);
102
+ const welcome = received.find((f) => f.method === "realtime:welcome");
103
+ if (!welcome) {
102
104
  const cause = closes.length > 0 ? `socket fermée (code ${String(closes[0]?.code)})` : "aucune frame émise";
103
105
  throw new Error(`createRealtimeHarness: pas de realtime:welcome — ${cause}. Vérifier l'Origin (seam #4) et l'authenticator (seam #2).`);
104
106
  }
105
- return received.find((f) => f.method === "realtime:welcome");
107
+ return welcome;
106
108
  },
107
109
  async subscribe(channel) {
108
110
  await feed(rpc({
@@ -44,6 +44,6 @@ export type IRealtimeConfigInput = z.input<typeof realtimeConfigSchema>;
44
44
  /** Config normalisée et gelée (sortie du builder, lue par `RealtimeService`). */
45
45
  export type IRealtimeConfig = IRealtimeSchemaConfig & {
46
46
  backplane: IRealtimeSchemaConfig["backplane"] & {
47
- instance?: IBackplane;
47
+ instance?: IBackplane | undefined;
48
48
  };
49
49
  };
@@ -19,10 +19,10 @@
19
19
  */
20
20
  export interface IChannelPolicy {
21
21
  /** Exige une connexion authentifiée (token non anonyme). */
22
- readonly authenticated?: boolean;
22
+ readonly authenticated?: boolean | undefined;
23
23
  /** Un de ces rôles suffit (évalué AVEC la hiérarchie de rôles, côté security). */
24
- readonly roles?: readonly string[];
24
+ readonly roles?: readonly string[] | undefined;
25
25
  /** Un de ces scopes suffit (axe API : JWT/clé API ; session BFF n'en porte pas). */
26
- readonly scopes?: readonly string[];
26
+ readonly scopes?: readonly string[] | undefined;
27
27
  }
28
28
  export default IChannelPolicy;
@@ -40,7 +40,7 @@ export interface IRealtimeAuthenticatorMatcher {
40
40
  */
41
41
  export interface ICompiledRealtimeMatcher {
42
42
  readonly pattern: RegExp;
43
- readonly host?: string;
43
+ readonly host?: string | undefined;
44
44
  /** Le matcher capture-t-il ce handshake ? */
45
45
  match(handshake: IRealtimeHandshake): boolean;
46
46
  }
@@ -21,7 +21,7 @@ export interface IRealtimeHandshake {
21
21
  /** IP du client (déjà extraite — derrière proxy : voir `trust proxy`). */
22
22
  readonly remoteAddress: string;
23
23
  /** Origin RFC 6455 §10.2 — `undefined` si en-tête absent (clients non-browser). */
24
- readonly origin?: string;
24
+ readonly origin?: string | undefined;
25
25
  /** Sous-protocoles annoncés (`Sec-WebSocket-Protocol`). `[]` si aucun. */
26
26
  readonly protocols: readonly string[];
27
27
  }
@@ -1,5 +1,7 @@
1
1
  import { type RpcActionHandler, type EventsMap, type ActionsMap, type DefaultEventsMap, type DefaultActionsMap, type EventNames, type EventPayload, type ActionNames, type ActionParams, type ActionResult } from "nodefony";
2
+ import type { ContextType } from "@nodefony/http";
2
3
  import { Controller } from "@nodefony/framework";
4
+ import type { ControllerScope } from "@nodefony/framework";
3
5
  import type { IRealtimeController, RealtimePublish, RealtimeInboundHandler } from "../../interfaces/IRealtimeController.js";
4
6
  /**
5
7
  * RealtimeController — base d'un endpoint WebSocket temps réel SERVEUR (JSON-RPC 2.0).
@@ -24,6 +26,31 @@ import type { IRealtimeController, RealtimePublish, RealtimeInboundHandler } fro
24
26
  * d'action n'est payé que sur une requête `id`. C'est aussi le seam du backplane Redis.
25
27
  */
26
28
  export declare abstract class RealtimeController<Emit extends EventsMap = DefaultEventsMap, Actions extends ActionsMap = DefaultActionsMap> extends Controller implements IRealtimeController {
29
+ /**
30
+ * Une instance PAR CONNEXION, par construction — le défaut `singleton` des
31
+ * contrôleurs ne s'applique pas ici. Le pair JSON-RPC de la connexion capture
32
+ * l'instance du handshake, et {@link notifyClient} / {@link requestClient}
33
+ * désignent « CETTE connexion » par l'instance : partagée, ils liraient
34
+ * l'ALS de celui qui ÉMET (un écouteur du hub, un minuteur), donc une autre
35
+ * connexion, ou aucune. Cf {@link assertScope}.
36
+ */
37
+ static scope: ControllerScope;
38
+ /**
39
+ * Refuse qu'une sous-classe se déclare singleton — au démarrage
40
+ * (`Router.setController`), et au constructeur pour qui construit par `new`
41
+ * hors du routeur (harnais de test). Sans ce refus, la socket s'ouvrirait
42
+ * sans jamais émettre `realtime:welcome` : une panne indiscernable d'un
43
+ * incident réseau.
44
+ *
45
+ * @throws BootConfigurationError si la classe se déclare `@Scope("singleton")`.
46
+ */
47
+ static assertScope(): void;
48
+ /**
49
+ * @param name - nom du service (celui de la classe, par convention).
50
+ * @param context - le contexte de la connexion.
51
+ * @throws BootConfigurationError si la classe se déclare singleton.
52
+ */
53
+ constructor(name: string, context: ContextType);
27
54
  /**
28
55
  * Map des factories de canaux EXACT, lue depuis les décorateurs `@RealtimeChannel`
29
56
  * au handshake (cold-path) et mémoïsée par instance. `null` = pas de décorateur
@@ -55,8 +55,8 @@ export interface IRealtimeFrame {
55
55
  }
56
56
  /** Fermeture demandée par le serveur (code RFC 6455 + raison). */
57
57
  export interface IHarnessClose {
58
- readonly code?: number;
59
- readonly reason?: string;
58
+ readonly code?: number | undefined;
59
+ readonly reason?: string | undefined;
60
60
  }
61
61
  /**
62
62
  * Refus d'abonnement notifié au client (`realtime:denied`) — le contrat de
package/docs/actions.md CHANGED
@@ -136,8 +136,8 @@ l'instance du contrôleur au handshake, ce qui donne accès au noyau, aux servic
136
136
  connexion sans élargir le contrat.
137
137
 
138
138
  **L'endpoint s'annonce lui-même.** La liste des actions exposées voyage dans la frame d'accueil —
139
- `IRealtimeWelcome` (`RealtimeController.ts:8`) — et se lit côté client par
140
- `RealtimeClient.serverMethods` (`RealtimeClient.ts:591`). Une interface n'écrit donc jamais un nom d'action en dur : elle
139
+ `IRealtimeWelcome` (`RealtimeController.ts:12`) — et se lit côté client par
140
+ `RealtimeClient.serverMethods` (`RealtimeClient.ts:597`). Une interface n'écrit donc jamais un nom d'action en dur : elle
141
141
  n'active un bouton que si le serveur a déclaré savoir le servir.
142
142
 
143
143
  **Le compromis, dit franchement** : une action est **un aller-retour**, point. Elle ne diffuse pas,
@@ -277,7 +277,7 @@ Le trajet complet d'une requête, des étapes qu'elle traverse aux branches par
277
277
  | Voie | Quand la choisir |
278
278
  | ------------------------------------------------------ | ----------------------------------------------------------- |
279
279
  | `@RealtimeAction("nom")` (`realtimeDecorators.ts:101`) | le cas courant — un nom fixe, une méthode, déclaratif |
280
- | `realtimeActions()` (`RealtimeController.ts:186`) | la table est **calculée** (noms dynamiques, boucle, config) |
280
+ | `realtimeActions()` (`RealtimeController.ts:236`) | la table est **calculée** (noms dynamiques, boucle, config) |
281
281
 
282
282
  Les deux sont fusionnées au handshake, et **l'override gagne** en cas de conflit de nom : une
283
283
  classe peut ainsi remplacer une action héritée sans toucher au parent
@@ -445,7 +445,7 @@ fois ? »**.
445
445
 
446
446
  Pour les mutations passant par le pont API, la clé n'est pas une convention : elle est **exigée
447
447
  par la signature** de `mutate()` (`RealtimeClient.ts:797`), et c'est la garde `@Idempotent`
448
- (`routerDecorators.ts:1142`) qui, côté serveur, reconnaît le rejeu et rend la réponse déjà calculée
448
+ (`routerDecorators.ts:1171`) qui, côté serveur, reconnaît le rejeu et rend la réponse déjà calculée
449
449
  au lieu de refaire l'effet.
450
450
 
451
451
  ```ts ignore
@@ -510,8 +510,8 @@ il sera conçu avec son premier consommateur réel.
510
510
  Un cas particulier mérite d'être connu avant d'écrire une action : **elle existe peut-être déjà en
511
511
  HTTP**. Le pont API expose la méthode `api.request`, qui rejoue une route de contrôleur sur la
512
512
  socket, avec la même garde et le même résultat qu'en REST — `invokeApiRequest()`
513
- (`RealtimeController.ts:878`). Il est **désactivé par défaut** et s'active en surchargeant
514
- `realtimeApiRequest()` (`RealtimeController.ts:219`).
513
+ (`RealtimeController.ts:949`). Il est **désactivé par défaut** et s'active en surchargeant
514
+ `realtimeApiRequest()` (`RealtimeController.ts:276`).
515
515
 
516
516
  ```ts ignore
517
517
  const modules = await socket.request("/nodefony/kernel/api/modules");
@@ -126,7 +126,7 @@ Nodefony étiquette avec **JSON-RPC 2.0**, une norme publique plutôt qu'un form
126
126
  Le point remarquable : le **même** moteur de protocole tourne des deux côtés du fil. La
127
127
  classe `JsonRpcPeer` (`JsonRpcPeer.ts:271`) est du code isomorphe du cœur — le navigateur
128
128
  l'exécute dans `RealtimeClient`, le serveur l'instancie une fois par connexion dans
129
- `RealtimeController.onHandshake()` (`RealtimeController.ts:328`). Le serveur peut donc
129
+ `RealtimeController.onHandshake()` (`RealtimeController.ts:374`). Le serveur peut donc
130
130
  appeler le client, pas seulement l'inverse : c'est du vrai duplex, pas un aller-retour
131
131
  déguisé.
132
132
 
@@ -141,7 +141,7 @@ Trois partis pris distinguent cette pile d'un simple « serveur WebSocket ».
141
141
  et explicite : `ServerRealtimeSocket.request()` (`ServerRealtimeSocket.ts:131`) **rejette
142
142
  toujours** — un handle posé sur le hub n'a pas d'interlocuteur unique, puisque le hub est
143
143
  multi-clients. Pour un appel serveur→client ciblé, on passe par la connexion :
144
- `RealtimeController.requestClient()` (`RealtimeController.ts:279`).
144
+ `RealtimeController.requestClient()` (`RealtimeController.ts:329`).
145
145
 
146
146
  **Un provider par canal, pas un par client.** Si mille onglets s'abonnent au même canal de
147
147
  santé, le calcul ne doit tourner qu'une fois. Le hub crée le producteur au **premier**
@@ -325,7 +325,7 @@ La pile se lit de haut en bas ; chaque étage ne connaît que son voisin du dess
325
325
  | Protocole | `JsonRpcPeer` (`JsonRpcPeer.ts:271`) | étiqueter, corréler, refuser une frame | par où passent les octets |
326
326
  | Transport | `WsConnectionTransport` (`WsConnectionTransport.ts:46`) | déplacer des octets, mesurer la file | ce que veut dire un message |
327
327
  | Hub | `RealtimeHub` (`RealtimeHub.ts:213`) | table des abonnés locaux + fan-out | qu'il existe d'autres process |
328
- | Backplane | `IBackplane` (`IBackplane.ts:75`) | porter un message aux autres process | ce qu'est un abonné |
328
+ | Backplane | `IBackplane` (`IBackplane.ts:107`) | porter un message aux autres process | ce qu'est un abonné |
329
329
 
330
330
  > [!NOTE]
331
331
  > Une part de la documentation historique décrit cette pile en « 5 étages » en comptant
@@ -388,7 +388,7 @@ Le schéma ci-dessous rend ces étages vivants : active le temps réel et il res
388
388
 
389
389
  ## 🔌 Le cycle de vie d'une connexion
390
390
 
391
- Tout se joue dans `RealtimeController.onHandshake()` (`RealtimeController.ts:328`), appelé
391
+ Tout se joue dans `RealtimeController.onHandshake()` (`RealtimeController.ts:374`), appelé
392
392
  une seule fois par connexion, en chemin froid.
393
393
 
394
394
  ```mermaid
@@ -419,19 +419,19 @@ sequenceDiagram
419
419
 
420
420
  Les étapes, dans l'ordre exact du code :
421
421
 
422
- 1. **Contrôle de l'origine** — `RealtimeHub.checkOrigin()` (`RealtimeHub.ts:970`). Refus →
422
+ 1. **Contrôle de l'origine** — `RealtimeHub.checkOrigin()` (`RealtimeHub.ts:986`). Refus →
423
423
  fermeture `4003`. La politique vient de la configuration ; sans politique, tout passe.
424
424
  2. **Résolution de l'identité** — `RealtimeHub.resolveAuthenticator()`
425
- (`RealtimeHub.ts:945`) parcourt les authentificateurs enregistrés : **le premier motif
425
+ (`RealtimeHub.ts:961`) parcourt les authentificateurs enregistrés : **le premier motif
426
426
  qui correspond capture**. Aucun ne correspond ? Le jeton anonyme gelé est posé
427
427
  (`ANONYMOUS_REALTIME_TOKEN`) — la lecture d'identité ne rend donc jamais `null`. Un
428
428
  échec d'authentification ferme en `4001`.
429
429
  3. **Création du peer et du transport**, puis association `peer → jeton`
430
- (`RealtimeHub.setTokenForPeer()`, `RealtimeHub.ts:1002`), stockée dans une `WeakMap` : le
430
+ (`RealtimeHub.setTokenForPeer()`, `RealtimeHub.ts:1018`), stockée dans une `WeakMap` : le
431
431
  jeton disparaît avec le peer, sans fuite.
432
432
  4. **Enregistrement des actions** — celles des décorateurs `@RealtimeAction`, puis celles
433
433
  de la surcharge `realtimeActions()`, qui gagne en cas de conflit. Le pont API
434
- `api.request` n'est ajouté que si `realtimeApiRequest()` (`RealtimeController.ts:219`)
434
+ `api.request` n'est ajouté que si `realtimeApiRequest()` (`RealtimeController.ts:276`)
435
435
  rend `true`.
436
436
  5. **Inscription aux registres** : sonde de connexion, révocation périodique si le jeton
437
437
  est révocable, préfixes de canaux broadcast, canaux entrants.
@@ -439,7 +439,7 @@ Les étapes, dans l'ordre exact du code :
439
439
  l'identité résolue (type, authentifié ou non, rôles, portées). Le client sait **qui il
440
440
  est** sans appeler la moindre route.
441
441
 
442
- À la fermeture, un unique `onFinish` (`RealtimeController.ts:578`) fait le ménage complet :
442
+ À la fermeture, un unique `onFinish` (`RealtimeController.ts:715`) fait le ménage complet :
443
443
  désabonnement de chaque canal tenu, retrait des deux registres, `fireClose()` du transport,
444
444
  `dispose()` du peer. C'est ce qui garantit qu'aucun minuteur ni écouteur ne survit à une
445
445
  déconnexion.
@@ -459,13 +459,13 @@ déconnexion.
459
459
  La seconde mérite une explication. Le verrou de frame est synchrone et lit une identité
460
460
  figée au handshake : il ne peut donc pas voir une session qui meurt en cours de route (une
461
461
  déconnexion HTTP, par exemple). Un minuteur — démarré au premier inscrit, arrêté dès que le
462
- registre se vide (`RealtimeHub.registerRevocable()`, `RealtimeHub.ts:778`) — relit
462
+ registre se vide (`RealtimeHub.registerRevocable()`, `RealtimeHub.ts:794`) — relit
463
463
  périodiquement ces identités et coupe les sockets orphelines. Seules les identités
464
464
  **révocables** y entrent : un visiteur anonyme ne coûte rien.
465
465
 
466
466
  ## Le hub — canaux partagés et fan-out
467
467
 
468
- Le hub est un singleton par process (`getRealtimeHub()`, `RealtimeHub.ts:1435`). Il ne
468
+ Le hub est un singleton par process (`getRealtimeHub()`, `RealtimeHub.ts:1451`). Il ne
469
469
  dépend de rien : ce sont les fabriques fournies par les contrôleurs qui portent les
470
470
  dépendances.
471
471
 
@@ -478,7 +478,7 @@ dépendances.
478
478
  paquet du producteur atteigne bien ce premier abonné.
479
479
  3. La fabrique du contrôleur rend `null` (canal inconnu de lui) ? Dernier recours : le
480
480
  registre des **canaux système** (`RealtimeHub.registerSystemChannel()`,
481
- `RealtimeHub.ts:1281`), qu'un module bas niveau alimente au démarrage. Toujours `null` →
481
+ `RealtimeHub.ts:1297`), qu'un module bas niveau alimente au démarrage. Toujours `null` →
482
482
  l'abonnement est refusé et rien n'est alloué.
483
483
 
484
484
  Au dernier désabonnement, `RealtimeHub.unsubscribe()` (`RealtimeHub.ts:576`) appelle le
@@ -547,12 +547,12 @@ coûte chaque abonné supplémentaire.
547
547
 
548
548
  ### Le contrat, en cinq méthodes
549
549
 
550
- `IBackplane` (`IBackplane.ts:75`) est délibérément minuscule : le backplane ne connaît ni
550
+ `IBackplane` (`IBackplane.ts:107`) est délibérément minuscule : le backplane ne connaît ni
551
551
  les abonnés ni les canaux logiques. Tout l'état vit dans le hub.
552
552
 
553
553
  | Membre | Rôle |
554
554
  | ------------------------------------------------- | --------------------------------------------------------------- |
555
- | `originId` (`IBackplane.ts:77`) | l'étiquette de CE process, lue par l'anti-écho |
555
+ | `originId` (`IBackplane.ts:109`) | l'étiquette de CE process, lue par l'anti-écho |
556
556
  | `start()` (`IBackplane.ts:115`) | ouvrir le transport. Idempotent, synchrone ou asynchrone |
557
557
  | `publish(channel, payload)` (`IBackplane.ts:121`) | propager aux **autres** pairs. Ne refait pas le fan-out local |
558
558
  | `onMessage(handler)` (`IBackplane.ts:127`) | recevoir des pairs. Un seul gestionnaire à la fois |
@@ -743,19 +743,19 @@ Deux propriétés architecturales méritent d'être notées ici, parce qu'elles
743
743
  choix de conception visibles partout dans le module :
744
744
 
745
745
  **Coût nul quand la sécurité est absente.** Le verrou de frame n'est branché sur le peer que
746
- si une politique existe (`RealtimeHub.hasFrameAuthorizer()`, `RealtimeHub.ts:1042`, testé une
746
+ si une politique existe (`RealtimeHub.hasFrameAuthorizer()`, `RealtimeHub.ts:1058`, testé une
747
747
  fois au handshake). Sans module de sécurité, `beforeDispatch` reste indéfini et le chemin
748
748
  chaud ne paie **rien** du tout.
749
749
 
750
750
  **Échec bruyant plutôt que faux sentiment de sécurité.** Si des canaux déclarent une
751
751
  politique sans qu'aucun décideur ne soit câblé, `hasUnenforcedChannelPolicies()`
752
- (`RealtimeHub.ts:1101`) le détecte et un avertissement est émis une fois par process
752
+ (`RealtimeHub.ts:1117`) le détecte et un avertissement est émis une fois par process
753
753
  (`RealtimeController.ts:487`). Un canal qui **se croit** gardé alors qu'il est ouvert est
754
754
  bien plus dangereux qu'un canal ouvertement public.
755
755
 
756
756
  ## 📡 Observabilité — la sonde et Studio
757
757
 
758
- `RealtimeHub.probe()` (`RealtimeHub.ts:849`) rend un instantané en **lecture pure** : aucune
758
+ `RealtimeHub.probe()` (`RealtimeHub.ts:865`) rend un instantané en **lecture pure** : aucune
759
759
  allocation sur le chemin chaud, jamais d'exception. Les compteurs sont des primitives
760
760
  incrémentées en O(1) ; ils sont **monotones**, ce qui laisse au lecteur le soin de dériver
761
761
  un débit.
@@ -808,7 +808,7 @@ révocables, authentificateurs, politiques de canal, association peer→jeton :
808
808
  `null`. Un process sans abonné n'alloue **rien** (`RealtimeHub.ts:141` et suivants).
809
809
 
810
810
  **Aucun minuteur au repos.** Le tick de révocation démarre au premier inscrit et s'arrête
811
- dès que le registre se vide — `RealtimeHub.unregisterRevocable()` (`RealtimeHub.ts:793`).
811
+ dès que le registre se vide — `RealtimeHub.unregisterRevocable()` (`RealtimeHub.ts:809`).
812
812
  Les minuteurs sont détachés de la boucle d'événements : ils ne retiennent jamais l'arrêt du
813
813
  process.
814
814
 
@@ -283,7 +283,7 @@ valeurs sont des constantes du code, pas des clés.
283
283
 
284
284
  Poser `false` charge le module sans rien activer. Concrètement : pas d'API d'administration
285
285
  enregistrée, pas de backplane câblé, pas de sonde de pod
286
- (`Realtime.onKernelBoot()`, `src/packages/@nodefony/realtime/index.ts:213`), et le service devient
286
+ (`Realtime.onKernelBoot()`, `src/packages/@nodefony/realtime/index.ts:239`), et le service devient
287
287
  inerte (`RealtimeService.init()`, `RealtimeService.ts:77`).
288
288
 
289
289
  > [!WARNING]
@@ -344,7 +344,7 @@ canal exotique.
344
344
  Ne concerne, là aussi, que les transports **partagés**. Un bus pub/sub ne dit pas _qui_ a publié :
345
345
  sans secret, tout ce qui atterrit sur le canal — venu d'un pod ou d'un tiers qui sait écrire dans ce
346
346
  Redis — est traité comme un message légitime. Poser un secret fait porter à chaque message un sceau
347
- HMAC-SHA256, vérifié à l'arrivée (`openBackplaneEnvelope()`, `envelope.ts:96`) : un message non
347
+ HMAC-SHA256, vérifié à l'arrivée (`openBackplaneEnvelope()`, `envelope.ts:112`) : un message non
348
348
  scellé, altéré ou repointé vers un autre canal est **ignoré**, sans exception possible.
349
349
 
350
350
  Le secret doit être **identique sur tous les pods** — c'est lui qui les reconnaît entre eux. En
@@ -378,7 +378,7 @@ d'éteindre une sonde sur un pod en incident sans redéployer une configuration.
378
378
  ### `slowConsumer.bytes` — un compteur, pas un frein
379
379
 
380
380
  Seuil de `bufferedAmount` — les octets en attente d'envoi sur une socket — au-delà duquel la sonde
381
- compte la connexion comme « lente » (`RealtimeHub.probe()`, `RealtimeHub.ts:849`).
381
+ compte la connexion comme « lente » (`RealtimeHub.probe()`, `RealtimeHub.ts:865`).
382
382
 
383
383
  > [!IMPORTANT]
384
384
  > **Cette clé observe, elle n'agit pas.** Elle ne change que le compteur `slowConsumers` de la
@@ -434,7 +434,7 @@ des canaux d'état, où seul le dernier instantané compte.
434
434
  Chaque canal ouvert coûte un producteur côté hub, un minuteur le plus souvent, et une entrée de table
435
435
  côté connexion. Sans borne, **un seul** client peut s'abonner jusqu'à épuiser la mémoire du
436
436
  processus. Le plafond est vérifié à chaque abonnement (`RealtimeController.startChannel()`,
437
- `RealtimeController.ts:746`).
437
+ `RealtimeController.ts:815`).
438
438
 
439
439
  Quatre propriétés qui décident du bon réglage :
440
440
 
@@ -457,7 +457,7 @@ les clients — un tableau de bord interne qui compose des dizaines de flux, par
457
457
  ### `csrf.checkOrigin` — qui a le droit d'ouvrir la socket
458
458
 
459
459
  Trois clés qui forment une seule politique, appliquée à l'ouverture de la connexion
460
- (`RealtimeHub.checkOrigin()`, `RealtimeHub.ts:970`). Une origine refusée ferme la socket avec le code
460
+ (`RealtimeHub.checkOrigin()`, `RealtimeHub.ts:986`). Une origine refusée ferme la socket avec le code
461
461
  `4003`.
462
462
 
463
463
  Le défaut est **désactivé**. C'est le seul défaut du module qui n'est pas le réglage recommandé :
@@ -705,7 +705,7 @@ Deux fonctions publiques, utiles surtout aux tests et aux outils.
705
705
 
706
706
  `defineRealtimeConfig(config?, options?)` (`defineModuleConfig.ts:35`) analyse, applique la variable
707
707
  de driver et **gèle**. Le module l'appelle lui-même à l'enregistrement
708
- (`Realtime.onKernelRegister()`, `src/packages/@nodefony/realtime/index.ts:219`) : tu n'as pas à
708
+ (`Realtime.onKernelRegister()`, `src/packages/@nodefony/realtime/index.ts:213`) : tu n'as pas à
709
709
  l'invoquer dans une application. En revanche, c'est l'outil qui permet de vérifier une configuration
710
710
  sans démarrer un serveur.
711
711
 
@@ -134,7 +134,7 @@ le moment venu.
134
134
 
135
135
  ```bash
136
136
  # 1. Créer l'application (profil complet : http, framework, realtime, sécurité, frontend)
137
- npx nodefony create app mon-chat --complete
137
+ npx nodefony create app mon-chat --preset complete
138
138
  cd mon-chat
139
139
 
140
140
  # 2. Créer le module qui portera le salon
@@ -1598,22 +1598,22 @@ Pour qui veut lire l'implémentation derrière chaque étape :
1598
1598
  | Ce que tu as utilisé | Où c'est écrit |
1599
1599
  | --- | --- |
1600
1600
  | Les trois décorateurs | `RealtimeAction()` (`realtimeDecorators.ts:101`), `RealtimeChannel()` (`realtimeDecorators.ts:192`), `RealtimeInbound()` (`realtimeDecorators.ts:231`) |
1601
- | La porte WebSocket | `RealtimeController.handleRealtime()` (`RealtimeController.ts:249`) |
1602
- | Le pont HTTP ⇄ WebSocket | `RealtimeController.realtimeApiRequest()` (`RealtimeController.ts:219`) |
1603
- | L'abonnement et son cycle de vie | `RealtimeHub.subscribe()` (`RealtimeHub.ts:462`), `RealtimeController.startChannel()` (`RealtimeController.ts:746`) |
1601
+ | La porte WebSocket | `RealtimeController.handleRealtime()` (`RealtimeController.ts:299`) |
1602
+ | Le pont HTTP ⇄ WebSocket | `RealtimeController.realtimeApiRequest()` (`RealtimeController.ts:276`) |
1603
+ | L'abonnement et son cycle de vie | `RealtimeHub.subscribe()` (`RealtimeHub.ts:462`), `RealtimeController.startChannel()` (`RealtimeController.ts:815`) |
1604
1604
  | La publication et le fan-out | `RealtimeHub.publish()` (`RealtimeHub.ts:604`), réinjection locale `RealtimeHub.publishLocal()` (`RealtimeHub.ts:623`) |
1605
1605
  | Les canaux diffusables | `RealtimeBroadcast` (`realtimeDecorators.ts:342`), `RealtimeHub.markBroadcastChannel()` (`RealtimeHub.ts:668`) |
1606
1606
  | Le branchement du backplane | `RealtimeHub.setBackplane()` (`RealtimeHub.ts:714`), registre `registerBackplaneDriver()` (`backplaneRegistry.ts:55`), cloison `resolveBackplaneOriginId()` (`originId.ts:24`) |
1607
- | Les politiques de canal | `IChannelPolicy` (`IChannelPolicy.ts:20`), `RealtimeHub.registerChannelPolicy()` (`RealtimeHub.ts:1127`), garde-fou `RealtimeHub.hasUnenforcedChannelPolicies()` (`RealtimeHub.ts:1127`) |
1607
+ | Les politiques de canal | `IChannelPolicy` (`IChannelPolicy.ts:20`), `RealtimeHub.registerChannelPolicy()` (`RealtimeHub.ts:1143`), garde-fou `RealtimeHub.hasUnenforcedChannelPolicies()` (`RealtimeHub.ts:1117`) |
1608
1608
  | Le branchement automatique du firewall | `Firewall.#wireRealtime()` (`firewall.ts:279`) |
1609
- | Les refus | `IRealtimeDenied` (`RealtimeEventMap.ts:269`), erreur `unauthorized` (`JsonRpcPeer.ts:400`), fermetures `origin not allowed` (`RealtimeController.ts:327`) et `unauthorized` (`RealtimeController.ts:349`) |
1610
- | La révocation en cours de session | `RealtimeHub.revalidateRevocable()` (`RealtimeHub.ts:810`) |
1609
+ | Les refus | `IRealtimeDenied` (`RealtimeEventMap.ts:269`), erreur `unauthorized` (`JsonRpcPeer.ts:417`), fermetures `origin not allowed` (`RealtimeController.ts:327`) et `unauthorized` (`RealtimeController.ts:397`) |
1610
+ | La révocation en cours de session | `RealtimeHub.revalidateRevocable()` (`RealtimeHub.ts:826`) |
1611
1611
  | Le consommateur lent | seuil `SLOW_CONSUMER_BYTES` (`RealtimeHub.ts:56`), fermeture `slow consumer` (`RealtimeHub.ts:63`) |
1612
- | Le plafond de canaux | `RealtimeHub.maxChannelsPerConnection` (`RealtimeHub.ts:984`) |
1612
+ | Le plafond de canaux | `RealtimeHub.maxChannelsPerConnection` (`RealtimeHub.ts:1000`) |
1613
1613
  | L'accueil et l'identité | `IRealtimeWelcome` (`RealtimeEventMap.ts:231`) |
1614
- | La sonde | `RealtimeHub.probe()` (`RealtimeHub.ts:849`), `IRealtimeProbe` (`IRealtimeProbe.ts:61`), `IRealtimeChannelStat` (`IRealtimeProbe.ts:47`) |
1614
+ | La sonde | `RealtimeHub.probe()` (`RealtimeHub.ts:865`), `IRealtimeProbe` (`IRealtimeProbe.ts:61`), `IRealtimeChannelStat` (`IRealtimeProbe.ts:47`) |
1615
1615
  | Le point de mesure d'administration | `createRealtimeAdminApi()` (`RealtimeAdminApi.ts:98`), `buildRealtimeHealth()` (`RealtimeAdminApi.ts:74`), `buildOwnHealth()` (`RealtimeAdminApi.ts:52`) |
1616
1616
  | Le service injectable | `RealtimeService.publish()` (`RealtimeService.ts:161`), `RealtimeService.probe()` (`RealtimeService.ts:213`), `RealtimeService.markBroadcastChannel()` (`RealtimeService.ts:222`) |
1617
- | Les hooks React | `useNodefony()` (`client/react/index.ts:145`), `useNodefonyState()` (`client/react/index.ts:163`), `useNodefonyIdentity()` (`client/react/index.ts:182`), `useNodefonyChannel()` (`client/react/index.ts:198`), `useNodefonyChannelData()` (`client/react/index.ts:221`), `useNodefonyAdaptiveChannel()` (`client/react/index.ts:182`) |
1617
+ | Les hooks React | `useNodefony()` (`client/react/index.ts:145`), `useNodefonyState()` (`client/react/index.ts:163`), `useNodefonyIdentity()` (`client/react/index.ts:182`), `useNodefonyChannel()` (`client/react/index.ts:198`), `useNodefonyChannelData()` (`client/react/index.ts:221`), `useNodefonyAdaptiveChannel()` (`client/react/index.ts:255`) |
1618
1618
  | Le client navigateur | `RealtimeClient.connect()` (`RealtimeClient.ts:389`), `RealtimeClient.subscribe()` (`RealtimeClient.ts:530`), `RealtimeClient.request()` (`RealtimeClient.ts:729`), `RealtimeClient.onDenied()` (`RealtimeClient.ts:471`) |
1619
- | L'upload | `UploadedFile` (`routerDecorators.ts:1253`), `IUploadedFile` (`IUpload.ts:49`), `queryFile` (`Request.ts:185`) |
1619
+ | L'upload | `UploadedFile` (`routerDecorators.ts:1286`), `IUploadedFile` (`IUpload.ts:49`), `queryFile` (`Request.ts:200`) |
package/docs/index.md CHANGED
@@ -325,7 +325,7 @@ vue de tous les workers d'un pod.
325
325
 
326
326
  Le data plane admin expose `/nodefony/realtime/api/health` — canaux et abonnés, compteurs de
327
327
  diffusion, connexions, octets et frames, pression d'écriture. C'est la même donnée que celle rendue à
328
- l'écran, servie par la sonde du hub (`RealtimeHub.probe()`, `RealtimeHub.ts:849`).
328
+ l'écran, servie par la sonde du hub (`RealtimeHub.probe()`, `RealtimeHub.ts:865`).
329
329
 
330
330
  ## 🧪 Tests & couverture
331
331
 
@@ -367,6 +367,3 @@ l'infrastructure sous la main.
367
367
  - 🏛️ **Transverse** : [vue d'ensemble du framework](../../../../../docs/architecture/vue-ensemble.md) ·
368
368
  [le pipeline d'une requête](../../../../../docs/architecture/pipeline-requete.md)
369
369
  - 📖 [Lexique général](../../../../../docs/lexique.md) du framework.
370
- </content>
371
-
372
- </invoke>
@@ -96,7 +96,7 @@ temps de réponse — elle mesure **ce qui n'est pas encore parti**.
96
96
  Trois partis pris, tous lisibles dans le code.
97
97
 
98
98
  **1. La sonde est une lecture pure, jamais un collecteur.** `RealtimeHub.probe()`
99
- (`RealtimeHub.ts:849`) ne fait qu'additionner des primitives déjà tenues à jour par le chemin chaud.
99
+ (`RealtimeHub.ts:865`) ne fait qu'additionner des primitives déjà tenues à jour par le chemin chaud.
100
100
  Aucune entrée-sortie, aucune exception possible, aucun état de lecture conservé. Appeler la sonde
101
101
  mille fois par seconde ne changerait rien à ce que le hub fait par ailleurs.
102
102
 
@@ -265,7 +265,7 @@ Le contrat est `IRealtimeProbe` (`IRealtimeProbe.ts:61`). Quatre familles, un di
265
265
  | ---------------------- | ------------------------------------------------------------------------------------------------------------------ |
266
266
  | `publishTotal` | Appels à `publish`. **Ce que le serveur a voulu envoyer.** |
267
267
  | `fanoutTotal` | Livraisons effectives (`publish` × abonnés). **Ce que ça a réellement coûté.** |
268
- | `inboundTotal` | Frames poussées par les clients sur les canaux entrants, via `RealtimeHub.recordInbound()` (`RealtimeHub.ts:838`). |
268
+ | `inboundTotal` | Frames poussées par les clients sur les canaux entrants, via `RealtimeHub.recordInbound()` (`RealtimeHub.ts:854`). |
269
269
  | `ingressRejectedTotal` | Messages venus du **backplane** et jetés parce que leur canal n'est pas déclaré diffusable. |
270
270
 
271
271
  Le rapport `fanoutTotal / publishTotal` est la mesure la plus parlante de la page : c'est la **taille
@@ -588,6 +588,3 @@ npm run coverage # rapport vitest — jamais de pourcentage figé dans cette p
588
588
  - [Sécurité](securite.md) — qui a le droit de s'abonner à un canal de santé, et pourquoi ça compte.
589
589
  - [Cookbook — un chat](cookbook-chat.md) — le patron appliqué de bout en bout sur une vraie fonctionnalité.
590
590
  - [Vocabulaire](vocabulaire.md) — les douze mots de la socket.
591
- </content>
592
-
593
- </invoke>
package/docs/protocole.md CHANGED
@@ -246,7 +246,7 @@ Sur le fil, dans l'inspecteur réseau du navigateur, exactement cinq frames :
246
246
  ```jsonc
247
247
  // 1. serveur → client : l'accueil, aussitôt après le handshake
248
248
  {"jsonrpc":"2.0","method":"realtime:welcome","params":{
249
- "ts":1770000000000,"protocol":"jsonrpc-2.0",
249
+ "ts":1770000000000,"protocol":"jsonrpc-2.0","env":"development",
250
250
  "channels":["chat:room-42"],"methods":["chat:history"],
251
251
  "identity":{"type":"anonymous","authenticated":false,
252
252
  "userIdentifier":"anonymous","roles":["ROLE_ANONYMOUS"],"scopes":[]}}}
@@ -264,9 +264,11 @@ Sur le fil, dans l'inspecteur réseau du navigateur, exactement cinq frames :
264
264
  {"jsonrpc":"2.0","method":"chat:room-42","params":{"text":"salut"}}
265
265
  ```
266
266
 
267
- L'accueil porte **cinq** champs : `ts`, `protocol`, `channels`, `methods`, `identity`
268
- (`IRealtimeWelcome`, `RealtimeEventMap.ts:231`), émis par le contrôleur au handshake
269
- (`RealtimeController.ts:8`). Il n'y a pas de champ `version`.
267
+ L'accueil porte cinq champs — `ts`, `protocol`, `channels`, `methods`, `identity`
268
+ (`IRealtimeWelcome`, `RealtimeEventMap.ts:231`) — plus un sixième, `env`, **uniquement hors
269
+ production** (`welcomeEnv()`, `welcomeEnv.ts:19`) : c'est lui que porte l'exemple ci-dessus, capturé
270
+ en développement. Émis par le contrôleur au handshake (`peer.notify("realtime:welcome")`,
271
+ `RealtimeController.ts:708`). Il n'y a pas de champ `version`.
270
272
 
271
273
  ## 🔌 Anatomie d'une frame, champ par champ
272
274
 
@@ -335,19 +337,19 @@ ouvertes à l'application. Colonne `id` : présent = requête (réponse due), ab
335
337
 
336
338
  | Méthode | Direction | `id` ? | Rôle | Ancrage |
337
339
  | ------------------ | ------------- | :-----: | --------------------------------------------------------------------- | --------------------------- |
338
- | `subscribe` | client→server | non | « pousse-moi ce canal » — `params.channel` | `RealtimeController.ts:459` |
339
- | `unsubscribe` | client→server | non | « arrête » — dernier abonné, le producteur est libéré | `RealtimeController.ts:727` |
340
- | `ping` | client→server | non | Battement de cœur — **no-op serveur**, aucun pong | `RealtimeClient.ts:740` |
341
- | `<canal>` | server→client | non | Push d'un message : le **nom du canal est la `method`** | `RealtimeController.ts:901` |
340
+ | `subscribe` | client→server | non | « pousse-moi ce canal » — `params.channel` | `RealtimeController.ts:522` |
341
+ | `unsubscribe` | client→server | non | « arrête » — dernier abonné, le producteur est libéré | `RealtimeController.ts:775` |
342
+ | `ping` | client→server | non | Battement de cœur — **no-op serveur**, aucun pong | `RealtimeClient.ts:882` |
343
+ | `<canal>` | server→client | non | Push d'un message : le **nom du canal est la `method`** | `RealtimeController.ts:972` |
342
344
  | `<canal entrant>` | client→server | non | Le client pousse sur un canal déclaré entrant | `RealtimeController.ts:736` |
343
345
  | `realtime:welcome` | server→client | non | L'accueil : 5 champs, dont l'identité résolue | `RealtimeController.ts:693` |
344
- | `realtime:denied` | server→client | non | Rend OBSERVABLE le refus d'une notification | `RealtimeController.ts:433` |
345
- | `api.request` | client→server | **oui** | Pont API — rejoue une route HTTP sur la socket (désactivé par défaut) | `RealtimeController.ts:534` |
346
+ | `realtime:denied` | server→client | non | Rend OBSERVABLE le refus d'une notification | `RealtimeController.ts:496` |
347
+ | `api.request` | client→server | **oui** | Pont API — rejoue une route HTTP sur la socket (désactivé par défaut) | `RealtimeController.ts:582` |
346
348
  | `<action>` | client→server | **oui** | Toute action déclarée par `@RealtimeAction` | `realtimeDecorators.ts:101` |
347
349
 
348
350
  > [!TIP]
349
351
  > **L'accueil est ta carte du territoire.** `methods` et `channels` sont construits à partir de ce
350
- > que l'endpoint expose réellement (`RealtimeController.ts:641`) : un client peut activer ou griser
352
+ > que l'endpoint expose réellement (`RealtimeController.ts:710`) : un client peut activer ou griser
351
353
  > ses commandes sans rien coder en dur. Côté navigateur, ils se lisent en `socket.serverMethods` et
352
354
  > `socket.serverChannels`.
353
355
 
@@ -357,12 +359,12 @@ Les quatre formes de frame circulent en permanence sous tes yeux — ce schéma
357
359
  > `nodefony:kernel:ping` et `nodefony:kernel:gc` (`StudioRealtimeController.ts:114`) sont des exemples d'actions,
358
360
  > **pas des méthodes du cœur temps réel** : elles sont déclarées par le contrôleur
359
361
  > d'administration de `@nodefony/studio`. Un endpoint applicatif ne les expose pas. Le helper
360
- > `RealtimeClient.ping()` (`RealtimeClient.ts:740`) mesure le RTT en les appelant — il suppose donc
362
+ > `RealtimeClient.ping()` (`RealtimeClient.ts:878`) mesure le RTT en les appelant — il suppose donc
361
363
  > un endpoint qui les déclare, contrairement à la notification `ping` du battement de cœur, qui
362
364
  > n'attend jamais de réponse.
363
365
 
364
366
  `subscribe` et `unsubscribe` ne sont **pas** des actions enregistrées : elles sont traitées dans
365
- `onRealtimeNotification()` (`RealtimeController.ts:715`). Envoyées avec un `id`, elles seraient
367
+ `onRealtimeNotification()` (`RealtimeController.ts:784`). Envoyées avec un `id`, elles seraient
366
368
  classées « requête », ne trouveraient aucun handler et récolteraient un `-32601`.
367
369
 
368
370
  ## Une conversation type, de bout en bout
@@ -400,7 +402,7 @@ de `RpcError`.
400
402
  | `-32603` | même méthode (`JsonRpcPeer.ts:528`) | le handler a levé une exception **ordinaire** | `internal error` — générique, rien d'autre |
401
403
  | `-32001` | le refus du verrou de frame (`JsonRpcPeer.ts:400`) | `beforeDispatch` a dit non **sur une requête** | `unauthorized`, sans jamais dire pourquoi |
402
404
  | `-32000` | défaut du constructeur de `RpcError` (`JsonRpcPeer.ts:74`) | le handler expose volontairement son refus | le message ET le `data` choisis par le handler |
403
- | `-32602` | le pont API, via `RpcError` (`RealtimeController.ts:718`) | `api.request` appelé avec un `params.path` invalide | message explicite (l'appel est malformé) |
405
+ | `-32602` | le pont API, via `RpcError` (`RealtimeController.ts:787`) | `api.request` appelé avec un `params.path` invalide | message explicite (l'appel est malformé) |
404
406
 
405
407
  Et un échec qui n'est **pas** une frame : l'expiration. `startCall()` ne reçoit rien dans le délai
406
408
  imparti, supprime l'entrée en attente et rejette localement avec `RPC timeout: <méthode>`
@@ -431,12 +433,12 @@ radicalement :
431
433
  | La frame refusée est… | Ce qui part | Pourquoi |
432
434
  | --------------------- | --------------------------------------------------------------- | ---------------------------------------------------- |
433
435
  | une **requête** | `-32001 "unauthorized"` (`JsonRpcPeer.ts:413`) | Un `id` existe : il y a un canal de réponse |
434
- | une **notification** | la notification `realtime:denied` (`RealtimeController.ts:433`) | Aucun `id` : sans elle, le client se croirait abonné |
436
+ | une **notification** | la notification `realtime:denied` (`RealtimeController.ts:496`) | Aucun `id` : sans elle, le client se croirait abonné |
435
437
 
436
438
  `IRealtimeDenied` (`RealtimeEventMap.ts:269`) porte `channel` et `reason` — plus un `detail` optionnel, posé hors production seulement — et le motif
437
439
  est **générique**. Jamais « il te manque `ROLE_ADMIN` » : ce serait un oracle d'autorisation, un
438
440
  attaquant y lirait la carte des droits. Deux motifs circulent : `forbidden` (le verrou a dit non) et
439
- `limit` (le plafond de canaux de la connexion est atteint, `RealtimeController.ts:763`). Côté client,
441
+ `limit` (le plafond de canaux de la connexion est atteint, `RealtimeController.ts:811`). Côté client,
440
442
  `onDenied()` (`RealtimeClient.ts:469`) branche un handler dessus.
441
443
 
442
444
  > [!CAUTION]
@@ -474,7 +476,7 @@ décrits dans [la page sécurité](./securite.md).
474
476
  | L'exception du serveur n'arrive jamais au client | Zero Trust : tout throw ordinaire devient `-32603` générique (`JsonRpcPeer.ts:528`) | Lever une `RpcError` pour exposer volontairement code et `data` |
475
477
  | Une notification refusée disparaît sans trace côté client | Sans `id`, aucune réponse possible (`beforeDispatch`, `JsonRpcPeer.ts:151`) | Écouter `realtime:denied` via `onDenied()` (`RealtimeClient.ts:471`) |
476
478
  | `nodefony:kernel:ping` répond `-32601` sur mon endpoint | L'action `nodefony:kernel:ping` est déclarée par `@nodefony/studio` (`StudioRealtimeController.ts:114`) | Déclarer la sienne, ou lire `serverMethods` avant d'appeler |
477
- | Le battement de cœur ne renvoie aucun pong | La notification `ping` est un no-op serveur (`RealtimeController.ts:742`) | Pour mesurer un RTT, utiliser une action RPC — `ping()` (`RealtimeClient.ts:740`) |
479
+ | Le battement de cœur ne renvoie aucun pong | La notification `ping` est un no-op serveur (`RealtimeController.ts:791`) | Pour mesurer un RTT, utiliser une action RPC — `ping()` (`RealtimeClient.ts:878`) |
478
480
  | Une réponse reçue est ignorée sans message | Corrélation sur `id` **numériques** seulement (`JsonRpcPeer.ts:537`) | Ne pas fabriquer soi-même de réponse à `id` chaîne |
479
481
  | Les premières frames envoyées après `connect()` semblent perdues | Le transport n'est branché qu'une fois le handshake terminé | Attendre `realtime:welcome` — le client le fait déjà |
480
482
 
package/docs/securite.md CHANGED
@@ -118,7 +118,7 @@ politiques.**
118
118
  `security/nodefony/config/config.ts:109`). L'opt-out est explicite ; un opt-in aurait été
119
119
  _fail-open_ (une zone qui oublie le flag laisserait le WS anonyme).
120
120
  - Le verrou de frame consulte la **même** fonction de match que le HTTP, `Firewall.matchPath()`
121
- (`firewall.ts:712`), et la **même** hiérarchie de rôles, `Firewall.hasRole()`
121
+ (`firewall.ts:719`), et la **même** hiérarchie de rôles, `Firewall.hasRole()`
122
122
  (`firewall.ts:482`). Invariant par construction : `api.request {path}` n'accorde jamais plus que
123
123
  `GET {path}`.
124
124
  - L'identité du handshake est celle du firewall HTTP : `FirewallRealtimeAuthenticator`
@@ -261,13 +261,13 @@ Nodefony pose **deux barrières successives**, à deux étages différents.
261
261
  | Allowlist stricte | `@nodefony/realtime` | **inactif** (`enabled: false`) | configurable | `close 4003` |
262
262
 
263
263
  **Barrière 1 — transport, active sans rien faire.** `HttpKernel.checkWebsocketOrigin()`
264
- (`http-kernel.ts:599`) exige que l'`Origin` du handshake corresponde au `Host` servi, avec tolérance
264
+ (`http-kernel.ts:621`) exige que l'`Origin` du handshake corresponde au `Host` servi, avec tolérance
265
265
  loopback en développement et une allowlist optionnelle (`allowedOrigins`,
266
266
  `http/nodefony/config/config.ts:552`) acceptant le hostname exact ou un wildcard à un label. Une
267
267
  requête **sans** `Origin` est acceptée : un attaquant non-navigateur n'a pas besoin de CSWSH.
268
268
 
269
269
  **Barrière 2 — module realtime, opt-in et plus stricte.** `RealtimeHub.checkOrigin()`
270
- (`RealtimeHub.ts:970`) consulte une garde compilée une fois au boot par `buildOriginGuard()`
270
+ (`RealtimeHub.ts:986`) consulte une garde compilée une fois au boot par `buildOriginGuard()`
271
271
  (`RealtimeService.ts:313`) depuis `checkOriginSchema` (`realtime/nodefony/config/config.ts:162`).
272
272
  Trois différences comptent :
273
273
 
@@ -292,18 +292,18 @@ n'est jamais traitée.
292
292
 
293
293
  ### Le pipeline exact
294
294
 
295
- `RealtimeController.onHandshake()` (`RealtimeController.ts:328`) exécute, une fois par connexion :
295
+ `RealtimeController.onHandshake()` (`RealtimeController.ts:374`) exécute, une fois par connexion :
296
296
 
297
297
  1. Construction d'un DTO neutre `IRealtimeHandshake` par `buildHandshakeFromContext()`
298
- (`RealtimeController.ts:1113`) — headers, cookies aplatis, url, origin, sous-protocoles. Aucune
298
+ (`RealtimeController.ts:1198`) — headers, cookies aplatis, url, origin, sous-protocoles. Aucune
299
299
  dépendance à `@nodefony/security` dans le contrat.
300
300
  2. Contrôle d'origine (verrou 1).
301
- 3. Résolution de l'authenticator par `RealtimeHub.resolveAuthenticator()` (`RealtimeHub.ts:945`) :
301
+ 3. Résolution de l'authenticator par `RealtimeHub.resolveAuthenticator()` (`RealtimeHub.ts:961`) :
302
302
  les matchers sont testés dans l'ordre d'enregistrement, **le premier qui matche capture**.
303
303
  4. `authenticator.authenticate(handshake)` — **async autorisé** (on est en cold path, une fois par
304
304
  connexion : lire un store est acceptable ici, jamais par frame).
305
305
  5. Pose du token sur la WeakMap `peer → token` via `RealtimeHub.setTokenForPeer()`
306
- (`RealtimeHub.ts:1002`), **avant** l'envoi du `welcome` : le lookup est garanti dès la première
306
+ (`RealtimeHub.ts:1018`), **avant** l'envoi du `welcome` : le lookup est garanti dès la première
307
307
  frame.
308
308
 
309
309
  Un `throw` de `authenticate()` ferme la socket en `4001` « unauthorized », après un log `WARNING`
@@ -312,8 +312,8 @@ d'audit défectueux ne peut pas empêcher la fermeture.
312
312
 
313
313
  ### Les matchers — quelle porte, quel vigile
314
314
 
315
- `RealtimeHub.useAuthenticator()` (`RealtimeHub.ts:927`) associe un sélecteur à une stratégie.
316
- `compileMatcher()` (`RealtimeHub.ts:1402`) le compile une fois :
315
+ `RealtimeHub.useAuthenticator()` (`RealtimeHub.ts:943`) associe un sélecteur à une stratégie.
316
+ `compileMatcher()` (`RealtimeHub.ts:1418`) le compile une fois :
317
317
 
318
318
  - `pattern` chaîne → RegExp **préfixe ancrée**, méta-caractères échappés par `RegExp.escape`.
319
319
  `"/admin/"` matche `/admin/` et `/admin/foo`, littéralement.
@@ -321,7 +321,7 @@ d'audit défectueux ne peut pas empêcher la fermeture.
321
321
  - `host` optionnel → comparaison **stricte** (insensible à la casse) sur l'en-tête `Host`, sans
322
322
  wildcard.
323
323
  - Le match porte sur le **path**, query comprise, jamais sur l'URL absolue : `handshakePath()`
324
- (`RealtimeController.ts:1165`) extrait `pathname + search` du `WebsocketContext.url`, qui est
324
+ (`RealtimeController.ts:1250`) extrait `pathname + search` du `WebsocketContext.url`, qui est
325
325
  absolu. Sans cette extraction, un matcher `^/nodefony/…` ne se déclencherait jamais.
326
326
 
327
327
  `@nodefony/security` enregistre ces matchers automatiquement dans `Firewall.#wireRealtime()`
@@ -330,7 +330,7 @@ identité d'instance et une instance partagée n'enregistrerait que le premier m
330
330
 
331
331
  ### Zero Trust — il y a toujours un token
332
332
 
333
- `RealtimeHub.getTokenForPeer()` (`RealtimeHub.ts:1014`) ne renvoie **jamais** `null` : à défaut de
333
+ `RealtimeHub.getTokenForPeer()` (`RealtimeHub.ts:1030`) ne renvoie **jamais** `null` : à défaut de
334
334
  token posé, c'est `ANONYMOUS_REALTIME_TOKEN` (`AnonymousRealtimeToken.ts:18`), singleton gelé,
335
335
  `isAuthenticated() === false`, `roles: ["ROLE_ANONYMOUS"]`. Le code consommateur (verrou, audit)
336
336
  n'a jamais à écrire « et s'il n'y a pas de token ? ».
@@ -451,7 +451,7 @@ peut **resserrer**, jamais desserrer.
451
451
 
452
452
  | Cible | Politique par défaut |
453
453
  | ------------------------------------------ | -------------------------------------------------------------- |
454
- | `nodefony:audit` | authentifié + `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts:99`) |
454
+ | `nodefony:audit` | authentifié + `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts:120`) |
455
455
  | tout le reste de `nodefony:` | authentifié + `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts:89`) |
456
456
  | tout canal contenant `:health` ou `:stats` | authentifié + `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts:253`) |
457
457
  | tout le reste | libre, sauf policy déclarée |
@@ -472,7 +472,7 @@ Trois durcissements méritent d'être connus :
472
472
  donc re-cibler `nodefony:syslog` sur `ROLE_SECURITY_AUDITOR` ; on ne peut pas l'ouvrir à l'anonyme.
473
473
 
474
474
  Le canal du journal d'audit (`nodefony:audit`) est enregistré comme **canal système** sur le hub
475
- (`RealtimeHub.registerSystemChannel()`, `RealtimeHub.ts:1281`) : il devient servable par n'importe
475
+ (`RealtimeHub.registerSystemChannel()`, `RealtimeHub.ts:1297`) : il devient servable par n'importe
476
476
  quel endpoint realtime, sans qu'aucun controller ne le connaisse — et il est gardé par sa règle
477
477
  dédiée. Son enregistrement est **couplé** à la pose du verrou (même condition), donc il n'existe
478
478
  jamais de canal d'audit non gardé.
@@ -536,15 +536,15 @@ C'est exactement le test de `Firewall.#wireRealtime()` (`firewall.ts:279`) : san
536
536
  évaluée — ni métier, ni système. `nodefony:syslog` redevient un canal ordinaire.
537
537
 
538
538
  Deuxième subtilité : `beforeDispatch` n'est branché sur une connexion que si le verrou est **déjà**
539
- posé au moment de son handshake (`RealtimeController.ts:429`, via
540
- `RealtimeHub.hasFrameAuthorizer()` — `RealtimeHub.ts:1042`). Choix de performance délibéré (un hub
539
+ posé au moment de son handshake (`RealtimeController.ts:504`, via
540
+ `RealtimeHub.hasFrameAuthorizer()` — `RealtimeHub.ts:1058`). Choix de performance délibéré (un hub
541
541
  non sécurisé garde un coût nul par frame), mais avec une conséquence : **une connexion ouverte avant
542
542
  la pose du verrou n'est jamais gardée**, et ce jusqu'à sa fermeture. En fonctionnement normal le
543
543
  firewall se construit au boot, avant tout trafic ; le cas ne se présente qu'en appelant
544
544
  `setFrameAuthorizer()` à chaud.
545
545
 
546
546
  **Le refus de dégrader en silence.** Quand des policies sont déclarées sans décideur câblé,
547
- `RealtimeHub.hasUnenforcedChannelPolicies()` (`RealtimeHub.ts:1101`) renvoie `true` et le controller
547
+ `RealtimeHub.hasUnenforcedChannelPolicies()` (`RealtimeHub.ts:1117`) renvoie `true` et le controller
548
548
  émet un WARNING explicite, une seule fois par process (`RealtimeController.ts:558`) :
549
549
 
550
550
  ```text
@@ -600,16 +600,16 @@ Nodefony ferme l'écart par deux mécanismes de granularité différente.
600
600
 
601
601
  | Surface | Re-validation | Fenêtre d'exposition | Où |
602
602
  | --------------------------- | -------------------- | -------------------- | --------------------------------------------------------------------- |
603
- | `api.request` (data plane) | **à chaque frame** | nulle | `RealtimeController.invokeApiRequest()` (`RealtimeController.ts:878`) |
604
- | `subscribe` / flux de canal | **périodique**, 30 s | ≤ 30 s | `RealtimeHub.revalidateRevocable()` (`RealtimeHub.ts:810`) |
603
+ | `api.request` (data plane) | **à chaque frame** | nulle | `RealtimeController.invokeApiRequest()` (`RealtimeController.ts:949`) |
604
+ | `subscribe` / flux de canal | **périodique**, 30 s | ≤ 30 s | `RealtimeHub.revalidateRevocable()` (`RealtimeHub.ts:826`) |
605
605
 
606
606
  **Sur `api.request`**, `token.isValid()` est appelé avant l'exécution de l'action ; identité périmée
607
607
  ou changée → `-32000` avec `status: 401`, et le client bascule sur un `fetch` HTTP porteur du cookie
608
608
  courant. Une erreur de re-validation vaut refus (fail-closed).
609
609
 
610
610
  **Sur les canaux**, le hub n'inscrit au registre de révocation que les connexions dont le token porte
611
- `isValid` (`RealtimeController.ts:586`) — anonymes et JWT n'y entrent jamais, coût nul.
612
- `RealtimeHub.registerRevocable()` (`RealtimeHub.ts:778`) démarre un `setInterval` `unref` au premier
611
+ `isValid` (`RealtimeController.ts:659`) — anonymes et JWT n'y entrent jamais, coût nul.
612
+ `RealtimeHub.registerRevocable()` (`RealtimeHub.ts:794`) démarre un `setInterval` `unref` au premier
613
613
  inscrit et l'arrête dès que le registre se vide : zéro timer au repos. Période :
614
614
  `REVOCATION_REVALIDATE_MS` (`RealtimeHub.ts:111`), 30 s, alignée sur le heartbeat WS.
615
615
 
@@ -652,7 +652,7 @@ pas** borné.
652
652
  Chaque canal ouvert coûte un provider, un ticker et une entrée de Map. Sans borne, une connexion
653
653
  peut abonner jusqu'à l'OOM — un déni de service mémoire déclenché par **un seul** client.
654
654
 
655
- `RealtimeController.startChannel()` (`RealtimeController.ts:746`) refuse au-delà de
655
+ `RealtimeController.startChannel()` (`RealtimeController.ts:815`) refuse au-delà de
656
656
  `limits.maxChannelsPerConnection` (`realtime/nodefony/config/config.ts:142`), défaut **256**,
657
657
  `null` pour illimité. Points prouvés par `realtimeChannelCap.attack.test.ts` :
658
658
 
@@ -693,7 +693,7 @@ au défaut de la librairie `ws`.
693
693
  | Limite de **fréquence** des frames entrantes | Un client authentifié peut inonder le peer ; seul le coût CPU le freine |
694
694
  | Limite de **connexions par IP ou par utilisateur** | Rien n'empêche N sockets par client au niveau du module |
695
695
  | Plafond **global** de canaux du process | Le plafond est par connexion ; M connexions × 256 canaux reste possible |
696
- | Seuils de back-pressure **configurables** | `slowConsumer.bytes` ne pilote que le **comptage** de la sonde, pas les seuils de drop/close (`WsConnectionTransport` est construit sans override, `RealtimeController.ts:373`) |
696
+ | Seuils de back-pressure **configurables** | `slowConsumer.bytes` ne pilote que le **comptage** de la sonde, pas les seuils de drop/close (`WsConnectionTransport` est construit sans override, `RealtimeController.ts:421`) |
697
697
 
698
698
  ## ⚙️ Configuration de sécurité
699
699
 
@@ -773,7 +773,7 @@ n'est pas appliquée.
773
773
  ## 📡 Observabilité — Studio
774
774
 
775
775
  - **Santé de la socket** : `/nodefony/realtime/api/health`, alimenté par `RealtimeHub.probe()`
776
- (`RealtimeHub.ts:849`) — canaux, abonnés, fan-out, connexions, back-pressure et carte d'identité
776
+ (`RealtimeHub.ts:865`) — canaux, abonnés, fan-out, connexions, back-pressure et carte d'identité
777
777
  du backplane. Un `drops` qui grimpe signale des clients en souffrance ; un `slowConsumers` non nul
778
778
  précède souvent une fermeture `1013`. Même snapshot en flux sur le canal `nodefony:socket`
779
779
  (namespace réservé : `ROLE_NODEFONY_ADMIN`).
@@ -127,7 +127,7 @@ vers un autre service — et le consommateur ne voit jamais la différence
127
127
  **`contrôleur` est le même mot qu'en HTTP.** Un endpoint temps réel est un contrôleur qui étend
128
128
  `RealtimeController` (`RealtimeController.ts:144`) et porte une route WebSocket : HTTP et WebSocket
129
129
  vivent dans le **même contexte de contrôleur**. C'est ce qui permet au pont `api.request`
130
- (`RealtimeController.realtimeApiRequest()`, `RealtimeController.ts:219`) de rejouer sur la socket
130
+ (`RealtimeController.realtimeApiRequest()`, `RealtimeController.ts:276`) de rejouer sur la socket
131
131
  **la même action** que celle servie en REST.
132
132
 
133
133
  ## 🚀 Démarrage rapide — les mots en situation
@@ -270,7 +270,7 @@ Un handler qui lève une erreur rend un `-32603` **générique** ; seule une `Rp
270
270
 
271
271
  Un canal où le **client a le droit d'émettre** vers le serveur. Défaut sûr : **aucun**. Un canal
272
272
  n'accepte d'entrée que déclaré explicitement, par `@RealtimeInbound` (`realtimeDecorators.ts:231`) ou
273
- par l'override `realtimeInbound()` (`RealtimeController.ts:205`).
273
+ par l'override `realtimeInbound()` (`RealtimeController.ts:262`).
274
274
 
275
275
  Le handler reçoit `(params, reply)` — `params` vient du réseau, donc **jamais fiable** :
276
276
  `RealtimeInboundHandler` (`IRealtimeController.ts:16`).
@@ -297,7 +297,7 @@ importable **sans** aucune dépendance serveur, ce qui est la condition de l'iso
297
297
  (`ServerRealtimeSocket.ts:223`) : un service métier tient un handle et publie **comme une page
298
298
  front**. Une différence assumée : `request()` n'y est pas supporté — au-dessus du hub il n'y a pas
299
299
  **un** pair mais N clients. Pour un appel serveur → un client précis, c'est `requestClient()`
300
- (`RealtimeController.ts:279`).
300
+ (`RealtimeController.ts:329`).
301
301
 
302
302
  ### `accueil` (welcome) — la première frame
303
303
 
@@ -335,7 +335,7 @@ handshake, jamais renégociée par frame.
335
335
  ### `hub` — le broker du process
336
336
 
337
337
  Le **standard téléphonique** du pod : il tient la table « canal → abonnés locaux » et diffuse. Un
338
- process = un hub, obtenu par `getRealtimeHub()` (`RealtimeHub.ts:1435`). Il ne connaît **ni** les
338
+ process = un hub, obtenu par `getRealtimeHub()` (`RealtimeHub.ts:1451`). Il ne connaît **ni** les
339
339
  contrôleurs, **ni** le métier : ce sont les providers qui portent les dépendances.
340
340
 
341
341
  `RealtimeHub` (`RealtimeHub.ts:213`). ⚠️ « hub » désigne **toujours** le serveur ; ce que tient le
@@ -395,7 +395,7 @@ Un canal dont la fabrique est déclarée par un module bas niveau (le journal d'
395
395
  qui devient servable par **n'importe quel** endpoint, présent ou futur, sans qu'aucun contrôleur ne
396
396
  le connaisse. Consulté seulement quand la fabrique du contrôleur a dit « inconnu ».
397
397
 
398
- `RealtimeHub.registerSystemChannel()` (`RealtimeHub.ts:1281`).
398
+ `RealtimeHub.registerSystemChannel()` (`RealtimeHub.ts:1297`).
399
399
 
400
400
  ### `canal broadcast` — celui qui traverse le backplane
401
401
 
@@ -420,7 +420,7 @@ La classe de base d'un endpoint : elle porte tout le protocole (handshake, accue
420
420
  des frames, cycle de vie des canaux) et ne laisse au métier que ses canaux et ses actions.
421
421
 
422
422
  `RealtimeController` (`RealtimeController.ts:144`), point d'entrée `handleRealtime()`
423
- (`RealtimeController.ts:249`). C'est un **contrôleur** au sens habituel de Nodefony : la même classe
423
+ (`RealtimeController.ts:299`). C'est un **contrôleur** au sens habituel de Nodefony : la même classe
424
424
  peut porter des routes HTTP.
425
425
 
426
426
  ### `service realtime` — la façade d'injection
@@ -437,8 +437,8 @@ L'option qui expose la méthode `api.request { path }` : la connexion rejoue **l
437
437
  contrôleur** que celle servie en REST, avec **la même garde**. Le pont n'atteint que les routes qui
438
438
  déclarent explicitement le transport WebSocket — aucun contournement possible.
439
439
 
440
- `realtimeApiRequest()` (`RealtimeController.ts:219`), mise en œuvre `invokeApiRequest()`
441
- (`RealtimeController.ts:878`). Désactivé par défaut.
440
+ `realtimeApiRequest()` (`RealtimeController.ts:276`), mise en œuvre `invokeApiRequest()`
441
+ (`RealtimeController.ts:949`). Désactivé par défaut.
442
442
 
443
443
  ## 🔌 Le protocole et le transport — ce qui passe sur le fil
444
444
 
@@ -630,7 +630,7 @@ l'authenticator tourne et que l'identité est figée. Tout ce qui coûte cher se
630
630
  frame.
631
631
 
632
632
  DTO neutre `IRealtimeHandshake` (`IRealtimeHandshake.ts:14`) — en-têtes, cookies, URL, origine,
633
- sous-protocoles. Traitement dans `onHandshake()` (`RealtimeController.ts:328`).
633
+ sous-protocoles. Traitement dans `onHandshake()` (`RealtimeController.ts:374`).
634
634
 
635
635
  ### `authenticator` — du handshake à l'identité
636
636
 
@@ -639,7 +639,7 @@ La stratégie qui transforme une poignée de main en identité : `supports` (est
639
639
  HTTP — un seul modèle mental pour les deux transports.
640
640
 
641
641
  `IRealtimeAuthenticator` (`IRealtimeAuthenticator.ts:24`), enregistrement
642
- `RealtimeHub.useAuthenticator()` (`RealtimeHub.ts:927`).
642
+ `RealtimeHub.useAuthenticator()` (`RealtimeHub.ts:943`).
643
643
 
644
644
  ### `matcher` — qui capture quoi
645
645
 
@@ -647,7 +647,7 @@ Le sélecteur (motif d'URL, hôte optionnel) qui décide **quel** authenticator
647
647
  **premier** qui capture gagne : on enregistre donc du plus spécifique au plus général.
648
648
 
649
649
  `IRealtimeAuthenticatorMatcher` (`IRealtimeAuthenticatorMatcher.ts:25`), résolution
650
- `resolveAuthenticator()` (`RealtimeHub.ts:945`).
650
+ `resolveAuthenticator()` (`RealtimeHub.ts:961`).
651
651
 
652
652
  ### `jeton` (token) — l'identité figée
653
653
 
@@ -664,7 +664,7 @@ Les exigences déclarées d'un canal : authentifié, rôles, scopes. Elles s'att
664
664
  canal, pas à la méthode qui l'implémente — parce que c'est le nom que le pare-feu résout.
665
665
 
666
666
  `IChannelPolicy` (`IChannelPolicy.ts:20`), déclaration par `@RealtimeChannel`
667
- (`realtimeDecorators.ts:192`), résolution `resolveChannelPolicy()` (`RealtimeHub.ts:1212`).
667
+ (`realtimeDecorators.ts:192`), résolution `resolveChannelPolicy()` (`RealtimeHub.ts:1228`).
668
668
 
669
669
  ### `verrou de frame` — la décision par frame
670
670
 
@@ -672,8 +672,8 @@ La fonction qui répond « cette frame passe-t-elle ? » à partir du jeton **d
672
672
  **Synchrone par doctrine** : attendre une réponse distante à chaque frame sérialiserait le flux d'une
673
673
  connexion. Un contrôle qui doit interroger le réseau se fait au handshake, pas ici.
674
674
 
675
- `FrameAuthorizer` (`RealtimeHub.ts:39`), pose `setFrameAuthorizer()` (`RealtimeHub.ts:1023`), appel
676
- `runAuthorizer()` (`RealtimeHub.ts:1312`).
675
+ `FrameAuthorizer` (`RealtimeHub.ts:39`), pose `setFrameAuthorizer()` (`RealtimeHub.ts:1039`), appel
676
+ `runAuthorizer()` (`RealtimeHub.ts:1328`).
677
677
 
678
678
  ### `contrôle d'origine` — la défense d'ouverture
679
679
 
@@ -689,7 +689,7 @@ Le problème que ce mot nomme : une connexion ouverte pourrait survivre à la se
689
689
  autorisée, puisque le verrou de frame ne relit rien. Un contrôle périodique referme l'écart en
690
690
  re-validant les identités révocables et en fermant celles qui ne valent plus.
691
691
 
692
- `registerRevocable()` (`RealtimeHub.ts:778`), `revalidateRevocable()` (`RealtimeHub.ts:810`), période
692
+ `registerRevocable()` (`RealtimeHub.ts:794`), `revalidateRevocable()` (`RealtimeHub.ts:826`), période
693
693
  `REVOCATION_REVALIDATE_MS` (`RealtimeHub.ts:111`). Une re-validation en erreur **ferme** — jamais
694
694
  l'inverse.
695
695
 
@@ -728,7 +728,7 @@ ressources ; sans borne, une seule connexion peut les épuiser. Au-delà, l'abon
728
728
  client en est informé.
729
729
 
730
730
  `limits.maxChannelsPerConnection` (`config.ts:142`), pose `setMaxChannelsPerConnection()`
731
- (`RealtimeHub.ts:979`).
731
+ (`RealtimeHub.ts:995`).
732
732
 
733
733
  ## 📡 L'observabilité — ce que la socket dit d'elle-même
734
734
 
@@ -747,7 +747,7 @@ L'oscilloscope du module : « voici mon état, maintenant ». Canaux et abonnés
747
747
  livraisons, connexions, octets, back-pressure, carte d'identité du backplane. Lecture **pure**, sans
748
748
  allocation sur le chemin chaud.
749
749
 
750
- `RealtimeHub.probe()` (`RealtimeHub.ts:849`) rend un `IRealtimeProbe` (`IRealtimeProbe.ts:61`).
750
+ `RealtimeHub.probe()` (`RealtimeHub.ts:865`) rend un `IRealtimeProbe` (`IRealtimeProbe.ts:61`).
751
751
 
752
752
  > [!WARNING]
753
753
  > `IRealtimeProbe` est la **forme des données**, pas une interface à implémenter avec une méthode
@@ -807,7 +807,7 @@ elle-même par le même chemin que les autres modules.
807
807
  | Deux déploiements se mélangent sur un même serveur Redis | pas de **cloison** — le numéro de base ne cloisonne pas le pub/sub | Poser `backplane.namespace` (`config.ts:59`) |
808
808
  | Le fan-out disparaît entre conteneurs identiques | **origine** dérivée du seul identifiant de processus : deux conteneurs sont tous deux le n° 1, l'anti-écho avale tout | `resolveBackplaneOriginId()` (`originId.ts:24`) dérive du pod ou de l'hôte |
809
809
  | Deux tickers pour le même tableau de bord | **cadence** différente = **canal** différent, jamais réconcilié | Fabriquer le nom via `rateChannel()` (`channelRate.ts:44`) des deux côtés |
810
- | Un canal se croit protégé mais laisse tout passer | **politique** déclarée sans **verrou de frame** posé pour la faire respecter | Le hub le détecte et avertit : `hasUnenforcedChannelPolicies()` (`RealtimeHub.ts:1101`) |
810
+ | Un canal se croit protégé mais laisse tout passer | **politique** déclarée sans **verrou de frame** posé pour la faire respecter | Le hub le détecte et avertit : `hasUnenforcedChannelPolicies()` (`RealtimeHub.ts:1117`) |
811
811
  | Une connexion garde ses flux après une déconnexion applicative | le **verrou de frame** ne relit rien : par construction, il ne voit pas la session mourir | Inscrire l'identité au registre de **révocation** (`RealtimeHub.ts:433`) |
812
812
  | « Le provider a planté après la fermeture d'un onglet » | **provider** confondu avec **sink** : le provider est partagé, il survit à la connexion créatrice | N'y capturer que des dépendances à longue vie (`ChannelFactory`, `RealtimeHub.ts:170`) |
813
813
  | « Notre store realtime est indisponible » | **store** employé pour **driver** : le backplane ne conserve rien, il fait passer | Dire « le driver de backplane » — les données ont des stores, les flux ont des drivers |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodefony/realtime",
3
- "version": "10.0.0-alpha.9",
3
+ "version": "10.0.0-beta.1",
4
4
  "description": "Couche temps réel serveur de Nodefony : canaux, appels bidirectionnels JSON-RPC 2.0, contre-pression, et diffusion entre plusieurs instances (boucle locale, IPC de grappe, Redis)",
5
5
  "author": "Christophe CAMENSULI <ccamensuli@gmail.com>",
6
6
  "type": "module",
@@ -41,20 +41,20 @@
41
41
  "esm"
42
42
  ],
43
43
  "peerDependencies": {
44
- "@nodefony/framework": "^10.0.0-alpha.9",
45
- "@nodefony/http": "^10.0.0-alpha.9",
46
- "nodefony": "^10.0.0-alpha.9",
44
+ "@nodefony/framework": "^10.0.0-beta.1",
45
+ "@nodefony/http": "^10.0.0-beta.1",
46
+ "nodefony": "^10.0.0-beta.1",
47
47
  "reflect-metadata": "0.2.2",
48
48
  "zod": "^4.6.5"
49
49
  },
50
50
  "devDependencies": {
51
- "@nodefony/framework": "^10.0.0-alpha.9",
52
- "@nodefony/http": "^10.0.0-alpha.9",
53
- "@types/node": "26.6.2",
54
- "@vitest/coverage-v8": "5.0.1",
55
- "nodefony": "^10.0.0-alpha.9",
51
+ "@nodefony/framework": "^10.0.0-beta.1",
52
+ "@nodefony/http": "^10.0.0-beta.1",
53
+ "@types/node": "26.6.3",
54
+ "@vitest/coverage-v8": "5.0.2",
55
+ "nodefony": "^10.0.0-beta.1",
56
56
  "reflect-metadata": "0.2.2",
57
- "vitest": "5.0.1"
57
+ "vitest": "5.0.2"
58
58
  },
59
59
  "private": false,
60
60
  "engines": {