@nodefony/realtime 10.0.0-beta.5 → 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 (96) hide show
  1. package/README.md +55 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +91 -12
  4. package/dist/nodefony/config/config.js +7 -0
  5. package/dist/nodefony/decorators/realtimeDecorators.js +341 -43
  6. package/dist/nodefony/interfaces/IRealtimeConnection.js +1 -0
  7. package/dist/nodefony/src/backplane/ClusterBackplane.js +32 -4
  8. package/dist/nodefony/src/backplane/RedisBackplane.js +146 -41
  9. package/dist/nodefony/src/backplane/controlOrder.js +86 -0
  10. package/dist/nodefony/src/backplane/envelope.js +505 -27
  11. package/dist/nodefony/src/backplane/ingress.js +93 -0
  12. package/dist/nodefony/src/backplane/presenceMessage.js +205 -0
  13. package/dist/nodefony/src/backplane/publishQueue.js +97 -3
  14. package/dist/nodefony/src/cluster/ClusterProbeClient.js +2 -0
  15. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
  16. package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
  17. package/dist/nodefony/src/server/RealtimeConnection.js +135 -0
  18. package/dist/nodefony/src/server/RealtimeController.js +409 -107
  19. package/dist/nodefony/src/server/RealtimeHub.js +1158 -111
  20. package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
  21. package/dist/nodefony/src/server/admissionBudget.js +176 -0
  22. package/dist/nodefony/src/server/channelPolicyRegistry.js +95 -0
  23. package/dist/nodefony/src/server/channelPolicyResolution.js +191 -0
  24. package/dist/nodefony/src/server/channelPresence.js +488 -0
  25. package/dist/nodefony/src/server/connectionAdmission.js +242 -0
  26. package/dist/nodefony/src/server/contractRoutes.js +75 -0
  27. package/dist/nodefony/src/server/deniedDetail.js +10 -3
  28. package/dist/nodefony/src/server/frameAdmission.js +160 -0
  29. package/dist/nodefony/src/server/presenceBus.js +486 -0
  30. package/dist/nodefony/src/server/realtimeShutdown.js +31 -0
  31. package/dist/nodefony/src/server/remotePresence.js +541 -0
  32. package/dist/nodefony/src/server/subscribedAck.js +13 -1
  33. package/dist/nodefony/src/server/syslogUplink.js +3 -4
  34. package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
  35. package/dist/nodefony/src/service/RealtimeService.js +51 -1
  36. package/dist/nodefony/src/service/publicOrigin.js +40 -0
  37. package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
  38. package/dist/nodefony/testing/backplaneConformance.js +347 -0
  39. package/dist/nodefony/testing/index.js +43 -12
  40. package/dist/nodefony/testing/presenceClient.js +54 -0
  41. package/dist/types/index.d.ts +39 -7
  42. package/dist/types/nodefony/config/config.d.ts +9 -0
  43. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +198 -17
  44. package/dist/types/nodefony/interfaces/IBackplane.d.ts +46 -51
  45. package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
  46. package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
  47. package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
  48. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
  49. package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
  50. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
  51. package/dist/types/nodefony/interfaces/index.d.ts +2 -1
  52. package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
  53. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +89 -18
  54. package/dist/types/nodefony/src/backplane/controlOrder.d.ts +75 -0
  55. package/dist/types/nodefony/src/backplane/envelope.d.ts +77 -0
  56. package/dist/types/nodefony/src/backplane/ingress.d.ts +48 -0
  57. package/dist/types/nodefony/src/backplane/presenceMessage.d.ts +150 -0
  58. package/dist/types/nodefony/src/backplane/publishQueue.d.ts +25 -1
  59. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +10 -0
  60. package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
  61. package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +20 -0
  62. package/dist/types/nodefony/src/server/RealtimeController.d.ts +110 -12
  63. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +448 -42
  64. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
  65. package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
  66. package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +41 -0
  67. package/dist/types/nodefony/src/server/channelPolicyResolution.d.ts +112 -0
  68. package/dist/types/nodefony/src/server/channelPresence.d.ts +155 -0
  69. package/dist/types/nodefony/src/server/connectionAdmission.d.ts +68 -0
  70. package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
  71. package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
  72. package/dist/types/nodefony/src/server/frameAdmission.d.ts +66 -0
  73. package/dist/types/nodefony/src/server/presenceBus.d.ts +112 -0
  74. package/dist/types/nodefony/src/server/realtimeShutdown.d.ts +21 -0
  75. package/dist/types/nodefony/src/server/remotePresence.d.ts +151 -0
  76. package/dist/types/nodefony/src/server/subscribedAck.d.ts +10 -0
  77. package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
  78. package/dist/types/nodefony/src/service/RealtimeService.d.ts +17 -2
  79. package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
  80. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
  81. package/dist/types/nodefony/testing/backplaneConformance.d.ts +78 -0
  82. package/dist/types/nodefony/testing/index.d.ts +13 -2
  83. package/dist/types/nodefony/testing/presenceClient.d.ts +20 -0
  84. package/docs/actions.md +420 -18
  85. package/docs/architecture.md +101 -11
  86. package/docs/cadence-et-contre-pression.md +87 -11
  87. package/docs/canaux-a-membres.md +277 -0
  88. package/docs/cas-d-usage.md +1703 -0
  89. package/docs/configuration.md +175 -19
  90. package/docs/cookbook-chat.md +701 -224
  91. package/docs/index.md +11 -1
  92. package/docs/observabilite.md +97 -21
  93. package/docs/protocole.md +142 -33
  94. package/docs/securite.md +347 -41
  95. package/docs/vocabulaire.md +13 -12
  96. package/package.json +16 -13
@@ -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
  };
@@ -34,6 +34,7 @@ function mergeClusterHealth(instances, ts = Date.now()) {
34
34
  fanoutTotal: 0,
35
35
  inboundTotal: 0,
36
36
  ingressRejectedTotal: 0,
37
+ localOnlyTotal: 0,
37
38
  connectionCount: 0,
38
39
  bytesSentTotal: 0,
39
40
  messagesSentTotal: 0,
@@ -52,6 +53,7 @@ function mergeClusterHealth(instances, ts = Date.now()) {
52
53
  totals.fanoutTotal += h.fanoutTotal;
53
54
  totals.inboundTotal += h.inboundTotal;
54
55
  totals.ingressRejectedTotal += h.ingressRejectedTotal;
56
+ totals.localOnlyTotal += h.localOnlyTotal;
55
57
  totals.connectionCount += h.connectionCount;
56
58
  totals.bytesSentTotal += h.bytesSentTotal;
57
59
  totals.messagesSentTotal += h.messagesSentTotal;
@@ -1,5 +1,23 @@
1
1
  // nodefony/src/server/AnonymousRealtimeToken.ts
2
2
  /**
3
+ * Identité d'admission de l'ANONYME — gelée et partagée par toutes les connexions non authentifiées (0 allocation par connexion).
4
+ *
5
+ * Porte l'invariant de l'ADR-0021 §S10 : anonyme ⇔ `authenticated === false`,
6
+ * `id === null` et `issuer === null`. Un canal qui déclare `allow` refuse
7
+ * cette identité d'office, sauf option explicite `anonymous: true` — ce qui
8
+ * garantit que `user:${who.id}` ne vaut jamais `"user:null"`.
9
+ */
10
+ const ANONYMOUS_SUBSCRIBER_IDENTITY = Object.freeze({
11
+ type: "anonymous",
12
+ authenticated: false,
13
+ id: null,
14
+ userIdentifier: "anonymous",
15
+ roles: Object.freeze(["ROLE_ANONYMOUS"]),
16
+ scopes: Object.freeze([]),
17
+ issuer: null,
18
+ account: false
19
+ });
20
+ /**
3
21
  * Token realtime ANONYME — fallback Zero Trust quand aucun matcher
4
22
  * `IRealtimeAuthenticatorMatcher` ne capture le handshake.
5
23
  *
@@ -26,6 +44,9 @@ const ANONYMOUS_REALTIME_TOKEN = Object.freeze({
26
44
  getScopes() {
27
45
  return [];
28
46
  },
29
- getAttribute(_key) {}
47
+ getAttribute(_key) {},
48
+ getIdentity() {
49
+ return ANONYMOUS_SUBSCRIBER_IDENTITY;
50
+ }
30
51
  });
31
- export { ANONYMOUS_REALTIME_TOKEN, ANONYMOUS_REALTIME_TOKEN as default };
52
+ export { ANONYMOUS_REALTIME_TOKEN, ANONYMOUS_REALTIME_TOKEN as default, ANONYMOUS_SUBSCRIBER_IDENTITY };
@@ -0,0 +1,75 @@
1
+ import { RealtimeController } from "./RealtimeController.js";
2
+ // nodefony/src/server/ContractRealtimeController.ts
3
+ /**
4
+ * Socle commun des deux formes de {@link ContractRealtimeController} : l'étiquette
5
+ * de contrat et les canaux annoncés. Les actions y gardent le défaut de la base
6
+ * (`{}`) — un contrat SANS `serverMethods` n'a rien à écrire.
7
+ *
8
+ * Ne s'étend pas directement : on étend {@link ContractRealtimeController}, qui
9
+ * choisit cette forme ou la forme stricte selon le contrat.
10
+ *
11
+ * @typeParam C - le contrat de l'application.
12
+ */
13
+ var ContractRealtimeControllerBase = class extends RealtimeController {
14
+ /** Canaux annoncés au handshake, contraints aux noms de `serverToClient`. */
15
+ realtimeChannels() {
16
+ return [];
17
+ }
18
+ };
19
+ /**
20
+ * Forme stricte de {@link ContractRealtimeController}, retenue quand le contrat
21
+ * déclare des `serverMethods` : `realtimeActions()` y devient ABSTRAITE et typée
22
+ * par le contrat — la seule construction qui fait d'une méthode OUBLIÉE une
23
+ * erreur de compilation (un décorateur ne voit jamais la classe entière).
24
+ *
25
+ * Ne s'étend pas directement : on étend {@link ContractRealtimeController}.
26
+ *
27
+ * @typeParam C - le contrat de l'application.
28
+ */
29
+ var ContractRealtimeControllerStrict = class extends ContractRealtimeControllerBase {};
30
+ /**
31
+ * Base d'un contrôleur qui IMPLÉMENTE un contrat temps réel — la porte du
32
+ * serveur. Le contrat s'écrit UNE fois, sur `extends` ; la direction du serveur
33
+ * en est dérivée (il émet `serverToClient`, écoute `clientToServer`, appelle
34
+ * `clientMethods`) et il DOIT servir chaque méthode de `serverMethods`.
35
+ *
36
+ * Quand le contrat déclare des `serverMethods`, `realtimeActions()` est
37
+ * abstraite et typée par lui : une méthode OUBLIÉE ne compile pas. Un contrat
38
+ * SANS `serverMethods` (tableau de bord, chat qui ne fait que diffuser) n'a rien
39
+ * à écrire. Les entrées client (`realtimeInbound()`) restent facultatives canal
40
+ * par canal : accepter une entrée est une permission, pas une promesse. Les
41
+ * politiques d'accès se posent sur la classe par `@RealtimePolicy`, qui lit le
42
+ * contrat sur cette classe.
43
+ *
44
+ * Le fichier du contrat, fait de types seuls, est partagé avec le navigateur, qui
45
+ * entre par `NodefonySocket.fromContract<C>()`.
46
+ *
47
+ * Un contrat qui déclare des `serverRoutes` (routes du pont, typées côté client
48
+ * par le schéma que la route valide) se double d'une table statique de VALEURS,
49
+ * `static readonly serverRoutes = { … } satisfies ContractRouteSchemas<C>` : le
50
+ * compilateur y exige chaque route du contrat avec son schéma, et le démarrage
51
+ * vérifie que chacune est montée, servie par la socket et valide LE MÊME schéma
52
+ * (`BootConfigurationError` sinon). Sans cette table, aucun contrôle.
53
+ *
54
+ * @typeParam C - le contrat de l'application.
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * @RealtimePolicy({ "chat:send": { roles: ["ROLE_USER"] } })
59
+ * class ChatRt extends ContractRealtimeController<ChatContract> {
60
+ * protected realtimeActions() {
61
+ * return { "chat:send": (p: { text: string }) => ({ id: this.store(p.text) }) };
62
+ * }
63
+ * }
64
+ *
65
+ * class DashRt extends ContractRealtimeController<DashContract> {} // sans serverMethods
66
+ *
67
+ * class ChatRoutesRt extends ContractRealtimeController<ChatContract> {
68
+ * static readonly serverRoutes = {
69
+ * "POST /chat/send": { body: SendBody }, // le schéma de la route, pas une copie
70
+ * } satisfies ContractRouteSchemas<ChatContract>;
71
+ * }
72
+ * ```
73
+ */
74
+ const ContractRealtimeController = ContractRealtimeControllerStrict;
75
+ export { ContractRealtimeController, ContractRealtimeControllerBase, ContractRealtimeControllerStrict };
@@ -0,0 +1,135 @@
1
+ // nodefony/src/server/RealtimeConnection.ts
2
+ /**
3
+ * Poignée d'une connexion temps réel servie par ce processus — implémente {@link IRealtimeConnection}.
4
+ *
5
+ * Créée paresseusement par le contrôleur, au premier abonnement ou à la
6
+ * première trame entrante de la connexion (jamais au welcome : une connexion
7
+ * qui ne fait ni l'un ni l'autre n'en paie aucune). Fermée au `onFinish` de la
8
+ * socket : les gestes du hub rendent alors `false`, et les abonnements imposés
9
+ * en attente se soldent par un refus.
10
+ *
11
+ * @internal Le type public est {@link IRealtimeConnection} ; seuls le hub et le
12
+ * contrôleur appellent les méthodes de cette classe.
13
+ */
14
+ var RealtimeConnection = class {
15
+ #host;
16
+ #identify;
17
+ #userId = null;
18
+ #account = false;
19
+ #open = true;
20
+ #imposing = null;
21
+ /**
22
+ * @param host - le contrôleur qui sert la connexion.
23
+ * @param identify - lit l'identité d'admission, figée au handshake ; appelé
24
+ * au plus une fois.
25
+ */
26
+ constructor(host, identify) {
27
+ this.#host = host;
28
+ this.#identify = identify;
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
+ }
39
+ /** Identifiant du compte connecté ; `null` pour l'anonyme. */
40
+ get userId() {
41
+ this.#resolveIdentity();
42
+ return this.#userId;
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
+ }
63
+ /** `false` une fois la socket fermée. */
64
+ get open() {
65
+ return this.#open;
66
+ }
67
+ /**
68
+ * Impose l'abonnement à `channel` — même politique, même `allow` qu'un `subscribe` du client.
69
+ *
70
+ * @returns une promesse résolue à `true` une fois l'abonnement acquitté, à
71
+ * `false` s'il est refusé, si la connexion se ferme avant, ou si le client
72
+ * s'en désabonne pendant l'attente.
73
+ */
74
+ impose(channel) {
75
+ if (!this.#open) return Promise.resolve(false);
76
+ const imposing = this.#imposing ??= /* @__PURE__ */ new Map();
77
+ const pending = imposing.get(channel);
78
+ if (pending !== void 0) return new Promise((resolve) => {
79
+ pending.settle.push(resolve);
80
+ });
81
+ const entry = {
82
+ settle: [],
83
+ clientAsked: false
84
+ };
85
+ const verdict = new Promise((resolve) => {
86
+ entry.settle.push(resolve);
87
+ });
88
+ imposing.set(channel, entry);
89
+ this.#host.impose(channel);
90
+ return verdict;
91
+ }
92
+ /**
93
+ * Met fin à l'abonnement de cette connexion à `channel`.
94
+ *
95
+ * @returns `false` si la connexion est fermée ou n'est pas abonnée au canal.
96
+ */
97
+ end(channel, reason) {
98
+ return this.#open && this.#host.end(channel, reason);
99
+ }
100
+ /** Un abonnement imposé à `channel` attend-il son verdict ? */
101
+ isImposing(channel) {
102
+ return this.#imposing?.has(channel) === true;
103
+ }
104
+ /** Le client demande `channel` pendant l'attente de son imposition : un refus lui sera dû. */
105
+ markClientAsked(channel) {
106
+ const entry = this.#imposing?.get(channel);
107
+ if (entry !== void 0) entry.clientAsked = true;
108
+ }
109
+ /**
110
+ * Solde l'abonnement imposé à `channel`, s'il y en a un.
111
+ *
112
+ * @returns `true` si le refus doit AUSSI être rendu au client (il avait
113
+ * demandé ce canal) ou s'il n'y avait pas d'imposition en cours — dans les
114
+ * deux cas l'appelant suit son chemin habituel ; `false` si le refus
115
+ * n'appartient qu'à l'appelant serveur.
116
+ */
117
+ settleImposed(channel, subscribed) {
118
+ const imposing = this.#imposing;
119
+ const entry = imposing?.get(channel);
120
+ if (imposing === null || entry === void 0) return true;
121
+ imposing.delete(channel);
122
+ if (imposing.size === 0) this.#imposing = null;
123
+ for (const settle of entry.settle) settle(subscribed);
124
+ return entry.clientAsked;
125
+ }
126
+ /** Fermeture de la socket : la poignée ne désigne plus rien, les impositions en attente échouent. */
127
+ close() {
128
+ this.#open = false;
129
+ const imposing = this.#imposing;
130
+ if (imposing === null) return;
131
+ this.#imposing = null;
132
+ for (const entry of imposing.values()) for (const settle of entry.settle) settle(false);
133
+ }
134
+ };
135
+ export { RealtimeConnection };