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

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 (45) hide show
  1. package/README.md +2 -2
  2. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  4. package/dist/index.js +17 -10
  5. package/dist/nodefony/config/config.js +6 -3
  6. package/dist/nodefony/config/configSchema.js +19 -0
  7. package/dist/nodefony/config/defineModuleConfig.js +42 -15
  8. package/dist/nodefony/decorators/realtimeDecorators.js +68 -17
  9. package/dist/nodefony/src/backplane/RedisBackplane.js +21 -4
  10. package/dist/nodefony/src/backplane/bootNotices.js +43 -0
  11. package/dist/nodefony/src/backplane/publishQueue.js +1 -4
  12. package/dist/nodefony/src/cluster/ClusterProbeClient.js +15 -4
  13. package/dist/nodefony/src/guards.js +41 -0
  14. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +1 -4
  15. package/dist/nodefony/src/server/RealtimeController.js +120 -50
  16. package/dist/nodefony/src/server/RealtimeHub.js +29 -51
  17. package/dist/nodefony/src/server/ServerRealtimeSocket.js +13 -5
  18. package/dist/nodefony/src/server/syslogUplink.js +4 -2
  19. package/dist/nodefony/src/service/RealtimeService.js +3 -2
  20. package/dist/nodefony/testing/index.js +41 -1
  21. package/dist/types/nodefony/config/configSchema.d.ts +12 -0
  22. package/dist/types/nodefony/config/defineModuleConfig.d.ts +29 -6
  23. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +19 -2
  24. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +1 -1
  25. package/dist/types/nodefony/interfaces/IRealtimeHandshake.d.ts +1 -1
  26. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +7 -0
  27. package/dist/types/nodefony/src/backplane/bootNotices.d.ts +25 -0
  28. package/dist/types/nodefony/src/guards.d.ts +32 -0
  29. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +13 -0
  30. package/dist/types/nodefony/src/server/RealtimeController.d.ts +24 -4
  31. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +22 -16
  32. package/dist/types/nodefony/src/service/RealtimeService.d.ts +1 -1
  33. package/dist/types/nodefony/testing/index.d.ts +2 -2
  34. package/docs/actions.md +124 -41
  35. package/docs/architecture.md +93 -91
  36. package/docs/cadence-et-contre-pression.md +634 -0
  37. package/docs/configuration.md +66 -56
  38. package/docs/cookbook-chat.md +39 -33
  39. package/docs/index.md +47 -31
  40. package/docs/observabilite.md +21 -18
  41. package/docs/protocole.md +79 -65
  42. package/docs/securite.md +69 -65
  43. package/docs/vocabulaire.md +80 -79
  44. package/package.json +16 -12
  45. package/docs/couverture-bancs.md +0 -139
@@ -1,3 +1,4 @@
1
+ import { isRecord } from "../guards.js";
1
2
  import { ANONYMOUS_REALTIME_TOKEN } from "./AnonymousRealtimeToken.js";
2
3
  import { getRealtimeHub } from "./RealtimeHub.js";
3
4
  import { DEFAULT_ACTION_POLICY, getRealtimeActions, getRealtimeChannelPolicies, getRealtimeChannels, getRealtimeInbound } from "../../decorators/realtimeDecorators.js";
@@ -5,11 +6,16 @@ import { createSyslogUplinkHandler } from "./syslogUplink.js";
5
6
  import { WsConnectionTransport } from "../transport/WsConnectionTransport.js";
6
7
  import { welcomeEnv } from "./welcomeEnv.js";
7
8
  import { deniedDetail } from "./deniedDetail.js";
8
- import { BootConfigurationError, JsonRpcPeer, PLATFORM_INBOUND, RequestContext, RpcEnvelope, RpcError, Scope, identityHint } from "nodefony";
9
+ import { BootConfigurationError, JsonRpcError, JsonRpcPeer, JsonRpcServerError, PLATFORM_INBOUND, RequestContext, RpcEnvelope, RpcError, Scope, identityHint, isJsonRpcId, isPromise, jsonRpcFailure } from "nodefony";
9
10
  import { readBackpressureOptions } from "@nodefony/http";
10
- import { Controller } from "@nodefony/framework";
11
+ import { Controller, buildContextParamArgs, getParamArgsMeta } from "@nodefony/framework";
11
12
  //#region nodefony/src/server/RealtimeController.ts
12
13
  /**
14
+ * Une action RPC n'a ni variables de route ni URL : `@Param` et `@Query` y
15
+ * lisent cet objet vide.
16
+ */
17
+ const NO_ROUTE_PARAMS = Object.freeze({});
18
+ /**
13
19
  * Statut HTTP-équivalent d'une erreur survenue pendant une invocation du pont.
14
20
  *
15
21
  * UNE seule lecture pour les trois sources possibles — la `RpcError` déjà mappée
@@ -22,11 +28,11 @@ import { Controller } from "@nodefony/framework";
22
28
  */
23
29
  function httpStatusOfFrameError(e) {
24
30
  if (e instanceof RpcError) {
25
- const status = e.data?.status;
31
+ const status = isRecord(e.data) ? e.data.status : void 0;
26
32
  if (typeof status === "number") return status;
27
- return e.code === -32602 ? 400 : null;
33
+ return e.code === JsonRpcError.INVALID_PARAMS ? 400 : null;
28
34
  }
29
- const code = e.code;
35
+ const code = isRecord(e) ? e.code : void 0;
30
36
  return typeof code === "number" && code >= 400 && code <= 599 ? code : null;
31
37
  }
32
38
  /**
@@ -40,7 +46,7 @@ function httpStatusOfFrameError(e) {
40
46
  function toFrameRpcError(e, status, requestId) {
41
47
  if (e instanceof RpcError) {
42
48
  if (!requestId) return e;
43
- const data = typeof e.data === "object" && e.data !== null ? {
49
+ const data = isRecord(e.data) ? {
44
50
  ...e.data,
45
51
  requestId
46
52
  } : { requestId };
@@ -49,10 +55,23 @@ function toFrameRpcError(e, status, requestId) {
49
55
  if (status !== null) {
50
56
  const data = { status };
51
57
  if (requestId) data.requestId = requestId;
52
- return new RpcError(e instanceof Error ? e.message : String(e), -32e3, data);
58
+ return new RpcError(e instanceof Error ? e.message : String(e), JsonRpcServerError.DEFAULT, data);
53
59
  }
54
60
  return e;
55
61
  }
62
+ /** Le canal nommé par les params d'un `subscribe`/`unsubscribe` — entrée réseau. */
63
+ function channelParam(params) {
64
+ return isRecord(params) && typeof params.channel === "string" ? params.channel : void 0;
65
+ }
66
+ /**
67
+ * Le serveur WebSocket qui sert la connexion, ou `null` — lu une fois par
68
+ * poignée de main pour ses réglages de contre-pression. Un `WebSocketServer`
69
+ * porte toujours ses `options` ; aucun serveur HTTP n'en déclare.
70
+ */
71
+ function webSocketServerOf(ctx) {
72
+ const server = ctx.server;
73
+ return server && "options" in server ? server : null;
74
+ }
56
75
  let warnedUnenforcedPolicies = false;
57
76
  /**
58
77
  * RealtimeController — base d'un endpoint WebSocket temps réel SERVEUR (JSON-RPC 2.0).
@@ -62,7 +81,7 @@ let warnedUnenforcedPolicies = false;
62
81
  * — écrit UNE fois, partagé par tous les modules. Un contrôleur concret ne déclare que
63
82
  * son métier : {@link createRealtimeChannel} (providers de canaux) + {@link realtimeActions}.
64
83
  *
65
- * Chaque connexion compose un `JsonRpcPeer` (le MÊME que `RealtimeClient` côté navigateur
84
+ * Chaque connexion compose un `JsonRpcPeer` (le MÊME que `NodefonySocket` côté navigateur
66
85
  * — symétrie isomorphe) branché sur un {@link WsConnectionTransport}.
67
86
  *
68
87
  * Usage : le sous-classe garde sa route WS et délègue —
@@ -179,13 +198,13 @@ var RealtimeController = class extends Controller {
179
198
  * est structurel, pas un choix : c'est au client d'attendre `realtime:welcome`
180
199
  * avant de pousser.
181
200
  *
182
- * ⚠️ Cette phrase a longtemps ajouté « ce que `RealtimeClient` fait nativement ».
201
+ * ⚠️ Cette phrase a longtemps ajouté « ce que le client fait nativement ».
183
202
  * C'était FAUX : le client rejouait ses abonnements dès l'ouverture de la socket,
184
203
  * si bien qu'un `subscribe` posé avant le démarrage et TOUS ceux d'après une
185
204
  * reconnexion étaient perdus ici même, sans un mot des deux côtés. Un contrat écrit
186
205
  * d'un seul côté du fil n'est pas tenu ; il l'est désormais par
187
- * `RealtimeClient.replaySubscriptions` et les deux cas « la fenêtre où le serveur
188
- * écoute (welcome) » de `RealtimeClientCoverage.test.ts`.
206
+ * `NodefonySocket.replaySubscriptions` et les deux cas « la fenêtre où le serveur
207
+ * écoute (welcome) » de `NodefonySocketCoverage.test.ts`.
189
208
  */
190
209
  handleRealtime(message) {
191
210
  const ctx = this.context;
@@ -270,7 +289,7 @@ var RealtimeController = class extends Controller {
270
289
  return;
271
290
  }
272
291
  else token = ANONYMOUS_REALTIME_TOKEN;
273
- const transport = new WsConnectionTransport(conn, readBackpressureOptions(ctx.server ?? null));
292
+ const transport = new WsConnectionTransport(conn, readBackpressureOptions(webSocketServerOf(ctx)));
274
293
  const peerOptions = {
275
294
  send: (frame) => {
276
295
  let json;
@@ -278,16 +297,9 @@ var RealtimeController = class extends Controller {
278
297
  json = JSON.stringify(frame);
279
298
  } catch (e) {
280
299
  this.log(`WS realtime send: payload non sérialisable — ${e instanceof Error ? e.message : String(e)}`, "ERROR");
281
- const id = frame.id;
282
- if (id === void 0) return;
283
- json = JSON.stringify({
284
- jsonrpc: "2.0",
285
- id,
286
- error: {
287
- code: -32603,
288
- message: "internal error: non-serializable payload"
289
- }
290
- });
300
+ const id = isRecord(frame) ? frame.id : void 0;
301
+ if (!isJsonRpcId(id)) return;
302
+ json = JSON.stringify(jsonRpcFailure(id, JsonRpcError.INTERNAL_ERROR, "internal error: non-serializable payload"));
291
303
  }
292
304
  transport.send(json);
293
305
  },
@@ -298,9 +310,10 @@ var RealtimeController = class extends Controller {
298
310
  peerOptions.onFrameAudit = (reason, frame, auditedPeer) => {
299
311
  if (reason !== "denied") return;
300
312
  this.log("WS realtime frame refused by authorizer", "WARNING");
313
+ if (!isRecord(frame)) return;
301
314
  const f = frame;
302
315
  if (f.id !== void 0) return;
303
- const channel = f.method === "subscribe" ? typeof f.params?.channel === "string" ? f.params.channel : void 0 : typeof f.method === "string" ? f.method : void 0;
316
+ const channel = f.method === "subscribe" ? channelParam(f.params) : typeof f.method === "string" ? f.method : void 0;
304
317
  if (channel !== void 0) {
305
318
  const hint = identityHint(this.kernel);
306
319
  const denied = {
@@ -323,7 +336,7 @@ var RealtimeController = class extends Controller {
323
336
  peer.receive(parsed);
324
337
  });
325
338
  const allActions = {
326
- ...getRealtimeActions(this),
339
+ ...getRealtimeActions(this, this.actionParamsWrapper(ctx)),
327
340
  ...this.realtimeActions()
328
341
  };
329
342
  for (const [name, handler] of Object.entries(allActions)) {
@@ -414,11 +427,11 @@ var RealtimeController = class extends Controller {
414
427
  */
415
428
  onRealtimeNotification(ctx, method, params) {
416
429
  if (method === "subscribe") {
417
- this.startChannel(ctx, params?.channel);
430
+ this.startChannel(ctx, channelParam(params));
418
431
  return;
419
432
  }
420
433
  if (method === "unsubscribe") {
421
- this.stopChannel(ctx, params?.channel);
434
+ this.stopChannel(ctx, channelParam(params));
422
435
  return;
423
436
  }
424
437
  const state = ctx.__nfRealtime;
@@ -472,6 +485,48 @@ var RealtimeController = class extends Controller {
472
485
  this.log(floor ? `WS subscribe refusé (canal de plateforme, aucun module de sécurité) → ${channel}` : `WS subscribe refusé (aucun producteur pour ce canal) → ${channel}`, "DEBUG");
473
486
  }
474
487
  /**
488
+ * Enveloppe des actions `@RealtimeAction` dont un paramètre est décoré
489
+ * (`@Body`, `@CurrentUser`…) : l'action reçoit ses arguments comme une route,
490
+ * résolus par la fonction du framework et non par une copie.
491
+ *
492
+ * L'identité ne change PAS : un message est déjà traité dans la bulle
493
+ * `RequestContext` du handshake (`WebsocketContext` lie son écouteur par
494
+ * `AsyncResource.bind`), où le pare-feu a posé `user` et `token`. La bulle
495
+ * ouverte ici HÉRITE de ce store et n'y ajoute que la charge (`body`, que
496
+ * `@Body` lit dans l'ALS comme pour `api.request`) — une écriture
497
+ * `RequestContext.set()` dans l'action reste ainsi propre à l'appel.
498
+ *
499
+ * Une action sans paramètre décoré est rendue telle quelle : rien ne change
500
+ * pour elle, ni comportement ni coût. Les métadonnées se lisent ICI, une fois
501
+ * par connexion.
502
+ *
503
+ * @param ctx - le contexte de la connexion
504
+ * @returns l'enveloppe passée à {@link getRealtimeActions}
505
+ */
506
+ actionParamsWrapper(ctx) {
507
+ const proto = Reflect.getPrototypeOf(this);
508
+ return (method, propertyKey) => {
509
+ if (proto === null) return method;
510
+ const metas = getParamArgsMeta(proto, propertyKey);
511
+ if (metas === null) return method;
512
+ return (params) => {
513
+ const store = {
514
+ requestId: ctx.requestId,
515
+ ...RequestContext.get(),
516
+ body: params
517
+ };
518
+ const result = RequestContext.run(store, () => method(...buildContextParamArgs(metas, ctx, NO_ROUTE_PARAMS, NO_ROUTE_PARAMS)));
519
+ if (isPromise(result)) {
520
+ const release = () => RequestContext.release(store);
521
+ result.then(release, release);
522
+ return result;
523
+ }
524
+ RequestContext.release(store);
525
+ return result;
526
+ };
527
+ };
528
+ }
529
+ /**
475
530
  * Handler de la méthode RPC `api.request {path}` — le **pont API souverain**
476
531
  * (POC Ph.3) : résout le path porté par le message via le Router
477
532
  * (`cleanPathOverride` — l'URL de la connexion n'est jamais mutée) puis
@@ -491,13 +546,13 @@ var RealtimeController = class extends Controller {
491
546
  * Tout autre throw de l'action reste opaque (`-32603`, Zero Trust).
492
547
  */
493
548
  async invokeApiRequest(ctx, params, peer) {
494
- const p = params;
549
+ const p = isRecord(params) ? params : void 0;
495
550
  const path = p?.path;
496
- if (typeof path !== "string" || path.charCodeAt(0) !== 47) throw new RpcError("api.request: params.path invalide", -32602);
551
+ if (typeof path !== "string" || path.charCodeAt(0) !== 47) throw new RpcError("api.request: params.path invalide", JsonRpcError.INVALID_PARAMS);
497
552
  const method = typeof p?.method === "string" ? p.method.toUpperCase() : "GET";
498
- if (method !== "GET" && method !== "POST" && method !== "PUT" && method !== "PATCH" && method !== "DELETE") throw new RpcError(`api.request: méthode ${method} non supportée`, -32602);
553
+ if (method !== "GET" && method !== "POST" && method !== "PUT" && method !== "PATCH" && method !== "DELETE") throw new RpcError(`api.request: méthode ${method} non supportée`, JsonRpcError.INVALID_PARAMS);
499
554
  const router = ctx.router;
500
- if (!router) throw new RpcError("api.request: router indisponible", -32e3, { status: 500 });
555
+ if (!router) throw new RpcError("api.request: router indisponible", JsonRpcServerError.DEFAULT, { status: 500 });
501
556
  const qIdx = path.indexOf("?");
502
557
  const pathname = qIdx === -1 ? path : path.slice(0, qIdx);
503
558
  const frame = ctx.beginFrame(method, path);
@@ -509,7 +564,7 @@ var RealtimeController = class extends Controller {
509
564
  } finally {
510
565
  frame?.phaseEnd("resolve");
511
566
  }
512
- if (!resolver.resolve) throw new RpcError(`api.request: not found ${pathname}`, -32e3, { status: 404 });
567
+ if (!resolver.resolve) throw new RpcError(`api.request: not found ${pathname}`, JsonRpcServerError.DEFAULT, { status: 404 });
513
568
  if (frame) frame.resolver = resolver;
514
569
  if (qIdx !== -1 && qIdx < path.length - 1) {
515
570
  const sp = new URLSearchParams(path.slice(qIdx + 1));
@@ -533,15 +588,14 @@ var RealtimeController = class extends Controller {
533
588
  } finally {
534
589
  frame?.phaseEnd("identity");
535
590
  }
536
- if (!valid) throw new RpcError("api.request: identité de session expirée ou invalide", -32e3, { status: 401 });
591
+ if (!valid) throw new RpcError("api.request: identité de session expirée ou invalide", JsonRpcServerError.DEFAULT, { status: 401 });
537
592
  }
538
593
  const renderSink = {};
539
- const result = await RequestContext.run({
594
+ const store = {
540
595
  requestId: ctx.requestId,
541
596
  scheme: ctx.scheme,
542
597
  token,
543
598
  user: token.getAttribute("user"),
544
- userId: token.getUserIdentifier(),
545
599
  context: ctx,
546
600
  resolver,
547
601
  scope: ctx.container instanceof Scope ? ctx.container : void 0,
@@ -550,24 +604,40 @@ var RealtimeController = class extends Controller {
550
604
  renderSink,
551
605
  invocation: frame ?? void 0,
552
606
  queries: frame?.profilerQueries ?? void 0
553
- }, async () => {
554
- frame?.phaseStart("action");
555
- try {
556
- const { result: actionResult } = await resolver.executeActionGuarded(void 0, true);
557
- const raw = await Promise.resolve(actionResult);
558
- if (renderSink.body !== void 0) {
559
- const text = Buffer.isBuffer(renderSink.body) ? renderSink.body.toString("utf8") : renderSink.body;
560
- try {
561
- return JSON.parse(text);
562
- } catch {
563
- return text;
607
+ };
608
+ let result;
609
+ try {
610
+ result = await RequestContext.run(store, async () => {
611
+ frame?.phaseStart("action");
612
+ 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;
564
632
  }
633
+ return raw;
634
+ } finally {
635
+ frame?.phaseEnd("action");
565
636
  }
566
- return raw;
567
- } finally {
568
- frame?.phaseEnd("action");
569
- }
570
- });
637
+ });
638
+ } finally {
639
+ RequestContext.release(store);
640
+ }
571
641
  frame?.finish(200);
572
642
  return frame ? new RpcEnvelope(result, { requestId: frame.requestId }) : result;
573
643
  } catch (e) {
@@ -1,6 +1,6 @@
1
1
  import { LoopbackBackplane } from "../backplane/LoopbackBackplane.js";
2
2
  import { ANONYMOUS_REALTIME_TOKEN } from "./AnonymousRealtimeToken.js";
3
- import { NODEFONY_CHANNEL_NAMESPACE, isPlatformChannel } from "nodefony";
3
+ import { IDENTITY_REFRESH_CLOSE_CODE, NODEFONY_CHANNEL_NAMESPACE, REVOCATION_REVALIDATE_MS, RevocationWatch, isPlatformChannel } from "nodefony";
4
4
  //#region nodefony/src/server/RealtimeHub.ts
5
5
  /**
6
6
  * Seuil d'alerte slow-consumer (octets de `bufferedAmount`). Au-delà, la connexion
@@ -45,17 +45,6 @@ const RESERVED_SYSTEM_PREFIXES = [NODEFONY_CHANNEL_NAMESPACE];
45
45
  */
46
46
  const isReservedSystemChannel = isPlatformChannel;
47
47
  /**
48
- * Période de re-validation des identités RÉVOCABLES — F4 (revue 0.6).
49
- * Le verrou de frame est SYNC par doctrine (identité figée au handshake, cf
50
- * {@link FrameAuthorizer}) → il ne peut pas re-lire la session par frame. Un socket
51
- * survivrait donc à sa session (`subscribe` garderait ses flux après un logout HTTP),
52
- * là où `api.request` re-valide déjà par requête (`isValid()`). Ce tick ferme
53
- * l'écart : toutes les `REVOCATION_REVALIDATE_MS`, les tokens révocables sont
54
- * re-validés et la connexion fermée si l'identité n'est plus valide (≤ 1 fenêtre de
55
- * délai). Ordre de grandeur aligné sur le heartbeat WS (`keepaliveInterval` 20 s).
56
- */
57
- const REVOCATION_REVALIDATE_MS = 3e4;
58
- /**
59
48
  * Le MOTIF de famille d'un canal — son dernier segment remplacé par `*`.
60
49
  *
61
50
  * `"studio:metrics:1s"` → `"studio:metrics:*"`, `"alerts"` → `"*"`. Sert à
@@ -99,7 +88,7 @@ function channelFamily(channel) {
99
88
  * multiplexe des canaux ; le hub aiguille entre les sockets. La façade *consommateur*
100
89
  * côté serveur EXISTE — {@link ServerRealtimeSocket} (`subscribe/on/publish`), exportée
101
90
  * par le barrel du module : c'est elle qu'un service back tient, jamais ce hub
102
- * directement. Même rôle que `RealtimeClient` côté navigateur.
91
+ * directement. Même rôle que `NodefonySocket` côté navigateur.
103
92
  */
104
93
  var RealtimeHub = class RealtimeHub {
105
94
  #channels = null;
@@ -111,8 +100,12 @@ var RealtimeHub = class RealtimeHub {
111
100
  #notice = null;
112
101
  #noticed = null;
113
102
  #connections = null;
114
- #revocable = null;
115
- #revalidateTimer = null;
103
+ #revocation = new RevocationWatch({
104
+ isValid: (entry) => entry.token.isValid?.(),
105
+ revoke: (entry) => entry.close(4001, "session revoked"),
106
+ identityOf: (entry) => entry.token.getUserIdentifier(),
107
+ refresh: (entry) => entry.close(IDENTITY_REFRESH_CLOSE_CODE, "identity changed")
108
+ });
116
109
  #backplane = null;
117
110
  #authenticators = null;
118
111
  #peerTokens = null;
@@ -433,51 +426,36 @@ var RealtimeHub = class RealtimeHub {
433
426
  * non révocable (anonyme/JWT sans revalidation) ne doit pas y entrer.
434
427
  */
435
428
  registerRevocable(entry) {
436
- (this.#revocable ??= /* @__PURE__ */ new Set()).add(entry);
437
- if (this.#revalidateTimer === null) {
438
- this.#revalidateTimer = setInterval(() => {
439
- this.revalidateRevocable();
440
- }, REVOCATION_REVALIDATE_MS);
441
- this.#revalidateTimer.unref();
442
- }
429
+ this.#revocation.register(entry);
443
430
  }
444
431
  /**
445
432
  * Retire une connexion du registre de révocation (au close). Arrête le tick dès que
446
433
  * le registre est vide (0 timer au repos). No-op si absente.
447
434
  */
448
435
  unregisterRevocable(entry) {
449
- if (this.#revocable === null) return;
450
- this.#revocable.delete(entry);
451
- if (this.#revocable.size === 0 && this.#revalidateTimer !== null) {
452
- clearInterval(this.#revalidateTimer);
453
- this.#revalidateTimer = null;
454
- }
436
+ this.#revocation.unregister(entry);
455
437
  }
456
438
  /**
457
- * Tick de re-validation (F4) : re-lit l'identité de chaque connexion révocable
458
- * (`token.isValid()`) et FERME la socket (`4001`) si l'identité est morte : session
459
- * détruite ou passée à un autre compte, jeton expiré ou révoqué.
460
- * Fail-closed : une re-validation qui throw ferme aussi (parité `invokeApiRequest`).
461
- * Snapshot du registre AVANT les `await` (le `close` mute le registre via le cleanup
462
- * `onFinish`). Exposé (public) pour un test déterministe sans fake timers.
439
+ * Re-valide toutes les connexions révocables et ferme (`4001`) celles dont
440
+ * l'identité est morte : session détruite ou passée à un autre compte, jeton
441
+ * expiré ou révoqué. Fail-closed : une re-validation qui lève ferme aussi. Le
442
+ * registre est celui des flux SSE ({@link RevocationWatch}) — une garde, deux
443
+ * transports. Exposé (public) pour un test déterministe sans faux minuteur.
463
444
  */
464
- async revalidateRevocable() {
465
- if (this.#revocable === null || this.#revocable.size === 0) return;
466
- const snapshot = [...this.#revocable];
467
- for (const entry of snapshot) {
468
- let valid;
469
- try {
470
- valid = await entry.token.isValid?.();
471
- } catch {
472
- valid = false;
473
- }
474
- if (valid === false) {
475
- this.unregisterRevocable(entry);
476
- try {
477
- entry.close(4001, "session revoked");
478
- } catch {}
479
- }
480
- }
445
+ revalidateRevocable() {
446
+ return this.#revocation.revalidate();
447
+ }
448
+ /**
449
+ * Ferme tout de suite les connexions qui ont figé cette identité — ses droits
450
+ * ont changé (`IDENTITY_CHANGED_EVENT`). Code {@link IDENTITY_REFRESH_CLOSE_CODE},
451
+ * reconnectable : le client revient avec ses rôles frais. Le registre est
452
+ * celui des flux SSE — une règle, deux transports.
453
+ *
454
+ * @param identifier - identifiant de connexion de l'identité changée.
455
+ * @returns le nombre de connexions fermées.
456
+ */
457
+ refreshIdentity(identifier) {
458
+ return this.#revocation.refreshIdentity(identifier);
481
459
  }
482
460
  /**
483
461
  * Compte une frame entrante full-duplex (canaux gated SIP/bridge). Appelé par le
@@ -74,6 +74,14 @@ var ServerRealtimeSocket = class {
74
74
  }
75
75
  /** Branche un handler de réception sur un canal. Renvoie un `dispose`. */
76
76
  on(channel, handler) {
77
+ return this.#addHandler(channel, handler);
78
+ }
79
+ /** Retire un handler d'un canal. */
80
+ off(channel, handler) {
81
+ this.#removeHandler(channel, handler);
82
+ }
83
+ /** Porte non typée de {@link on} : un nom de canal quelconque, un handler `unknown`. */
84
+ #addHandler(channel, handler) {
77
85
  const handlers = this.#handlers ??= /* @__PURE__ */ new Map();
78
86
  let set = handlers.get(channel);
79
87
  if (!set) {
@@ -81,10 +89,10 @@ var ServerRealtimeSocket = class {
81
89
  handlers.set(channel, set);
82
90
  }
83
91
  set.add(handler);
84
- return () => this.off(channel, handler);
92
+ return () => this.#removeHandler(channel, handler);
85
93
  }
86
- /** Retire un handler d'un canal. */
87
- off(channel, handler) {
94
+ /** Porte non typée de {@link off}. */
95
+ #removeHandler(channel, handler) {
88
96
  this.#handlers?.get(channel)?.delete(handler);
89
97
  }
90
98
  /**
@@ -102,7 +110,7 @@ var ServerRealtimeSocket = class {
102
110
  return {
103
111
  name,
104
112
  on(handler) {
105
- const dispose = hub.on(name, handler);
113
+ const dispose = hub.#addHandler(name, handler);
106
114
  disposers.add(dispose);
107
115
  return () => {
108
116
  dispose();
@@ -110,7 +118,7 @@ var ServerRealtimeSocket = class {
110
118
  };
111
119
  },
112
120
  send(payload) {
113
- hub.publish(name, payload);
121
+ hub.hub.publish(name, payload);
114
122
  },
115
123
  open() {
116
124
  hub.subscribe(name);
@@ -1,3 +1,4 @@
1
+ import { isRecord } from "../guards.js";
1
2
  import { BROWSER_ORIGIN, Pdu } from "nodefony";
2
3
  //#region nodefony/src/server/syslogUplink.ts
3
4
  /**
@@ -89,8 +90,9 @@ function createSyslogUplinkHandler(opts) {
89
90
  for (let i = 0; i < limit; i += 1) {
90
91
  if (windowCount >= maxEntriesPerWindow) return;
91
92
  windowCount += 1;
92
- const raw = entries[i];
93
- if (raw === null || typeof raw !== "object") continue;
93
+ const entry = entries[i];
94
+ if (!isRecord(entry)) continue;
95
+ const raw = entry;
94
96
  const severity = clampSeverity(raw.severity);
95
97
  const msgid = str(raw.msgid, 64) ?? "";
96
98
  const msg = str(raw.msg, maxStringLength) ?? "";
@@ -1,3 +1,4 @@
1
+ import { isRealtimeConfig } from "../../config/defineModuleConfig.js";
1
2
  import { RESERVED_SYSTEM_PREFIXES, getRealtimeHub } from "../server/RealtimeHub.js";
2
3
  import { applyDeclaredBroadcastPrefixes } from "../../decorators/realtimeDecorators.js";
3
4
  import { Service } from "nodefony";
@@ -35,7 +36,7 @@ var RealtimeService = class extends Service {
35
36
  #config = null;
36
37
  #hub = null;
37
38
  constructor(module) {
38
- super(serviceName, module.container, module.notificationsCenter, module.options);
39
+ super(serviceName, module.container ?? void 0, module.notificationsCenter, module.options);
39
40
  this.module = module;
40
41
  }
41
42
  /**
@@ -55,7 +56,7 @@ var RealtimeService = class extends Service {
55
56
  */
56
57
  async init(_module) {
57
58
  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
+ if (!isRealtimeConfig(config)) throw new Error(`${serviceName}: realtimeConfig absente (this.module.config vide) — la Module class doit la valider à onKernelRegister`);
59
60
  this.#config = config;
60
61
  if (!this.#config.enabled) {
61
62
  this.log("realtime enabled:false → service inerte (hub non câblé)", "INFO");
@@ -1,5 +1,37 @@
1
1
  import { getRealtimeHub } from "../src/server/RealtimeHub.js";
2
+ import { RequestContext } from "nodefony";
2
3
  //#region nodefony/testing/index.ts
4
+ /**
5
+ * `@nodefony/realtime/testing` — **harnais de test d'un controller temps réel**.
6
+ *
7
+ * Écrire un test de socket demandait jusqu'ici une centaine de lignes de
8
+ * plomberie FRAMEWORK avant la première assertion : un faux `ContextType` qui ne
9
+ * fournit que ce que la base touche, un pont vers `handleRealtime` (qui est
10
+ * `protected`), la remise à zéro du hub — singleton par process — entre deux
11
+ * cas, et la pose d'une identité au handshake. Cette plomberie se périme au
12
+ * premier changement de signature de la base ; recopiée dans chaque application,
13
+ * elle y aurait vieilli sans que personne ne le sache. Elle vit donc ICI, avec
14
+ * le code dont elle dépend.
15
+ *
16
+ * Le harnais pilote le **côté serveur** : il injecte des frames JSON-RPC 2.0
17
+ * telles qu'un client les enverrait, et lit celles qui sortent. Il n'embarque
18
+ * PAS `NodefonySocket` — le client est un artefact navigateur du cœur, et le
19
+ * tirer ici le ferait entrer dans le paquet serveur publié. Ce qu'on veut
20
+ * prouver d'un controller (ses canaux, ses actions, son plein-duplex, son
21
+ * nettoyage) s'observe entièrement aux frames.
22
+ *
23
+ * ```ts
24
+ * import { createRealtimeHarness } from "@nodefony/realtime/testing";
25
+ *
26
+ * const h = createRealtimeHarness((ctx) => new ChatController(ctx));
27
+ * await h.connect();
28
+ * await h.subscribe("chat:ticker");
29
+ * expect(h.messages("chat:ticker")).to.have.length.greaterThan(0);
30
+ * h.dispose();
31
+ * ```
32
+ *
33
+ * @packageDocumentation
34
+ */
3
35
  /** `readyState` d'une connexion WebSocket ouverte (`ws` / DOM). */
4
36
  const OPEN = 1;
5
37
  /** Laisse tourner les microtâches et les timers immédiats. */
@@ -79,8 +111,16 @@ function createRealtimeHarness(create, options = {}) {
79
111
  });
80
112
  const bridge = controller;
81
113
  let nextId = 1;
114
+ const identity = options.identity;
115
+ const handshakeStore = identity === void 0 ? { requestId: "realtime-harness" } : {
116
+ requestId: "realtime-harness",
117
+ token: identity,
118
+ user: identity.getAttribute("user")
119
+ };
82
120
  const feed = async (frame) => {
83
- bridge.handleRealtime(frame === null ? null : JSON.stringify(frame));
121
+ RequestContext.run(handshakeStore, () => {
122
+ bridge.handleRealtime(frame === null ? null : JSON.stringify(frame));
123
+ });
84
124
  await tick();
85
125
  };
86
126
  const rpc = (payload) => ({
@@ -0,0 +1,12 @@
1
+ /**
2
+ * JSON Schema de la configuration de `@nodefony/realtime` — lu SANS démarrer
3
+ * l'application, par `nodefony env` et la notice `.env.example`, qui y
4
+ * trouvent les noms courts (`.meta({ env })`).
5
+ *
6
+ * Déclaré dans `package.json` → `nodefony.configSchema` ; le nom de l'export
7
+ * est FIXE. Fichier FEUILLE : il n'importe que zod et le schéma, et n'ouvre
8
+ * rien à l'import — une commande qui ne démarre pas l'application l'exécute.
9
+ *
10
+ * @returns le JSON Schema, métadonnées Nodefony comprises.
11
+ */
12
+ export declare function configJsonSchema(): unknown;
@@ -1,4 +1,4 @@
1
- import { z } from "zod";
1
+ import type { z } from "zod";
2
2
  import { realtimeConfigSchema, type IRealtimeSchemaConfig } from "./config.js";
3
3
  import type { IBackplane } from "../interfaces/IBackplane.js";
4
4
  /**
@@ -19,11 +19,13 @@ import type { IBackplane } from "../interfaces/IBackplane.js";
19
19
  * faire dans un JSON Schema Studio). L'instance, si fournie, prend précédence
20
20
  * sur `driver` côté service ; ce dernier reste source d'introspection/UI.
21
21
  *
22
- * **Layering d'environnement** (convention-frère avec `REDIS_*` de
23
- * `@nodefony/redis`) : `NF_REALTIME_DRIVER` surcharge le driver backplane
24
- * APRÈS le parse (schéma pur, déterministe). Indispensable en déploiement
25
- * (k8s/Docker : `NF_REALTIME_DRIVER=redis`) et fiable quel que soit le
26
- * timing du merge `module-realtime` côté app (cf chantier ordering config).
22
+ * **Variables d'environnement** : `NF_REALTIME_DRIVER`,
23
+ * `NF_REALTIME_BACKPLANE_SECRET` et `NF_REALTIME_BACKPLANE_NAMESPACE` ne se
24
+ * lisent PAS ici. Chacune est déclarée sur son champ dans `config.ts`
25
+ * (`.meta({ env })`) et posée par le Kernel AVANT la validation : le schéma
26
+ * les valide comme une valeur de l'application (un secret de moins de 32
27
+ * caractères arrête le démarrage). Un appel direct de ce builder, hors Kernel,
28
+ * ne les voit donc pas.
27
29
  *
28
30
  * @param config - configuration brute (sections omises = défauts sûrs).
29
31
  * @param options.backplane - instance `IBackplane` custom (override le driver à
@@ -34,6 +36,27 @@ import type { IBackplane } from "../interfaces/IBackplane.js";
34
36
  export declare function defineRealtimeConfig(config?: IRealtimeConfigInput, options?: {
35
37
  backplane?: IBackplane;
36
38
  }): IRealtimeConfig;
39
+ /**
40
+ * Le builder pour une entrée NON typée — la config reçue par le module au
41
+ * démarrage, dont le schéma décide seul de la forme. Interne au paquet : une
42
+ * application passe par {@link defineRealtimeConfig}, qui type ce qu'elle écrit.
43
+ *
44
+ * @param config - configuration brute, validée par le schéma
45
+ * @param options.backplane - instance `IBackplane` custom
46
+ * @returns config gelée prête pour `RealtimeService`.
47
+ * @throws BootConfigurationError si invalide.
48
+ */
49
+ export declare function buildRealtimeConfig(config?: unknown, options?: {
50
+ backplane?: IBackplane;
51
+ }): IRealtimeConfig;
52
+ /**
53
+ * Vrai si la valeur est une config produite par {@link buildRealtimeConfig}
54
+ * (donc validée par le schéma). Lu une fois, à l'init du service.
55
+ *
56
+ * @param value - la config lue sur le module
57
+ * @returns `true` si elle sort du builder
58
+ */
59
+ export declare function isRealtimeConfig(value: unknown): value is IRealtimeConfig;
37
60
  /**
38
61
  * JSON Schema introspectable de la config realtime — destiné au formulaire
39
62
  * d'édition Studio (futur). N'inclut PAS `backplane.instance` (non sérialisable).