@nodefony/realtime 10.0.0-beta.6 → 10.0.0-beta.7
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/README.md +5 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
- package/dist/index.js +15 -3
- package/dist/nodefony/decorators/realtimeDecorators.js +231 -39
- package/dist/nodefony/src/backplane/RedisBackplane.js +33 -40
- package/dist/nodefony/src/backplane/controlOrder.js +34 -5
- package/dist/nodefony/src/backplane/envelope.js +422 -45
- package/dist/nodefony/src/backplane/ingress.js +93 -0
- package/dist/nodefony/src/backplane/presenceMessage.js +205 -0
- package/dist/nodefony/src/backplane/publishQueue.js +97 -3
- package/dist/nodefony/src/server/RealtimeConnection.js +31 -6
- package/dist/nodefony/src/server/RealtimeController.js +87 -44
- package/dist/nodefony/src/server/RealtimeHub.js +631 -127
- package/dist/nodefony/src/server/admissionBudget.js +8 -8
- package/dist/nodefony/src/server/channelPolicyRegistry.js +35 -13
- package/dist/nodefony/src/server/channelPolicyResolution.js +191 -0
- package/dist/nodefony/src/server/channelPresence.js +488 -0
- package/dist/nodefony/src/server/contractRoutes.js +3 -13
- package/dist/nodefony/src/server/frameAdmission.js +27 -35
- package/dist/nodefony/src/server/presenceBus.js +486 -0
- package/dist/nodefony/src/server/realtimeShutdown.js +31 -0
- package/dist/nodefony/src/server/remotePresence.js +541 -0
- package/dist/nodefony/src/server/subscribedAck.js +3 -0
- package/dist/nodefony/src/service/RealtimeService.js +9 -0
- package/dist/nodefony/testing/backplaneConformance.js +79 -3
- package/dist/nodefony/testing/index.js +22 -8
- package/dist/nodefony/testing/presenceClient.js +54 -0
- package/dist/types/index.d.ts +6 -1
- package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +117 -16
- package/dist/types/nodefony/interfaces/IBackplane.d.ts +12 -1
- package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +10 -2
- package/dist/types/nodefony/src/backplane/controlOrder.d.ts +23 -0
- package/dist/types/nodefony/src/backplane/envelope.d.ts +50 -7
- package/dist/types/nodefony/src/backplane/ingress.d.ts +48 -0
- package/dist/types/nodefony/src/backplane/presenceMessage.d.ts +150 -0
- package/dist/types/nodefony/src/backplane/publishQueue.d.ts +25 -1
- package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +3 -0
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +19 -1
- package/dist/types/nodefony/src/server/RealtimeHub.d.ts +139 -30
- package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +9 -2
- package/dist/types/nodefony/src/server/channelPolicyResolution.d.ts +112 -0
- package/dist/types/nodefony/src/server/channelPresence.d.ts +155 -0
- package/dist/types/nodefony/src/server/contractRoutes.d.ts +1 -1
- package/dist/types/nodefony/src/server/frameAdmission.d.ts +14 -18
- package/dist/types/nodefony/src/server/presenceBus.d.ts +112 -0
- package/dist/types/nodefony/src/server/realtimeShutdown.d.ts +21 -0
- package/dist/types/nodefony/src/server/remotePresence.d.ts +151 -0
- package/dist/types/nodefony/src/server/subscribedAck.d.ts +3 -0
- package/dist/types/nodefony/src/service/RealtimeService.d.ts +8 -1
- package/dist/types/nodefony/testing/backplaneConformance.d.ts +14 -2
- package/dist/types/nodefony/testing/index.d.ts +1 -1
- package/dist/types/nodefony/testing/presenceClient.d.ts +20 -0
- package/docs/actions.md +1 -0
- package/docs/architecture.md +21 -6
- package/docs/canaux-a-membres.md +13 -3
- package/docs/cas-d-usage.md +4 -3
- package/docs/configuration.md +38 -2
- package/docs/cookbook-chat.md +400 -152
- package/docs/observabilite.md +28 -9
- package/docs/protocole.md +10 -10
- package/docs/securite.md +64 -10
- package/package.json +12 -12
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { BackplaneEcho, openBackplaneEnvelope, openBackplaneEnvelopeBytes } from "./envelope.js";
|
|
2
|
+
// nodefony/src/backplane/ingress.ts
|
|
3
|
+
/**
|
|
4
|
+
* **Entrée d'un bus partagé** — le tri de ce qu'un pilote reçoit, mutualisé pour
|
|
5
|
+
* que tout pilote à transport réseau (Redis aujourd'hui ; Kafka, NATS, RabbitMQ…
|
|
6
|
+
* demain) l'hérite sans le réécrire : écho de ce pod reconnu et compté, sceau
|
|
7
|
+
* vérifié, refus comptés et annoncés.
|
|
8
|
+
*
|
|
9
|
+
* Le pilote ne garde que son transport : il passe chaque message reçu à
|
|
10
|
+
* {@link text} ou {@link bytes} et remet au hub ce qui en sort.
|
|
11
|
+
*
|
|
12
|
+
* Ordre du tri, du moins cher au plus cher :
|
|
13
|
+
* 1. l'ÉCHO de ce pod se reconnaît à la fin du message, sans le décoder — un HMAC
|
|
14
|
+
* sous un secret (cf {@link BackplaneEcho}) ;
|
|
15
|
+
* 2. sinon, l'enveloppe est ouverte et son sceau vérifié sur la tranche reçue ;
|
|
16
|
+
* 3. un message ouvert qui porte l'origine de ce pod sous une autre forme est
|
|
17
|
+
* encore un écho — compté, jamais réinjecté (anti-boucle).
|
|
18
|
+
*/
|
|
19
|
+
var BackplaneIngress = class {
|
|
20
|
+
#originId;
|
|
21
|
+
#secret;
|
|
22
|
+
#echo;
|
|
23
|
+
#notice;
|
|
24
|
+
#echoTotal = 0;
|
|
25
|
+
#sealRejectedTotal = 0;
|
|
26
|
+
/**
|
|
27
|
+
* @param originId - identité de CE pod sur le bus.
|
|
28
|
+
* @param secret - secret partagé, ou `null` sur un bus non scellé.
|
|
29
|
+
* @param notice - annonce des sceaux refusés ; omis = silencieux (tests).
|
|
30
|
+
*/
|
|
31
|
+
constructor(originId, secret, notice = null) {
|
|
32
|
+
this.#originId = originId;
|
|
33
|
+
this.#secret = secret;
|
|
34
|
+
this.#echo = new BackplaneEcho(originId, secret);
|
|
35
|
+
this.#notice = notice;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Trie un message reçu sur le sujet TEXTE du bus.
|
|
39
|
+
*
|
|
40
|
+
* @param raw - message brut tel que lu sur le transport.
|
|
41
|
+
* @returns le message à remettre au hub, ou `null` s'il est jeté (écho de ce
|
|
42
|
+
* pod, malformé, sceau refusé — chacun compté).
|
|
43
|
+
*/
|
|
44
|
+
text(raw) {
|
|
45
|
+
if (this.#echo.text(raw)) {
|
|
46
|
+
this.#echoTotal += 1;
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return this.#admit(openBackplaneEnvelope(raw, this.#secret));
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Trie un message reçu sur le sujet BINAIRE du bus (ADR-0021 S6).
|
|
53
|
+
*
|
|
54
|
+
* @param raw - octets tels que lus sur le transport.
|
|
55
|
+
* @param codec - le codec binaire du serveur.
|
|
56
|
+
* @returns le message à remettre au hub, ou `null` s'il est jeté.
|
|
57
|
+
*/
|
|
58
|
+
bytes(raw, codec) {
|
|
59
|
+
if (this.#echo.bytes(raw, codec)) {
|
|
60
|
+
this.#echoTotal += 1;
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
return this.#admit(openBackplaneEnvelopeBytes(raw, codec, this.#secret));
|
|
64
|
+
}
|
|
65
|
+
/** Échos de ce pod reçus du bus (monotone) — le bus rend ce qu'on y publie. */
|
|
66
|
+
get echoTotal() {
|
|
67
|
+
return this.#echoTotal;
|
|
68
|
+
}
|
|
69
|
+
/** Messages jetés au sceau refusé, sous un secret (monotone). */
|
|
70
|
+
get sealRejectedTotal() {
|
|
71
|
+
return this.#sealRejectedTotal;
|
|
72
|
+
}
|
|
73
|
+
/** Fin du tri commune aux deux sujets : refus compté, écho tardif, ou message admis. */
|
|
74
|
+
#admit(msg) {
|
|
75
|
+
if (msg === null) {
|
|
76
|
+
if (this.#secret !== null) this.#sealRejected();
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
if (msg.originId === this.#originId) {
|
|
80
|
+
this.#echoTotal += 1;
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
return msg;
|
|
84
|
+
}
|
|
85
|
+
/** Compte un sceau refusé ; annonce le premier, puis un sur cent (pas d'inondation du journal). */
|
|
86
|
+
#sealRejected() {
|
|
87
|
+
this.#sealRejectedTotal += 1;
|
|
88
|
+
const n = this.#sealRejectedTotal;
|
|
89
|
+
if (n !== 1 && n % 100 !== 0) return;
|
|
90
|
+
this.#notice?.(`backplane : ${n} message(s) reçu(s) au sceau refusé, JETÉS. Causes : rotation du secret en cours (des exemplaires n'ont pas le même NF_REALTIME_BACKPLANE_SECRET), producteur mal configuré, ou écriture tierce dans le bus. Compteur : sealRejectedTotal.`, "WARNING");
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
export { BackplaneIngress };
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { isBoundedBusText } from "./controlOrder.js";
|
|
2
|
+
import { ownField } from "nodefony";
|
|
3
|
+
// nodefony/src/backplane/presenceMessage.ts
|
|
4
|
+
/**
|
|
5
|
+
* Messages de la présence répartie entre serveurs — la LISTE FERMÉE de ce qui
|
|
6
|
+
* circule sur `PLATFORM_BUS_CHANNELS.presence` (lots, demandes, réponses d'état)
|
|
7
|
+
* et `PLATFORM_BUS_CHANNELS.presenceAlive` (preuve de vie), ADR-0025 P2.
|
|
8
|
+
*
|
|
9
|
+
* Comme un ordre de contrôle (`controlOrder.ts#readControlOrder`), chaque
|
|
10
|
+
* message voyage dans la charge d'un message ordinaire du bus, sous le sceau de
|
|
11
|
+
* l'enveloppe : aucun champ neuf dans l'enveloppe, aucune méthode neuve dans
|
|
12
|
+
* `IBackplane`. L'émetteur (`originId`) se lit dans l'enveloppe, jamais dans la
|
|
13
|
+
* charge.
|
|
14
|
+
*
|
|
15
|
+
* Lecture par champs PROPRES (`ownField`), textes bornés (`isBoundedBusText`),
|
|
16
|
+
* entiers sûrs ; un champ inconnu est toléré (déploiement progressif), un champ
|
|
17
|
+
* connu de forme fausse fait refuser le message entier. Les tableaux rendus
|
|
18
|
+
* sont CEUX reçus, validés en place — aucune copie, aucune allocation par
|
|
19
|
+
* membre : le lecteur ne les modifie pas, l'appelant non plus.
|
|
20
|
+
*/
|
|
21
|
+
/** Plus petit intervalle de preuve de vie qu'un pair peut annoncer (ms). */
|
|
22
|
+
const MIN_PRESENCE_INTERVAL_MS = 100;
|
|
23
|
+
/** Plus grand intervalle de preuve de vie qu'un pair peut annoncer (ms) — 10 min. */
|
|
24
|
+
const MAX_PRESENCE_INTERVAL_MS = 6e5;
|
|
25
|
+
/**
|
|
26
|
+
* Plafond d'identités nommées qu'un pod peut annoncer pour UN canal — au-delà,
|
|
27
|
+
* l'entrée est refusée (ADR-0025 P2, « plafond de membres par pod et par canal »).
|
|
28
|
+
*/
|
|
29
|
+
const MAX_PRESENCE_MEMBERS = 1e5;
|
|
30
|
+
/** Plafond d'entrées de canal dans un même message (lot, demande ou part de réponse). */
|
|
31
|
+
const MAX_PRESENCE_CHANNELS = 4096;
|
|
32
|
+
/** Plafond de parts d'une réponse d'état (`n`). */
|
|
33
|
+
const MAX_PRESENCE_PARTS = 4096;
|
|
34
|
+
/** Entier sûr ≥ 0. */
|
|
35
|
+
function isCount(value) {
|
|
36
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Paires à plat `[identité, onglets, …]` recevables : longueur paire, au plus
|
|
40
|
+
* {@link MAX_PRESENCE_MEMBERS} identités, textes bornés, onglets entiers ≥ 1.
|
|
41
|
+
* Un seul parcours, aucune allocation.
|
|
42
|
+
*/
|
|
43
|
+
function isPairs(value) {
|
|
44
|
+
if (!Array.isArray(value)) return false;
|
|
45
|
+
const length = value.length;
|
|
46
|
+
if ((length & 1) !== 0 || length > 2e5) return false;
|
|
47
|
+
for (let i = 0; i < length; i += 2) {
|
|
48
|
+
if (!isBoundedBusText(value[i])) return false;
|
|
49
|
+
const tabs = value[i + 1];
|
|
50
|
+
if (typeof tabs !== "number" || !Number.isSafeInteger(tabs) || tabs < 1) return false;
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
/** Liste de textes bornés d'au plus `max` éléments. */
|
|
55
|
+
function isTextList(value, max) {
|
|
56
|
+
if (!Array.isArray(value) || value.length > max) return false;
|
|
57
|
+
for (let i = 0; i < value.length; i += 1) if (!isBoundedBusText(value[i])) return false;
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
/** Numéro de message recevable : entier sûr ≥ 1. */
|
|
61
|
+
function isSeq(value) {
|
|
62
|
+
return isCount(value) && value >= 1;
|
|
63
|
+
}
|
|
64
|
+
/** Un champ « numéro précédent » (`prev`, `last`) : entier sûr ≥ 0, strictement sous `seq`. */
|
|
65
|
+
function readBefore(payload, key, seq) {
|
|
66
|
+
const value = ownField(payload, key);
|
|
67
|
+
return isCount(value) && value < seq ? value : null;
|
|
68
|
+
}
|
|
69
|
+
function readDeltas(value) {
|
|
70
|
+
if (!Array.isArray(value) || value.length > 4096) return null;
|
|
71
|
+
const out = new Array(value.length);
|
|
72
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
73
|
+
const entry = value[i];
|
|
74
|
+
const channel = ownField(entry, "channel");
|
|
75
|
+
const unnamed = ownField(entry, "unnamed");
|
|
76
|
+
if (!isBoundedBusText(channel) || !isCount(unnamed)) return null;
|
|
77
|
+
const set = ownField(entry, "set");
|
|
78
|
+
if (set !== void 0 && set !== null && !isPairs(set)) return null;
|
|
79
|
+
const gone = ownField(entry, "gone");
|
|
80
|
+
if (gone !== void 0 && gone !== null && !isTextList(gone, 1e5)) return null;
|
|
81
|
+
out[i] = {
|
|
82
|
+
channel,
|
|
83
|
+
set: set ?? null,
|
|
84
|
+
gone: gone ?? null,
|
|
85
|
+
unnamed
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
function readStates(value) {
|
|
91
|
+
if (!Array.isArray(value) || value.length > 4096) return null;
|
|
92
|
+
const out = new Array(value.length);
|
|
93
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
94
|
+
const entry = value[i];
|
|
95
|
+
const channel = ownField(entry, "channel");
|
|
96
|
+
const unnamed = ownField(entry, "unnamed");
|
|
97
|
+
const members = ownField(entry, "members");
|
|
98
|
+
if (!isBoundedBusText(channel) || !isCount(unnamed) || !isPairs(members)) return null;
|
|
99
|
+
out[i] = {
|
|
100
|
+
channel,
|
|
101
|
+
members,
|
|
102
|
+
unnamed
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
/** Les types de message que cette version sait lire sur le canal de présence — liste fermée. */
|
|
108
|
+
function isKnownOp(op) {
|
|
109
|
+
return op === "batch" || op === "request" || op === "state";
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Lit un message du canal de présence — `null` si sa forme n'est pas celle d'un message connu.
|
|
113
|
+
*
|
|
114
|
+
* Une seule garde pour les deux bords : l'émetteur la passe en test (il n'émet
|
|
115
|
+
* jamais ce que ses pairs refuseraient), le récepteur avant d'appliquer.
|
|
116
|
+
*
|
|
117
|
+
* @param payload - la charge du message reçu (forme inconnue).
|
|
118
|
+
* @returns le message, ou `null` (forme invalide, ou type inconnu : cf
|
|
119
|
+
* {@link isUnknownPresenceMessage} pour les distinguer).
|
|
120
|
+
*/
|
|
121
|
+
function readPresenceMessage(payload) {
|
|
122
|
+
const op = ownField(payload, "op");
|
|
123
|
+
if (!isKnownOp(op)) return null;
|
|
124
|
+
const incarnation = ownField(payload, "incarnation");
|
|
125
|
+
const seq = ownField(payload, "seq");
|
|
126
|
+
if (!isCount(incarnation) || !isSeq(seq)) return null;
|
|
127
|
+
const raw = ownField(payload, "channels");
|
|
128
|
+
if (op === "batch") {
|
|
129
|
+
const prev = readBefore(payload, "prev", seq);
|
|
130
|
+
if (prev === null) return null;
|
|
131
|
+
const channels = readDeltas(raw);
|
|
132
|
+
return channels === null ? null : {
|
|
133
|
+
op,
|
|
134
|
+
incarnation,
|
|
135
|
+
seq,
|
|
136
|
+
prev,
|
|
137
|
+
channels
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (op === "request") {
|
|
141
|
+
if (!isTextList(raw, 4096)) return null;
|
|
142
|
+
const to = ownField(payload, "to");
|
|
143
|
+
if (to !== void 0 && to !== null && !isBoundedBusText(to)) return null;
|
|
144
|
+
return {
|
|
145
|
+
op,
|
|
146
|
+
incarnation,
|
|
147
|
+
seq,
|
|
148
|
+
channels: raw,
|
|
149
|
+
to: to ?? null
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
const last = readBefore(payload, "last", seq);
|
|
153
|
+
const i = ownField(payload, "i");
|
|
154
|
+
const n = ownField(payload, "n");
|
|
155
|
+
if (last === null || !isCount(n) || n < 1 || n > 4096 || !isCount(i) || i >= n) return null;
|
|
156
|
+
const channels = readStates(raw);
|
|
157
|
+
return channels === null ? null : {
|
|
158
|
+
op,
|
|
159
|
+
incarnation,
|
|
160
|
+
seq,
|
|
161
|
+
last,
|
|
162
|
+
i,
|
|
163
|
+
n,
|
|
164
|
+
channels
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* La charge est-elle un message de présence d'un TYPE que cette version ne connaît pas — `op` lisible, hors de la liste fermée ?
|
|
169
|
+
*
|
|
170
|
+
* C'est ce qu'émet un serveur plus récent pendant un déploiement progressif :
|
|
171
|
+
* jeté et compté dans `ingressUnknownDiscardedTotal`, jamais comme une
|
|
172
|
+
* injection (ADR-0025, « Règle de lecture du bus »).
|
|
173
|
+
*
|
|
174
|
+
* @param payload - la charge du message reçu (forme inconnue).
|
|
175
|
+
*/
|
|
176
|
+
function isUnknownPresenceMessage(payload) {
|
|
177
|
+
const op = ownField(payload, "op");
|
|
178
|
+
return isBoundedBusText(op) && !isKnownOp(op);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Lit une preuve de vie — `null` si sa forme est fausse.
|
|
182
|
+
*
|
|
183
|
+
* `interval` hors de [{@link MIN_PRESENCE_INTERVAL_MS}, {@link MAX_PRESENCE_INTERVAL_MS}]
|
|
184
|
+
* est refusé : un pair qui annoncerait 1 ms se ferait oublier à chaque tic, un
|
|
185
|
+
* qui annoncerait une heure garderait ses fantômes une demi-journée.
|
|
186
|
+
*
|
|
187
|
+
* @param payload - la charge du message reçu (forme inconnue).
|
|
188
|
+
*/
|
|
189
|
+
function readPresenceAlive(payload) {
|
|
190
|
+
const incarnation = ownField(payload, "incarnation");
|
|
191
|
+
const seq = ownField(payload, "seq");
|
|
192
|
+
if (!isCount(incarnation) || !isSeq(seq)) return null;
|
|
193
|
+
const last = readBefore(payload, "last", seq);
|
|
194
|
+
const interval = ownField(payload, "interval");
|
|
195
|
+
const final = ownField(payload, "final");
|
|
196
|
+
if (last === null || !isCount(interval) || interval < 100 || interval > 6e5 || final !== void 0 && typeof final !== "boolean") return null;
|
|
197
|
+
return {
|
|
198
|
+
incarnation,
|
|
199
|
+
seq,
|
|
200
|
+
last,
|
|
201
|
+
interval,
|
|
202
|
+
final: final === true
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
export { MAX_PRESENCE_CHANNELS, MAX_PRESENCE_INTERVAL_MS, MAX_PRESENCE_MEMBERS, MAX_PRESENCE_PARTS, MIN_PRESENCE_INTERVAL_MS, isUnknownPresenceMessage, readPresenceAlive, readPresenceMessage };
|
|
@@ -1,4 +1,47 @@
|
|
|
1
1
|
import { isThenable } from "../guards.js";
|
|
2
|
+
import { PLATFORM_BUS_CHANNELS, isPriorityBusChannel } from "nodefony";
|
|
3
|
+
// nodefony/src/backplane/publishQueue.ts
|
|
4
|
+
/**
|
|
5
|
+
* **File d'envoi bornée d'un backplane** — protège la mémoire du pod quand le bus
|
|
6
|
+
* n'absorbe pas le débit publié. Mutualisée ici (comme le sceau d'enveloppe) pour
|
|
7
|
+
* qu'un driver à transport réseau — Redis, et tout driver userland : NATS, Kafka,
|
|
8
|
+
* RabbitMQ — hérite de la garantie sans la réécrire.
|
|
9
|
+
*
|
|
10
|
+
* Pourquoi : `IBackplane.publish` est **fire-and-forget** par contrat (il rend
|
|
11
|
+
* `void`). Un client réseau met alors la commande dans SA file tant que la socket
|
|
12
|
+
* n'est pas drainée — file interne, invisible, sans limite. Une rafale y accumule
|
|
13
|
+
* des mégaoctets : 583 MB observés sur le banc multi-pods, pour 152 MB au repos.
|
|
14
|
+
* Aucune fuite (tout redescend), mais rien ne borne le pic → OOM sous rafale plus
|
|
15
|
+
* longue.
|
|
16
|
+
*
|
|
17
|
+
* Doctrine — la MÊME que le transport WS (seuil, compteur de jetés, avertissement),
|
|
18
|
+
* parce que le problème est le même : un pair qui ne draine pas. Deux règles en
|
|
19
|
+
* découlent :
|
|
20
|
+
* - **on protège la mémoire** : au-delà du seuil, les publications suivantes sont
|
|
21
|
+
* jetées (sémantique at-most-once déjà assumée par le port — le client realtime
|
|
22
|
+
* re-synchronise) ;
|
|
23
|
+
* - **on ne jette jamais en silence** : compteurs exposés dans la carte d'identité
|
|
24
|
+
* du backplane (donc sonde + Studio) et transitions annoncées.
|
|
25
|
+
* Cf `project_resilience_no_silent_degradation`.
|
|
26
|
+
*
|
|
27
|
+
* Ce qui est jeté est le message **nouveau**, pas le plus ancien : contrairement au
|
|
28
|
+
* WS (drop latest-wins sur une file qu'on possède), ce qui est déjà remis au client
|
|
29
|
+
* réseau ne peut plus être repris.
|
|
30
|
+
*
|
|
31
|
+
* **Les ordres de contrôle ne sont JAMAIS jetés** (#762) : terminer un canal ou
|
|
32
|
+
* éjecter un compte passe hors quota, même file saturée. Sinon, sous charge, un
|
|
33
|
+
* modérateur éjecte un compte sur un serveur et ce compte continue de lire sur un
|
|
34
|
+
* autre, sans que personne le sache. Les ordres sont rares et petits (une décision
|
|
35
|
+
* du serveur, quelques dizaines d'octets) : les exempter ne rouvre pas la mémoire.
|
|
36
|
+
* La **preuve de vie** de la présence répartie passe aussi hors quota (ADR-0025
|
|
37
|
+
* P2) : jetée, elle ferait oublier un pod vivant chez ses pairs ; elle est petite
|
|
38
|
+
* et d'intervalle borné. Liste des canaux prioritaires : `isPriorityBusChannel`
|
|
39
|
+
* (cœur), seule source.
|
|
40
|
+
* Un ordre que le bus REFUSE malgré tout (bus injoignable) est compté à part
|
|
41
|
+
* (`controlLostTotal`) et annoncé une fois par épisode.
|
|
42
|
+
*/
|
|
43
|
+
/** Canal des ordres de contrôle, lu une fois au chargement (pas d'accès de propriété par envoi). */
|
|
44
|
+
const CONTROL_CHANNEL = PLATFORM_BUS_CHANNELS.control;
|
|
2
45
|
/**
|
|
3
46
|
* Seuil par défaut d'octets en vol vers le bus : **8 MiB**, aligné sur le seuil de
|
|
4
47
|
* coupure d'un slow-consumer WS. Un bus sain acquitte en quelques millisecondes
|
|
@@ -23,6 +66,14 @@ var BackplanePublishQueue = class {
|
|
|
23
66
|
#saturated = false;
|
|
24
67
|
/** Jetés de l'épisode de saturation courant — chiffre annoncé au retour à la normale. */
|
|
25
68
|
#droppedInEpisode = 0;
|
|
69
|
+
/** Ordres de contrôle refusés par le transport (monotone). */
|
|
70
|
+
#controlLostTotal = 0;
|
|
71
|
+
/** Ordres perdus de l'épisode courant ; `> 0` = épisode ouvert (1 log par épisode). */
|
|
72
|
+
#controlLostInEpisode = 0;
|
|
73
|
+
/** Publications réseau en vol (un entier : le compte, pas les octets — un message vide compte). */
|
|
74
|
+
#inFlight = 0;
|
|
75
|
+
/** Attentes de {@link BackplanePublishQueue.settled} ; `null` tant que personne n'attend (arrêt seulement). */
|
|
76
|
+
#idle = null;
|
|
26
77
|
/**
|
|
27
78
|
* @param maxBytes - seuil d'octets en vol ; `0` (ou négatif) = illimité.
|
|
28
79
|
* @param notice - annonce des transitions ; omis = silencieux (tests unitaires).
|
|
@@ -39,13 +90,18 @@ var BackplanePublishQueue = class {
|
|
|
39
90
|
* (sinon elle ne partirait jamais — famine silencieuse), quitte à dépasser
|
|
40
91
|
* transitoirement. Même règle que le drop WS, qui teste `bufferedAmount` seul.
|
|
41
92
|
*
|
|
93
|
+
* Un canal prioritaire (`isPriorityBusChannel` du cœur : ordre de contrôle,
|
|
94
|
+
* preuve de vie de la présence) est admis hors quota : la file l'applique, pas
|
|
95
|
+
* le driver, pour qu'un driver qui emploie la file la porte sans y penser.
|
|
96
|
+
*
|
|
97
|
+
* @param channel - canal logique de la publication.
|
|
42
98
|
* @param bytes - taille de la charge sérialisée.
|
|
43
99
|
* @param emit - envoi réel ; appelé UNIQUEMENT si la publication est admise.
|
|
44
100
|
* Son retour est suivi s'il s'agit d'une promesse (transport asynchrone).
|
|
45
101
|
* @returns `true` si la publication est partie, `false` si elle a été jetée.
|
|
46
102
|
*/
|
|
47
|
-
send(bytes, emit) {
|
|
48
|
-
if (this.#maxBytes > 0 && this.#bytes >= this.#maxBytes) {
|
|
103
|
+
send(channel, bytes, emit) {
|
|
104
|
+
if (this.#maxBytes > 0 && this.#bytes >= this.#maxBytes && !isPriorityBusChannel(channel)) {
|
|
49
105
|
this.#droppedTotal += 1;
|
|
50
106
|
this.#droppedInEpisode += 1;
|
|
51
107
|
if (!this.#saturated) {
|
|
@@ -57,10 +113,18 @@ var BackplanePublishQueue = class {
|
|
|
57
113
|
const result = emit();
|
|
58
114
|
if (!isThenable(result)) return true;
|
|
59
115
|
this.#bytes += bytes;
|
|
116
|
+
this.#inFlight += 1;
|
|
60
117
|
const settle = (failed) => {
|
|
61
118
|
this.#bytes -= bytes;
|
|
62
119
|
if (failed) this.#failedTotal += 1;
|
|
120
|
+
if ((failed || this.#controlLostInEpisode > 0) && channel === CONTROL_CHANNEL) this.#controlSettled(failed);
|
|
63
121
|
this.#drain();
|
|
122
|
+
this.#inFlight -= 1;
|
|
123
|
+
if (this.#inFlight === 0 && this.#idle !== null) {
|
|
124
|
+
const waiting = this.#idle;
|
|
125
|
+
this.#idle = null;
|
|
126
|
+
for (const resolve of waiting) resolve();
|
|
127
|
+
}
|
|
64
128
|
};
|
|
65
129
|
result.then(() => settle(false), () => settle(true));
|
|
66
130
|
return true;
|
|
@@ -79,13 +143,43 @@ var BackplanePublishQueue = class {
|
|
|
79
143
|
this.#droppedInEpisode = 0;
|
|
80
144
|
this.#notice?.(`backplane : file d'envoi drainée (${this.#bytes} octets en vol) — ${lost} publication(s) perdue(s) pendant la saturation.`, "INFO");
|
|
81
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Suit l'acquittement d'un ordre de contrôle : un refus ouvre un épisode,
|
|
148
|
+
* annoncé une fois ; le premier ordre de nouveau acquitté le ferme, avec le
|
|
149
|
+
* bilan des pertes.
|
|
150
|
+
*/
|
|
151
|
+
#controlSettled(failed) {
|
|
152
|
+
if (failed) {
|
|
153
|
+
this.#controlLostTotal += 1;
|
|
154
|
+
this.#controlLostInEpisode += 1;
|
|
155
|
+
if (this.#controlLostInEpisode === 1) this.#notice?.("backplane : un ordre de contrôle (terminer un canal, éjecter un compte) a été REFUSÉ par le bus — les autres serveurs ne l'appliqueront pas. Cause : bus injoignable. Compteur : controlLostTotal.", "WARNING");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (this.#controlLostInEpisode === 0) return;
|
|
159
|
+
const lost = this.#controlLostInEpisode;
|
|
160
|
+
this.#controlLostInEpisode = 0;
|
|
161
|
+
this.#notice?.(`backplane : ordres de contrôle de nouveau acquittés — ${lost} ordre(s) perdu(s) pendant la coupure.`, "INFO");
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Attend que toutes les publications en vol soient acquittées ou refusées par le bus — à l'arrêt, pour que ce qui est déjà émis (la preuve de vie finale de la présence) parte avant la sortie du processus.
|
|
165
|
+
*
|
|
166
|
+
* Ne borne rien : un bus qui ne répond plus ne la résout jamais. L'appelant
|
|
167
|
+
* pose la limite (l'arrêt du noyau a la sienne).
|
|
168
|
+
*/
|
|
169
|
+
settled() {
|
|
170
|
+
if (this.#inFlight === 0) return Promise.resolve();
|
|
171
|
+
return new Promise((resolve) => {
|
|
172
|
+
(this.#idle ??= []).push(resolve);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
82
175
|
/** Compteurs pour la carte d'identité du backplane (sonde + Studio). */
|
|
83
176
|
describe() {
|
|
84
177
|
return {
|
|
85
178
|
bytes: this.#bytes,
|
|
86
179
|
maxBytes: this.#maxBytes,
|
|
87
180
|
droppedTotal: this.#droppedTotal,
|
|
88
|
-
failedTotal: this.#failedTotal
|
|
181
|
+
failedTotal: this.#failedTotal,
|
|
182
|
+
controlLostTotal: this.#controlLostTotal
|
|
89
183
|
};
|
|
90
184
|
}
|
|
91
185
|
};
|
|
@@ -15,26 +15,51 @@ var RealtimeConnection = class {
|
|
|
15
15
|
#host;
|
|
16
16
|
#identify;
|
|
17
17
|
#userId = null;
|
|
18
|
+
#account = false;
|
|
18
19
|
#open = true;
|
|
19
20
|
#imposing = null;
|
|
20
21
|
/**
|
|
21
22
|
* @param host - le contrôleur qui sert la connexion.
|
|
22
|
-
* @param identify - lit l'
|
|
23
|
+
* @param identify - lit l'identité d'admission, figée au handshake ; appelé
|
|
23
24
|
* au plus une fois.
|
|
24
25
|
*/
|
|
25
26
|
constructor(host, identify) {
|
|
26
27
|
this.#host = host;
|
|
27
28
|
this.#identify = identify;
|
|
28
29
|
}
|
|
30
|
+
/** Lit l'identité au premier besoin, une fois. */
|
|
31
|
+
#resolveIdentity() {
|
|
32
|
+
const identify = this.#identify;
|
|
33
|
+
if (identify === null) return;
|
|
34
|
+
this.#identify = null;
|
|
35
|
+
const identity = identify();
|
|
36
|
+
this.#userId = identity?.id ?? null;
|
|
37
|
+
this.#account = identity?.account === true;
|
|
38
|
+
}
|
|
29
39
|
/** Identifiant du compte connecté ; `null` pour l'anonyme. */
|
|
30
40
|
get userId() {
|
|
31
|
-
|
|
32
|
-
if (identify !== null) {
|
|
33
|
-
this.#identify = null;
|
|
34
|
-
this.#userId = identify();
|
|
35
|
-
}
|
|
41
|
+
this.#resolveIdentity();
|
|
36
42
|
return this.#userId;
|
|
37
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* `userId` désigne-t-il un compte LOCAL ? `false` pour l'anonyme et l'appelant
|
|
46
|
+
* machine sans compte (`<émetteur>#<sujet>`) — ceux que la présence compte sans
|
|
47
|
+
* les nommer (ADR-0025 P3).
|
|
48
|
+
*/
|
|
49
|
+
get account() {
|
|
50
|
+
this.#resolveIdentity();
|
|
51
|
+
return this.#account;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Écrit une trame PARTAGÉE (encodée une fois par codec) sur cette connexion — rien si la socket est fermée.
|
|
55
|
+
*
|
|
56
|
+
* Voie des notifications du protocole qui visent les membres d'un canal (la
|
|
57
|
+
* présence) : elles ne passent pas par le sink du canal, qui les livrerait
|
|
58
|
+
* sous le nom du canal.
|
|
59
|
+
*/
|
|
60
|
+
deliver(frame) {
|
|
61
|
+
if (this.#open) this.#host.send(frame);
|
|
62
|
+
}
|
|
38
63
|
/** `false` une fois la socket fermée. */
|
|
39
64
|
get open() {
|
|
40
65
|
return this.#open;
|