@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.
Files changed (62) hide show
  1. package/README.md +5 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +15 -3
  4. package/dist/nodefony/decorators/realtimeDecorators.js +231 -39
  5. package/dist/nodefony/src/backplane/RedisBackplane.js +33 -40
  6. package/dist/nodefony/src/backplane/controlOrder.js +34 -5
  7. package/dist/nodefony/src/backplane/envelope.js +422 -45
  8. package/dist/nodefony/src/backplane/ingress.js +93 -0
  9. package/dist/nodefony/src/backplane/presenceMessage.js +205 -0
  10. package/dist/nodefony/src/backplane/publishQueue.js +97 -3
  11. package/dist/nodefony/src/server/RealtimeConnection.js +31 -6
  12. package/dist/nodefony/src/server/RealtimeController.js +87 -44
  13. package/dist/nodefony/src/server/RealtimeHub.js +631 -127
  14. package/dist/nodefony/src/server/admissionBudget.js +8 -8
  15. package/dist/nodefony/src/server/channelPolicyRegistry.js +35 -13
  16. package/dist/nodefony/src/server/channelPolicyResolution.js +191 -0
  17. package/dist/nodefony/src/server/channelPresence.js +488 -0
  18. package/dist/nodefony/src/server/contractRoutes.js +3 -13
  19. package/dist/nodefony/src/server/frameAdmission.js +27 -35
  20. package/dist/nodefony/src/server/presenceBus.js +486 -0
  21. package/dist/nodefony/src/server/realtimeShutdown.js +31 -0
  22. package/dist/nodefony/src/server/remotePresence.js +541 -0
  23. package/dist/nodefony/src/server/subscribedAck.js +3 -0
  24. package/dist/nodefony/src/service/RealtimeService.js +9 -0
  25. package/dist/nodefony/testing/backplaneConformance.js +79 -3
  26. package/dist/nodefony/testing/index.js +22 -8
  27. package/dist/nodefony/testing/presenceClient.js +54 -0
  28. package/dist/types/index.d.ts +6 -1
  29. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +117 -16
  30. package/dist/types/nodefony/interfaces/IBackplane.d.ts +12 -1
  31. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +10 -2
  32. package/dist/types/nodefony/src/backplane/controlOrder.d.ts +23 -0
  33. package/dist/types/nodefony/src/backplane/envelope.d.ts +50 -7
  34. package/dist/types/nodefony/src/backplane/ingress.d.ts +48 -0
  35. package/dist/types/nodefony/src/backplane/presenceMessage.d.ts +150 -0
  36. package/dist/types/nodefony/src/backplane/publishQueue.d.ts +25 -1
  37. package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +3 -0
  38. package/dist/types/nodefony/src/server/RealtimeController.d.ts +19 -1
  39. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +139 -30
  40. package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +9 -2
  41. package/dist/types/nodefony/src/server/channelPolicyResolution.d.ts +112 -0
  42. package/dist/types/nodefony/src/server/channelPresence.d.ts +155 -0
  43. package/dist/types/nodefony/src/server/contractRoutes.d.ts +1 -1
  44. package/dist/types/nodefony/src/server/frameAdmission.d.ts +14 -18
  45. package/dist/types/nodefony/src/server/presenceBus.d.ts +112 -0
  46. package/dist/types/nodefony/src/server/realtimeShutdown.d.ts +21 -0
  47. package/dist/types/nodefony/src/server/remotePresence.d.ts +151 -0
  48. package/dist/types/nodefony/src/server/subscribedAck.d.ts +3 -0
  49. package/dist/types/nodefony/src/service/RealtimeService.d.ts +8 -1
  50. package/dist/types/nodefony/testing/backplaneConformance.d.ts +14 -2
  51. package/dist/types/nodefony/testing/index.d.ts +1 -1
  52. package/dist/types/nodefony/testing/presenceClient.d.ts +20 -0
  53. package/docs/actions.md +1 -0
  54. package/docs/architecture.md +21 -6
  55. package/docs/canaux-a-membres.md +13 -3
  56. package/docs/cas-d-usage.md +4 -3
  57. package/docs/configuration.md +38 -2
  58. package/docs/cookbook-chat.md +400 -152
  59. package/docs/observabilite.md +28 -9
  60. package/docs/protocole.md +10 -10
  61. package/docs/securite.md +64 -10
  62. 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'identifiant du compte, figé au handshake ; appelé
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
- const identify = this.#identify;
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;