@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.
- package/LICENSE +544 -0
- package/README.md +207 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/index.js +218 -0
- package/dist/nodefony/config/config.js +63 -0
- package/dist/nodefony/config/defineModuleConfig.js +60 -0
- package/dist/nodefony/decorators/realtimeDecorators.js +240 -0
- package/dist/nodefony/interfaces/IBackplane.js +1 -0
- package/dist/nodefony/interfaces/IChannelPolicy.js +1 -0
- package/dist/nodefony/interfaces/IClientLogsLimits.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeAuthenticator.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeAuthenticatorMatcher.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeController.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeHandshake.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeProbe.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeToken.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/src/backplane/ClusterBackplane.js +105 -0
- package/dist/nodefony/src/backplane/LoopbackBackplane.js +40 -0
- package/dist/nodefony/src/backplane/RedisBackplane.js +176 -0
- package/dist/nodefony/src/backplane/backplaneRegistry.js +20 -0
- package/dist/nodefony/src/backplane/envelope.js +109 -0
- package/dist/nodefony/src/backplane/originId.js +35 -0
- package/dist/nodefony/src/backplane/publishQueue.js +96 -0
- package/dist/nodefony/src/cluster/ClusterProbeClient.js +284 -0
- package/dist/nodefony/src/server/AnonymousRealtimeToken.js +35 -0
- package/dist/nodefony/src/server/RealtimeAdminApi.js +87 -0
- package/dist/nodefony/src/server/RealtimeController.js +601 -0
- package/dist/nodefony/src/server/RealtimeHub.js +926 -0
- package/dist/nodefony/src/server/ServerRealtimeSocket.js +168 -0
- package/dist/nodefony/src/server/deniedDetail.js +29 -0
- package/dist/nodefony/src/server/syslogUplink.js +105 -0
- package/dist/nodefony/src/server/welcomeEnv.js +25 -0
- package/dist/nodefony/src/service/RealtimeService.js +240 -0
- package/dist/nodefony/src/transport/WsConnectionTransport.js +94 -0
- package/dist/nodefony/testing/index.js +163 -0
- package/dist/types/index.d.ts +99 -0
- package/dist/types/nodefony/config/config.d.ts +43 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +49 -0
- package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +190 -0
- package/dist/types/nodefony/interfaces/IBackplane.d.ts +128 -0
- package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +28 -0
- package/dist/types/nodefony/interfaces/IClientLogsLimits.d.ts +22 -0
- package/dist/types/nodefony/interfaces/IRealtimeAuthenticator.d.ts +53 -0
- package/dist/types/nodefony/interfaces/IRealtimeAuthenticatorMatcher.d.ts +46 -0
- package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +36 -0
- package/dist/types/nodefony/interfaces/IRealtimeHandshake.d.ts +27 -0
- package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +206 -0
- package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +48 -0
- package/dist/types/nodefony/interfaces/index.d.ts +18 -0
- package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +76 -0
- package/dist/types/nodefony/src/backplane/LoopbackBackplane.d.ts +29 -0
- package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +164 -0
- package/dist/types/nodefony/src/backplane/backplaneRegistry.d.ts +51 -0
- package/dist/types/nodefony/src/backplane/envelope.d.ts +27 -0
- package/dist/types/nodefony/src/backplane/originId.d.ts +22 -0
- package/dist/types/nodefony/src/backplane/publishQueue.d.ts +71 -0
- package/dist/types/nodefony/src/cluster/ClusterProbeClient.d.ts +90 -0
- package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +3 -0
- package/dist/types/nodefony/src/server/RealtimeAdminApi.d.ts +18 -0
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +165 -0
- package/dist/types/nodefony/src/server/RealtimeHub.d.ts +516 -0
- package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +63 -0
- package/dist/types/nodefony/src/server/deniedDetail.d.ts +25 -0
- package/dist/types/nodefony/src/server/syslogUplink.d.ts +58 -0
- package/dist/types/nodefony/src/server/welcomeEnv.d.ts +21 -0
- package/dist/types/nodefony/src/service/RealtimeService.d.ts +167 -0
- package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +78 -0
- package/dist/types/nodefony/testing/index.d.ts +169 -0
- package/docs/actions.md +573 -0
- package/docs/architecture.md +889 -0
- package/docs/configuration.md +803 -0
- package/docs/cookbook-chat.md +1619 -0
- package/docs/couverture-bancs.md +139 -0
- package/docs/index.md +372 -0
- package/docs/observabilite.md +592 -0
- package/docs/protocole.md +518 -0
- package/docs/securite.md +845 -0
- package/docs/vocabulaire.md +845 -0
- 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 };
|