@nodefony/realtime 10.0.0-alpha.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.
Files changed (81) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +207 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/index.js +218 -0
  6. package/dist/nodefony/config/config.js +63 -0
  7. package/dist/nodefony/config/defineModuleConfig.js +60 -0
  8. package/dist/nodefony/decorators/realtimeDecorators.js +240 -0
  9. package/dist/nodefony/interfaces/IBackplane.js +1 -0
  10. package/dist/nodefony/interfaces/IChannelPolicy.js +1 -0
  11. package/dist/nodefony/interfaces/IClientLogsLimits.js +1 -0
  12. package/dist/nodefony/interfaces/IRealtimeAuthenticator.js +1 -0
  13. package/dist/nodefony/interfaces/IRealtimeAuthenticatorMatcher.js +1 -0
  14. package/dist/nodefony/interfaces/IRealtimeController.js +1 -0
  15. package/dist/nodefony/interfaces/IRealtimeHandshake.js +1 -0
  16. package/dist/nodefony/interfaces/IRealtimeProbe.js +1 -0
  17. package/dist/nodefony/interfaces/IRealtimeToken.js +1 -0
  18. package/dist/nodefony/interfaces/index.js +1 -0
  19. package/dist/nodefony/src/backplane/ClusterBackplane.js +105 -0
  20. package/dist/nodefony/src/backplane/LoopbackBackplane.js +40 -0
  21. package/dist/nodefony/src/backplane/RedisBackplane.js +176 -0
  22. package/dist/nodefony/src/backplane/backplaneRegistry.js +20 -0
  23. package/dist/nodefony/src/backplane/envelope.js +109 -0
  24. package/dist/nodefony/src/backplane/originId.js +35 -0
  25. package/dist/nodefony/src/backplane/publishQueue.js +96 -0
  26. package/dist/nodefony/src/cluster/ClusterProbeClient.js +284 -0
  27. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +35 -0
  28. package/dist/nodefony/src/server/RealtimeAdminApi.js +87 -0
  29. package/dist/nodefony/src/server/RealtimeController.js +601 -0
  30. package/dist/nodefony/src/server/RealtimeHub.js +926 -0
  31. package/dist/nodefony/src/server/ServerRealtimeSocket.js +168 -0
  32. package/dist/nodefony/src/server/deniedDetail.js +29 -0
  33. package/dist/nodefony/src/server/syslogUplink.js +105 -0
  34. package/dist/nodefony/src/server/welcomeEnv.js +25 -0
  35. package/dist/nodefony/src/service/RealtimeService.js +240 -0
  36. package/dist/nodefony/src/transport/WsConnectionTransport.js +94 -0
  37. package/dist/nodefony/testing/index.js +163 -0
  38. package/dist/types/index.d.ts +99 -0
  39. package/dist/types/nodefony/config/config.d.ts +43 -0
  40. package/dist/types/nodefony/config/defineModuleConfig.d.ts +49 -0
  41. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +190 -0
  42. package/dist/types/nodefony/interfaces/IBackplane.d.ts +128 -0
  43. package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +28 -0
  44. package/dist/types/nodefony/interfaces/IClientLogsLimits.d.ts +22 -0
  45. package/dist/types/nodefony/interfaces/IRealtimeAuthenticator.d.ts +53 -0
  46. package/dist/types/nodefony/interfaces/IRealtimeAuthenticatorMatcher.d.ts +46 -0
  47. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +36 -0
  48. package/dist/types/nodefony/interfaces/IRealtimeHandshake.d.ts +27 -0
  49. package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +206 -0
  50. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +48 -0
  51. package/dist/types/nodefony/interfaces/index.d.ts +18 -0
  52. package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +76 -0
  53. package/dist/types/nodefony/src/backplane/LoopbackBackplane.d.ts +29 -0
  54. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +164 -0
  55. package/dist/types/nodefony/src/backplane/backplaneRegistry.d.ts +51 -0
  56. package/dist/types/nodefony/src/backplane/envelope.d.ts +27 -0
  57. package/dist/types/nodefony/src/backplane/originId.d.ts +22 -0
  58. package/dist/types/nodefony/src/backplane/publishQueue.d.ts +71 -0
  59. package/dist/types/nodefony/src/cluster/ClusterProbeClient.d.ts +90 -0
  60. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +3 -0
  61. package/dist/types/nodefony/src/server/RealtimeAdminApi.d.ts +18 -0
  62. package/dist/types/nodefony/src/server/RealtimeController.d.ts +165 -0
  63. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +516 -0
  64. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +63 -0
  65. package/dist/types/nodefony/src/server/deniedDetail.d.ts +25 -0
  66. package/dist/types/nodefony/src/server/syslogUplink.d.ts +58 -0
  67. package/dist/types/nodefony/src/server/welcomeEnv.d.ts +21 -0
  68. package/dist/types/nodefony/src/service/RealtimeService.d.ts +167 -0
  69. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +78 -0
  70. package/dist/types/nodefony/testing/index.d.ts +169 -0
  71. package/docs/actions.md +573 -0
  72. package/docs/architecture.md +889 -0
  73. package/docs/configuration.md +803 -0
  74. package/docs/cookbook-chat.md +1619 -0
  75. package/docs/couverture-bancs.md +139 -0
  76. package/docs/index.md +372 -0
  77. package/docs/observabilite.md +592 -0
  78. package/docs/protocole.md +518 -0
  79. package/docs/securite.md +845 -0
  80. package/docs/vocabulaire.md +845 -0
  81. package/package.json +83 -0
@@ -0,0 +1,109 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ //#region nodefony/src/backplane/envelope.ts
3
+ /**
4
+ * **Sceau d'enveloppe backplane** — authenticité des messages transportés sur un
5
+ * bus **PARTAGÉ** (Redis pub/sub, et tout driver userland cross-host : NATS,
6
+ * RabbitMQ…). Mutualisé ici pour qu'un driver tiers hérite de la même garantie
7
+ * sans la réécrire (F83).
8
+ *
9
+ * Pourquoi : un bus partagé n'authentifie pas l'ÉMETTEUR d'un message. Sans sceau,
10
+ * quiconque écrit dans le Redis (autre app d'un Redis mutualisé, credential fuité,
11
+ * SSRF vers le port Redis) publie sur les canaux de **tous** les pods de l'app.
12
+ * Le sceau prouve que le message vient d'un pair qui détient le secret partagé.
13
+ *
14
+ * Ce qui NE s'applique PAS ici : le {@link ClusterBackplane} (IPC). Son transport
15
+ * est authentifié par construction — un canal IPC ne relie qu'un master à SES
16
+ * propres workers, il n'existe aucun tiers capable d'y écrire. Y imposer un secret
17
+ * serait de la friction pure.
18
+ *
19
+ * Algorithme : HMAC-SHA256 sur la **chaîne canonique** `originId\nchannel\npayload`
20
+ * (payload sérialisé), encodé en base64url. Comparaison à temps constant
21
+ * ({@link timingSafeEqual}) — une comparaison `===` fuiterait le sceau attendu
22
+ * octet par octet.
23
+ *
24
+ * Portée : le sceau couvre l'origine, le canal ET la charge → ni repointage de
25
+ * canal (`chat:` → `nodefony:audit`), ni substitution de charge.
26
+ *
27
+ * Hors portée (assumé, cf sémantique at-most-once du port) : le **rejeu**. Un
28
+ * tiers qui lit le bus peut re-publier un message scellé intact ; l'effet se
29
+ * limite à re-diffuser une charge déjà diffusée sur un canal déjà broadcast, que
30
+ * le client realtime re-synchronise. Un anti-rejeu (horodatage + fenêtre) exigerait
31
+ * des horloges alignées entre pods pour un gain nul face à ce modèle de menace.
32
+ */
33
+ /** Nom du champ portant le sceau dans l'enveloppe sérialisée. */
34
+ const SEAL_FIELD = "sig";
35
+ /**
36
+ * Sceau HMAC-SHA256 (base64url) d'un triplet **déjà sérialisé**. Le `\n` sépare
37
+ * trois fragments JSON : les deux premiers sont des chaînes échappées, donc
38
+ * aucun décalage de frontière n'est possible entre les parties.
39
+ *
40
+ * Prend les fragments et non le message : la charge n'est sérialisée **qu'une
41
+ * fois** par publication (elle sert et au sceau et à l'enveloppe finale) — sur
42
+ * un canal de fan-out, une seconde sérialisation par message se paierait à
43
+ * chaque diffusion.
44
+ */
45
+ function seal(originIdJson, channelJson, payloadJson, secret) {
46
+ return createHmac("sha256", secret).update(`${originIdJson}\n${channelJson}\n${payloadJson}`).digest("base64url");
47
+ }
48
+ /**
49
+ * Sérialise un message backplane, scellé si un secret est fourni.
50
+ *
51
+ * L'enveloppe scellée est assemblée par concaténation des fragments déjà
52
+ * sérialisés — un `JSON.stringify` d'objet complet re-sérialiserait la charge
53
+ * une seconde fois.
54
+ *
55
+ * @param msg - message à transporter (canal, charge, origine).
56
+ * @param secret - secret partagé entre pods ; `null`/`undefined` → enveloppe nue.
57
+ * @returns la chaîne à publier sur le bus.
58
+ */
59
+ function sealBackplaneEnvelope(msg, secret) {
60
+ if (!secret) return JSON.stringify(msg);
61
+ const channelJson = JSON.stringify(msg.channel);
62
+ const originIdJson = JSON.stringify(msg.originId);
63
+ const payloadJson = JSON.stringify(msg.payload) ?? "null";
64
+ const sig = seal(originIdJson, channelJson, payloadJson, secret);
65
+ return `{"channel":${channelJson},"payload":${payloadJson},"originId":${originIdJson},"${SEAL_FIELD}":"${sig}"}`;
66
+ }
67
+ /** Type-guard d'enveloppe — narrowing sûr d'un message brut venu du bus. */
68
+ function isEnvelope(m) {
69
+ if (typeof m !== "object" || m === null) return false;
70
+ const e = m;
71
+ return typeof e.channel === "string" && typeof e.originId === "string";
72
+ }
73
+ /**
74
+ * Parse et **vérifie** une enveloppe reçue du bus partagé.
75
+ *
76
+ * Fail-closed strict quand un secret est posé : une enveloppe sans sceau ou au
77
+ * sceau invalide est rejetée — **aucun downgrade** possible en retirant le champ
78
+ * `sig`. Sans secret, l'enveloppe nue est acceptée (compat ; l'alerte de boot qui
79
+ * signale ce mode non authentifié est portée par le wiring du driver).
80
+ *
81
+ * @param raw - message brut lu sur le bus.
82
+ * @param secret - secret partagé attendu, ou `null`/`undefined` si aucun.
83
+ * @returns le message vérifié, ou `null` s'il doit être ignoré (malformé, non
84
+ * scellé alors qu'un secret est exigé, sceau invalide).
85
+ */
86
+ function openBackplaneEnvelope(raw, secret) {
87
+ let parsed;
88
+ try {
89
+ parsed = JSON.parse(raw);
90
+ } catch {
91
+ return null;
92
+ }
93
+ if (!isEnvelope(parsed)) return null;
94
+ const msg = {
95
+ channel: parsed.channel,
96
+ payload: parsed.payload,
97
+ originId: parsed.originId
98
+ };
99
+ if (!secret) return msg;
100
+ const provided = parsed[SEAL_FIELD];
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);
103
+ const a = Buffer.from(provided, "base64url");
104
+ const b = Buffer.from(expected, "base64url");
105
+ if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
106
+ return msg;
107
+ }
108
+ //#endregion
109
+ export { openBackplaneEnvelope, sealBackplaneEnvelope };
@@ -0,0 +1,35 @@
1
+ import { hostname } from "node:os";
2
+ import { randomUUID } from "node:crypto";
3
+ //#region nodefony/src/backplane/originId.ts
4
+ /**
5
+ * Résout l'identifiant d'origine d'un backplane — l'étiquette que l'anti-écho
6
+ * compare pour jeter les messages qui reviennent à leur émetteur.
7
+ *
8
+ * POURQUOI pas `String(process.pid)` (ex-défaut) : en conteneur, le namespace
9
+ * PID est PAR conteneur → deux pods k8s peuvent tous deux être PID 1. Avec un
10
+ * backplane cross-pod (Redis/Kafka), l'anti-écho confond alors les pods et
11
+ * jette SILENCIEUSEMENT le fan-out légitime (dette 🔴 #2 du module).
12
+ *
13
+ * Résolution (du plus explicite au filet) :
14
+ * 1. `NF_POD_NAME` (downward API k8s, opt-in opérateur) ;
15
+ * 2. `os.hostname()` — en k8s = nom du pod (unique), en docker = id court du
16
+ * conteneur (unique), en bare-metal = nom de machine ;
17
+ * 3. suffixe `:pid` TOUJOURS ajouté : distingue les workers d'un même host
18
+ * (cluster bare-metal `-w N` + driver redis = même hostname, pids ≠) ;
19
+ * 4. hostname indisponible/vide (exotique) → `randomUUID()` (l'anti-écho n'a
20
+ * pas besoin de stabilité entre restarts, seulement d'unicité à l'instant t).
21
+ *
22
+ * Évalué à la CONSTRUCTION du backplane (boot) — 0 coût par message.
23
+ */
24
+ function resolveBackplaneOriginId() {
25
+ let host = process.env.NF_POD_NAME;
26
+ if (!host) try {
27
+ host = hostname();
28
+ } catch {
29
+ host = "";
30
+ }
31
+ if (!host) return randomUUID();
32
+ return `${host}:${process.pid}`;
33
+ }
34
+ //#endregion
35
+ export { resolveBackplaneOriginId as default, resolveBackplaneOriginId };
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Seuil par défaut d'octets en vol vers le bus : **8 MiB**, aligné sur le seuil de
3
+ * coupure d'un slow-consumer WS. Un bus sain acquitte en quelques millisecondes
4
+ * (3 ms mesurés cross-pod) : 8 MiB d'arriéré, c'est déjà trois ordres de grandeur
5
+ * au-dessus du régime nominal — le pod a un problème bien avant d'y arriver.
6
+ */
7
+ const DEFAULT_MAX_QUEUE_BYTES = 1 << 23;
8
+ /** Vrai si la valeur se comporte comme une promesse (transport asynchrone). */
9
+ function isThenable(v) {
10
+ return typeof v === "object" && v !== null && typeof v.then === "function";
11
+ }
12
+ /**
13
+ * File d'envoi bornée : décide d'admettre ou de jeter une publication, puis suit
14
+ * son acquittement pour rendre la place.
15
+ *
16
+ * Un transport **synchrone** (bus mémoire des tests, IPC) ne rend pas de promesse :
17
+ * il n'a donc pas de file, la garde reste alors inerte et ne jette jamais rien.
18
+ */
19
+ var BackplanePublishQueue = class {
20
+ #maxBytes;
21
+ #notice;
22
+ #bytes = 0;
23
+ #droppedTotal = 0;
24
+ #failedTotal = 0;
25
+ /** Vrai entre le franchissement du seuil et le retour à la normale (1 log par épisode). */
26
+ #saturated = false;
27
+ /** Jetés de l'épisode de saturation courant — chiffre annoncé au retour à la normale. */
28
+ #droppedInEpisode = 0;
29
+ /**
30
+ * @param maxBytes - seuil d'octets en vol ; `0` (ou négatif) = illimité.
31
+ * @param notice - annonce des transitions ; omis = silencieux (tests unitaires).
32
+ */
33
+ constructor(maxBytes = DEFAULT_MAX_QUEUE_BYTES, notice = null) {
34
+ this.#maxBytes = maxBytes > 0 ? maxBytes : 0;
35
+ this.#notice = notice;
36
+ }
37
+ /**
38
+ * Admet ou jette une publication, et suit son acquittement.
39
+ *
40
+ * L'admission regarde l'état de la file **avant** l'envoi, pas la taille du
41
+ * message : une charge plus grosse que le seuil part quand la file est vide
42
+ * (sinon elle ne partirait jamais — famine silencieuse), quitte à dépasser
43
+ * transitoirement. Même règle que le drop WS, qui teste `bufferedAmount` seul.
44
+ *
45
+ * @param bytes - taille de la charge sérialisée.
46
+ * @param emit - envoi réel ; appelé UNIQUEMENT si la publication est admise.
47
+ * Son retour est suivi s'il s'agit d'une promesse (transport asynchrone).
48
+ * @returns `true` si la publication est partie, `false` si elle a été jetée.
49
+ */
50
+ send(bytes, emit) {
51
+ if (this.#maxBytes > 0 && this.#bytes >= this.#maxBytes) {
52
+ this.#droppedTotal += 1;
53
+ this.#droppedInEpisode += 1;
54
+ if (!this.#saturated) {
55
+ this.#saturated = true;
56
+ this.#notice?.(`backplane : file d'envoi saturée (${this.#bytes} octets en vol ≥ seuil ${this.#maxBytes}) — publications JETÉES tant que le bus ne draine pas. Le fan-out cross-pod est incomplet. Causes : bus lent ou injoignable, débit publié trop élevé. Levier : backplane.maxQueueBytes.`, "WARNING");
57
+ }
58
+ return false;
59
+ }
60
+ const result = emit();
61
+ if (!isThenable(result)) return true;
62
+ this.#bytes += bytes;
63
+ const settle = (failed) => {
64
+ this.#bytes -= bytes;
65
+ if (failed) this.#failedTotal += 1;
66
+ this.#drain();
67
+ };
68
+ result.then(() => settle(false), () => settle(true));
69
+ return true;
70
+ }
71
+ /**
72
+ * Retour à la normale — annoncé une seule fois, avec le bilan des pertes.
73
+ *
74
+ * Hystérésis à la moitié du seuil : repasser sous le seuil tout court ferait
75
+ * osciller l'état (et les logs) à chaque acquittement en régime saturé.
76
+ */
77
+ #drain() {
78
+ if (!this.#saturated) return;
79
+ if (this.#maxBytes > 0 && this.#bytes * 2 > this.#maxBytes) return;
80
+ const lost = this.#droppedInEpisode;
81
+ this.#saturated = false;
82
+ this.#droppedInEpisode = 0;
83
+ this.#notice?.(`backplane : file d'envoi drainée (${this.#bytes} octets en vol) — ${lost} publication(s) perdue(s) pendant la saturation.`, "INFO");
84
+ }
85
+ /** Compteurs pour la carte d'identité du backplane (sonde + Studio). */
86
+ describe() {
87
+ return {
88
+ bytes: this.#bytes,
89
+ maxBytes: this.#maxBytes,
90
+ droppedTotal: this.#droppedTotal,
91
+ failedTotal: this.#failedTotal
92
+ };
93
+ }
94
+ };
95
+ //#endregion
96
+ export { BackplanePublishQueue, BackplanePublishQueue as default, DEFAULT_MAX_QUEUE_BYTES };
@@ -0,0 +1,284 @@
1
+ import { CLUSTER_PROBE_CTL_KIND, CLUSTER_PROBE_KIND, CLUSTER_PROBE_SNAPSHOT_KIND, RichProcessProbe, isClusterMessage, isClusterProbeEnrich, readOrmRich } from "nodefony";
2
+ //#region nodefony/src/cluster/ClusterProbeClient.ts
3
+ /** Transport de production : `process.send` + `process.on("message")`. */
4
+ const processProbeTransport = {
5
+ send(report) {
6
+ process.send?.(report);
7
+ },
8
+ onReceive(cb) {
9
+ process.on("message", (msg) => cb(msg));
10
+ }
11
+ };
12
+ /**
13
+ * Consolide N santés per-instance en une vue **POD** ({@link IRealtimeClusterHealth}).
14
+ * Pur (aucune I/O) : somme les scalaires ; la backpressure `maxBufferedAmount` est un MAX
15
+ * (le pire worker), `totalBufferedAmount`/`slowConsumers` des sommes.
16
+ *
17
+ * @param instances - santés per-instance (chaque worker, snapshot master).
18
+ * @param ts - horodatage du snapshot (défaut maintenant).
19
+ */
20
+ function mergeClusterHealth(instances, ts = Date.now()) {
21
+ const totals = {
22
+ channelCount: 0,
23
+ publishTotal: 0,
24
+ fanoutTotal: 0,
25
+ inboundTotal: 0,
26
+ ingressRejectedTotal: 0,
27
+ connectionCount: 0,
28
+ bytesSentTotal: 0,
29
+ messagesSentTotal: 0,
30
+ backpressure: {
31
+ maxBufferedAmount: 0,
32
+ totalBufferedAmount: 0,
33
+ slowConsumers: 0,
34
+ drops: 0
35
+ }
36
+ };
37
+ let orm;
38
+ let errors;
39
+ for (const h of instances) {
40
+ totals.channelCount += h.channelCount;
41
+ totals.publishTotal += h.publishTotal;
42
+ totals.fanoutTotal += h.fanoutTotal;
43
+ totals.inboundTotal += h.inboundTotal;
44
+ totals.ingressRejectedTotal += h.ingressRejectedTotal;
45
+ totals.connectionCount += h.connectionCount;
46
+ totals.bytesSentTotal += h.bytesSentTotal;
47
+ totals.messagesSentTotal += h.messagesSentTotal;
48
+ const bp = h.backpressure;
49
+ if (bp.maxBufferedAmount > totals.backpressure.maxBufferedAmount) totals.backpressure.maxBufferedAmount = bp.maxBufferedAmount;
50
+ totals.backpressure.totalBufferedAmount += bp.totalBufferedAmount;
51
+ totals.backpressure.slowConsumers += bp.slowConsumers;
52
+ totals.backpressure.drops += bp.drops;
53
+ if (h.orm) {
54
+ orm ??= {
55
+ connectors: 0,
56
+ connected: 0,
57
+ queryTotal: 0,
58
+ slowTotal: 0,
59
+ errorTotal: 0,
60
+ reconnectTotal: 0,
61
+ maxEwmaMs: null,
62
+ assumed: 0
63
+ };
64
+ orm.connectors += h.orm.connectors;
65
+ orm.connected += h.orm.connected;
66
+ orm.assumed += h.orm.assumed;
67
+ orm.queryTotal += h.orm.queryTotal;
68
+ orm.slowTotal += h.orm.slowTotal;
69
+ orm.errorTotal += h.orm.errorTotal;
70
+ orm.reconnectTotal += h.orm.reconnectTotal;
71
+ if (h.orm.maxEwmaMs !== null && (orm.maxEwmaMs === null || h.orm.maxEwmaMs > orm.maxEwmaMs)) orm.maxEwmaMs = h.orm.maxEwmaMs;
72
+ }
73
+ if (h.errors) {
74
+ errors ??= {
75
+ errorTotal: 0,
76
+ criticTotal: 0
77
+ };
78
+ errors.errorTotal += h.errors.errorTotal;
79
+ errors.criticTotal += h.errors.criticTotal;
80
+ }
81
+ }
82
+ if (orm) totals.orm = orm;
83
+ if (errors) totals.errors = errors;
84
+ return {
85
+ cluster: true,
86
+ ts,
87
+ instanceCount: instances.length,
88
+ instances,
89
+ totals
90
+ };
91
+ }
92
+ /**
93
+ * Client de sonde cluster **côté worker** (Phase 4c) — le pendant de
94
+ * {@link ClusterProbeAggregator} (master). Deux rôles :
95
+ * - **report** : envoie périodiquement sa santé per-instance au master (`CLUSTER_PROBE_KIND`) ;
96
+ * - **cache** : reçoit le snapshot agrégé du master (`CLUSTER_PROBE_SNAPSHOT_KIND`) et le garde
97
+ * → n'importe quel worker sert la vue POD en O(1) (modèle push : 0 latence de requête).
98
+ *
99
+ * **Bypass total quand désactivé** : ce client n'est instancié/`start()` QUE par le module
100
+ * Framework en worker de cluster AVEC la sonde activée. Sinon (mono-process, ou sonde coupée)
101
+ * il n'existe pas → aucun timer, aucun listener, aucun IPC, et {@link clusterProbeHealth}
102
+ * renvoie `null` → l'endpoint santé retombe sur la vue per-instance. Aucun coût « au cas où ».
103
+ *
104
+ * Perf : 1 timer `unref` (report) + 1 listener IPC. Le report sérialise 1 santé / intervalle
105
+ * (≥ 1 s, jamais hot path). SERVEUR uniquement.
106
+ */
107
+ var ClusterProbeClient = class {
108
+ #transport;
109
+ #intervalMs;
110
+ #lastInstances = null;
111
+ #snapTs = 0;
112
+ #timer = null;
113
+ #started = false;
114
+ #richProbe = null;
115
+ #richEnabled = false;
116
+ #ormRich = null;
117
+ #ormRichTimer = null;
118
+ #ormRichEnabled = false;
119
+ constructor(transport = processProbeTransport, intervalMs = 2e3) {
120
+ this.#transport = transport;
121
+ this.#intervalMs = intervalMs;
122
+ }
123
+ /**
124
+ * Démarre le report périodique + l'écoute des snapshots. Idempotent. 1ᵉʳ report immédiat.
125
+ *
126
+ * @param buildOwn - fournit la santé per-instance courante (capturée à chaque tick).
127
+ */
128
+ start(buildOwn) {
129
+ if (this.#started) return;
130
+ this.#started = true;
131
+ this.#transport.onReceive((msg) => this.#ingest(msg));
132
+ this.#report(buildOwn);
133
+ this.#timer = setInterval(() => this.#report(buildOwn), this.#intervalMs);
134
+ this.#timer.unref?.();
135
+ }
136
+ #report(buildOwn) {
137
+ const payload = buildOwn();
138
+ if (this.#richEnabled) {
139
+ if (this.#richProbe === null) this.#richProbe = new RichProcessProbe();
140
+ payload.rich = this.#richProbe.read();
141
+ }
142
+ if (this.#ormRich !== null) payload.ormRich = this.#ormRich;
143
+ this.#transport.send({
144
+ kind: CLUSTER_PROBE_KIND,
145
+ payload
146
+ });
147
+ }
148
+ /**
149
+ * Démarre le rafraîchissement périodique du blob ORM riche (facette `"orm"`). Idempotent.
150
+ * Le `connection/health` étant ASYNC (ping), on ne peut pas le lire dans le report sync :
151
+ * un ticker `unref` met à jour le cache `#ormRich`, que le report joint ensuite. 1ᵉʳ fetch
152
+ * immédiat. No-op si aucun driver n'a branché `setOrmRichProvider` (`readOrmRich() === null`).
153
+ */
154
+ #startOrmRich() {
155
+ if (this.#ormRichTimer !== null) return;
156
+ const refresh = () => {
157
+ const p = readOrmRich();
158
+ if (p === null) {
159
+ this.#ormRich = null;
160
+ return;
161
+ }
162
+ p.then((r) => {
163
+ if (this.#ormRichEnabled) this.#ormRich = r;
164
+ }).catch(() => {});
165
+ };
166
+ refresh();
167
+ this.#ormRichTimer = setInterval(refresh, this.#intervalMs);
168
+ this.#ormRichTimer.unref?.();
169
+ }
170
+ /** Arrête le rafraîchissement ORM riche et purge le cache. Idempotent. */
171
+ #stopOrmRich() {
172
+ if (this.#ormRichTimer !== null) {
173
+ clearInterval(this.#ormRichTimer);
174
+ this.#ormRichTimer = null;
175
+ }
176
+ this.#ormRich = null;
177
+ }
178
+ /**
179
+ * Demande au master d'(arrêter d')enrichir le worker `pid` sur une **facette** (drill-down).
180
+ * Émis par le worker qui tient la connexion navigateur quand un client subscribe/unsubscribe
181
+ * le canal `nodefony:supervision@<pid>` (facette `"process"`) ou `nodefony:orm:rich@<pid>` (facette
182
+ * `"orm"`). No-op hors cluster (`send` no-op).
183
+ *
184
+ * @param pid - worker ciblé (identité de la vue pod).
185
+ * @param enable - `true` = activer la sonde riche sur ce worker, `false` = la couper.
186
+ * @param facet - quelle sonde riche cibler (défaut `"process"`).
187
+ */
188
+ requestEnrich(pid, enable, facet = "process") {
189
+ this.#transport.send({
190
+ kind: CLUSTER_PROBE_CTL_KIND,
191
+ op: enable ? "enrich" : "stop",
192
+ pid,
193
+ facet
194
+ });
195
+ }
196
+ /**
197
+ * Met en cache le dernier snapshot agrégé, OU applique un ordre d'enrichissement ciblé
198
+ * du master (`nf:probe:enrich`). Ignore les autres kinds + malformés.
199
+ */
200
+ #ingest(msg) {
201
+ if (isClusterProbeEnrich(msg)) {
202
+ if ((msg.facet ?? "process") === "orm") {
203
+ this.#ormRichEnabled = msg.enabled;
204
+ if (msg.enabled) this.#startOrmRich();
205
+ else this.#stopOrmRich();
206
+ return;
207
+ }
208
+ this.#richEnabled = msg.enabled;
209
+ if (!msg.enabled && this.#richProbe !== null) {
210
+ this.#richProbe.disable();
211
+ this.#richProbe = null;
212
+ }
213
+ return;
214
+ }
215
+ if (!isClusterMessage(msg) || msg.kind !== CLUSTER_PROBE_SNAPSHOT_KIND) return;
216
+ const snap = msg;
217
+ if (Array.isArray(snap.instances)) {
218
+ this.#lastInstances = snap.instances;
219
+ this.#snapTs = typeof snap.ts === "number" ? snap.ts : Date.now();
220
+ }
221
+ }
222
+ /** Vue POD agrégée, ou `null` tant qu'aucun snapshot n'est reçu (cold start). */
223
+ getClusterHealth() {
224
+ if (this.#lastInstances === null) return null;
225
+ return mergeClusterHealth(this.#lastInstances, this.#snapTs);
226
+ }
227
+ /** Arrête le report et purge le cache. Idempotent. Libère la sonde riche (drill). */
228
+ stop() {
229
+ if (this.#timer !== null) {
230
+ clearInterval(this.#timer);
231
+ this.#timer = null;
232
+ }
233
+ this.#started = false;
234
+ this.#lastInstances = null;
235
+ this.#richEnabled = false;
236
+ if (this.#richProbe !== null) {
237
+ this.#richProbe.disable();
238
+ this.#richProbe = null;
239
+ }
240
+ this.#ormRichEnabled = false;
241
+ this.#stopOrmRich();
242
+ }
243
+ };
244
+ let _client = null;
245
+ /** Branche le client de sonde cluster du process (worker). Remplace un éventuel précédent. */
246
+ function setClusterProbeClient(client) {
247
+ _client = client;
248
+ return client;
249
+ }
250
+ /**
251
+ * Vue POD agrégée si la sonde cluster est branchée ET a reçu un snapshot, sinon `null`.
252
+ * `null` ⇒ l'endpoint santé sert la vue per-instance (mono-process / sonde désactivée /
253
+ * cold start). Lecture pure, jamais throw.
254
+ */
255
+ function clusterProbeHealth() {
256
+ return _client?.getClusterHealth() ?? null;
257
+ }
258
+ /**
259
+ * Demande au master d'(arrêter d')enrichir le worker `pid` sur une **facette** (drill-down
260
+ * Phase 2). Facettes indépendantes : `"process"` (supervision) et `"orm"` (drill ORM).
261
+ *
262
+ * @param pid - worker ciblé.
263
+ * @param enable - activer/couper la sonde riche.
264
+ * @param facet - quelle sonde riche cibler (défaut `"process"`).
265
+ * @returns `true` si la sonde cluster est branchée (worker de cluster) → l'ordre est émis ;
266
+ * `false` en mono-process / sonde désactivée (pas de drill cross-worker possible → le
267
+ * consommateur retombe sur la sonde riche locale du process courant).
268
+ */
269
+ function clusterProbeRequestEnrich(pid, enable, facet = "process") {
270
+ if (_client === null) return false;
271
+ _client.requestEnrich(pid, enable, facet);
272
+ return true;
273
+ }
274
+ /**
275
+ * Santé d'UN worker `pid` extraite du dernier snapshot pod (avec sa sonde riche si le
276
+ * drill est actif), ou `null` (mono-process, pid inconnu, ou cold start). Lecture pure.
277
+ */
278
+ function clusterProbeInstance(pid) {
279
+ const health = _client?.getClusterHealth();
280
+ if (!health) return null;
281
+ return health.instances.find((i) => i.process?.pid === pid || i.instanceId === String(pid)) ?? null;
282
+ }
283
+ //#endregion
284
+ export { ClusterProbeClient, ClusterProbeClient as default, clusterProbeHealth, clusterProbeInstance, clusterProbeRequestEnrich, mergeClusterHealth, processProbeTransport, setClusterProbeClient };
@@ -0,0 +1,35 @@
1
+ //#region nodefony/src/server/AnonymousRealtimeToken.ts
2
+ /**
3
+ * Token realtime ANONYME — fallback Zero Trust quand aucun matcher
4
+ * `IRealtimeAuthenticatorMatcher` ne capture le handshake.
5
+ *
6
+ * Singleton gelé (`Object.freeze`) — 0 alloc par connexion non authentifiée.
7
+ * Aligné sur le pattern `AnonymousToken` de `@nodefony/security` S1 (même
8
+ * surface logique, type `"anonymous"`, `roles = ["ROLE_ANONYMOUS"]`).
9
+ *
10
+ * Stocké par le hub sur la map `peer → token` au handshake si aucun
11
+ * authenticator ne capture la connexion. Les voters P6 (futur) verront un
12
+ * token avec `isAuthenticated() === false` et appliqueront leur politique
13
+ * (Zero Trust : zone protégée → frame refusée par `beforeDispatch`).
14
+ */
15
+ const anonymousAttributes = Object.freeze({});
16
+ const ANONYMOUS_REALTIME_TOKEN = Object.freeze({
17
+ type: "anonymous",
18
+ getUserIdentifier() {
19
+ return "anonymous";
20
+ },
21
+ isAuthenticated() {
22
+ return false;
23
+ },
24
+ getRoles() {
25
+ return ["ROLE_ANONYMOUS"];
26
+ },
27
+ getScopes() {
28
+ return [];
29
+ },
30
+ getAttribute(key) {
31
+ return anonymousAttributes[key];
32
+ }
33
+ });
34
+ //#endregion
35
+ export { ANONYMOUS_REALTIME_TOKEN, ANONYMOUS_REALTIME_TOKEN as default };
@@ -0,0 +1,87 @@
1
+ import { getRealtimeHub } from "./RealtimeHub.js";
2
+ import { listBackplaneDrivers } from "../backplane/backplaneRegistry.js";
3
+ import { clusterProbeHealth } from "../cluster/ClusterProbeClient.js";
4
+ import { Nodefony, ProcessProbe, readOrmHealth } from "nodefony";
5
+ //#region nodefony/src/server/RealtimeAdminApi.ts
6
+ const processProbe = new ProcessProbe();
7
+ /**
8
+ * Producteur `IAdminApi` de la **socket Nodefony** — exposé sous
9
+ * `/nodefony/realtime/api/*`. Surface l'**auto-observabilité** du {@link RealtimeHub}
10
+ * (la socket s'observe à travers elle-même) : canaux, fan-out, connexions et surtout
11
+ * **backpressure** (`bufferedAmount` = blocker mémoire #1 du multiplexing N canaux/1 WS).
12
+ *
13
+ * Namespace `realtime` (pas `studio`/`framework`) car la socket est une couche du
14
+ * framework, pas de Studio : le jour où `@nodefony/realtime` (P13.1) existera, ce
15
+ * producteur y déménagera sans changer le contrat. Réutilisé par l'endpoint HTTP
16
+ * (1ᵉʳ paint) ET par le ticker hub realtime `nodefony:socket` (push live).
17
+ *
18
+ * Endpoints :
19
+ * - `GET /nodefony/realtime/api/health` → vue POD agrégée ({@link IRealtimeClusterHealth})
20
+ * en cluster avec sonde active, sinon snapshot per-instance ({@link IRealtimeHealth}).
21
+ */
22
+ /**
23
+ * Santé per-instance de CE worker : lecture pure du hub ({@link RealtimeHub.probe}) +
24
+ * identité process. Aucune I/O, jamais throw. C'est ce que chaque worker **remonte** au
25
+ * master (Phase 4c) et le fallback quand il n'y a pas d'agrégat.
26
+ */
27
+ /**
28
+ * Compteurs d'erreurs Syslog du worker (cumuls monotones), ou `undefined` si le kernel/son
29
+ * syslog n'est pas joignable. Lecture pure de 2 entiers (0 scan du ring buffer).
30
+ */
31
+ function readInstanceErrorHealth() {
32
+ const syslog = Nodefony.getKernel()?.syslog;
33
+ if (!syslog) return void 0;
34
+ return {
35
+ errorTotal: syslog.errorTotal,
36
+ criticTotal: syslog.criticTotal
37
+ };
38
+ }
39
+ function buildOwnHealth() {
40
+ const health = {
41
+ instanceId: String(process.pid),
42
+ ...getRealtimeHub().probe(),
43
+ process: processProbe.read()
44
+ };
45
+ const orm = readOrmHealth();
46
+ if (orm) health.orm = orm;
47
+ const errors = readInstanceErrorHealth();
48
+ if (errors) health.errors = errors;
49
+ const drivers = listBackplaneDrivers();
50
+ if (drivers.length) health.backplaneDrivers = drivers;
51
+ return health;
52
+ }
53
+ /**
54
+ * Construit la **santé de la socket** servie par l'endpoint/le canal `nodefony:socket` :
55
+ * la **vue POD agrégée** si la sonde cluster est branchée et a reçu un snapshot du master
56
+ * (Phase 4c, push), sinon la **vue per-instance** (mono-process, sonde désactivée, ou cold
57
+ * start). Lecture pure, jamais throw. Async pour matcher la signature des tickers broker.
58
+ *
59
+ * @returns {@link IRealtimeClusterHealth} (cluster) ou {@link IRealtimeHealth} (per-instance).
60
+ */
61
+ async function buildRealtimeHealth() {
62
+ return clusterProbeHealth() ?? buildOwnHealth();
63
+ }
64
+ const descriptor = {
65
+ label: "Realtime",
66
+ icon: "wifi",
67
+ order: 3
68
+ };
69
+ /**
70
+ * Construit le producteur `IAdminApi` de la socket (namespace `"realtime"`).
71
+ *
72
+ * @returns le contrat admin, prêt à `broker.register()`.
73
+ */
74
+ function createRealtimeAdminApi() {
75
+ const endpoints = [{
76
+ path: "health",
77
+ summary: "Santé de la socket (per-instance) — canaux + abonnés, fan-out (publish/livraisons), connexions, octets/frames, backpressure (bufferedAmount max/total, slow-consumers).",
78
+ handler: () => buildRealtimeHealth()
79
+ }];
80
+ return {
81
+ adminNamespace: "realtime",
82
+ adminDescriptor: () => descriptor,
83
+ adminEndpoints: () => endpoints
84
+ };
85
+ }
86
+ //#endregion
87
+ export { buildOwnHealth, buildRealtimeHealth, createRealtimeAdminApi };