@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.
- package/README.md +55 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
- package/dist/index.js +91 -12
- package/dist/nodefony/config/config.js +7 -0
- package/dist/nodefony/decorators/realtimeDecorators.js +341 -43
- package/dist/nodefony/interfaces/IRealtimeConnection.js +1 -0
- package/dist/nodefony/src/backplane/ClusterBackplane.js +32 -4
- package/dist/nodefony/src/backplane/RedisBackplane.js +146 -41
- package/dist/nodefony/src/backplane/controlOrder.js +86 -0
- package/dist/nodefony/src/backplane/envelope.js +505 -27
- 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/cluster/ClusterProbeClient.js +2 -0
- package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
- package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
- package/dist/nodefony/src/server/RealtimeConnection.js +135 -0
- package/dist/nodefony/src/server/RealtimeController.js +409 -107
- package/dist/nodefony/src/server/RealtimeHub.js +1158 -111
- package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
- package/dist/nodefony/src/server/admissionBudget.js +176 -0
- package/dist/nodefony/src/server/channelPolicyRegistry.js +95 -0
- package/dist/nodefony/src/server/channelPolicyResolution.js +191 -0
- package/dist/nodefony/src/server/channelPresence.js +488 -0
- package/dist/nodefony/src/server/connectionAdmission.js +242 -0
- package/dist/nodefony/src/server/contractRoutes.js +75 -0
- package/dist/nodefony/src/server/deniedDetail.js +10 -3
- package/dist/nodefony/src/server/frameAdmission.js +160 -0
- 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 +13 -1
- package/dist/nodefony/src/server/syslogUplink.js +3 -4
- package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
- package/dist/nodefony/src/service/RealtimeService.js +51 -1
- package/dist/nodefony/src/service/publicOrigin.js +40 -0
- package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
- package/dist/nodefony/testing/backplaneConformance.js +347 -0
- package/dist/nodefony/testing/index.js +43 -12
- package/dist/nodefony/testing/presenceClient.js +54 -0
- package/dist/types/index.d.ts +39 -7
- package/dist/types/nodefony/config/config.d.ts +9 -0
- package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +198 -17
- package/dist/types/nodefony/interfaces/IBackplane.d.ts +46 -51
- package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
- package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
- package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
- package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
- package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
- package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
- package/dist/types/nodefony/interfaces/index.d.ts +2 -1
- package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
- package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +89 -18
- package/dist/types/nodefony/src/backplane/controlOrder.d.ts +75 -0
- package/dist/types/nodefony/src/backplane/envelope.d.ts +77 -0
- 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/AnonymousRealtimeToken.d.ts +10 -0
- package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
- package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +20 -0
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +110 -12
- package/dist/types/nodefony/src/server/RealtimeHub.d.ts +448 -42
- package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
- package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
- package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +41 -0
- 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/connectionAdmission.d.ts +68 -0
- package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
- package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
- package/dist/types/nodefony/src/server/frameAdmission.d.ts +66 -0
- 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 +10 -0
- package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
- package/dist/types/nodefony/src/service/RealtimeService.d.ts +17 -2
- package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
- package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
- package/dist/types/nodefony/testing/backplaneConformance.d.ts +78 -0
- package/dist/types/nodefony/testing/index.d.ts +13 -2
- package/dist/types/nodefony/testing/presenceClient.d.ts +20 -0
- package/docs/actions.md +420 -18
- package/docs/architecture.md +101 -11
- package/docs/cadence-et-contre-pression.md +87 -11
- package/docs/canaux-a-membres.md +277 -0
- package/docs/cas-d-usage.md +1703 -0
- package/docs/configuration.md +175 -19
- package/docs/cookbook-chat.md +701 -224
- package/docs/index.md +11 -1
- package/docs/observabilite.md +97 -21
- package/docs/protocole.md +142 -33
- package/docs/securite.md +347 -41
- package/docs/vocabulaire.md +13 -12
- 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 };
|