@nodefony/realtime 10.0.0-beta.6 → 10.0.0-beta.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
- package/dist/index.js +15 -3
- package/dist/nodefony/decorators/realtimeDecorators.js +231 -39
- package/dist/nodefony/src/backplane/RedisBackplane.js +33 -40
- package/dist/nodefony/src/backplane/controlOrder.js +34 -5
- package/dist/nodefony/src/backplane/envelope.js +422 -45
- package/dist/nodefony/src/backplane/ingress.js +93 -0
- package/dist/nodefony/src/backplane/presenceMessage.js +205 -0
- package/dist/nodefony/src/backplane/publishQueue.js +97 -3
- package/dist/nodefony/src/server/RealtimeConnection.js +31 -6
- package/dist/nodefony/src/server/RealtimeController.js +87 -44
- package/dist/nodefony/src/server/RealtimeHub.js +631 -127
- package/dist/nodefony/src/server/admissionBudget.js +8 -8
- package/dist/nodefony/src/server/channelPolicyRegistry.js +35 -13
- package/dist/nodefony/src/server/channelPolicyResolution.js +191 -0
- package/dist/nodefony/src/server/channelPresence.js +488 -0
- package/dist/nodefony/src/server/contractRoutes.js +3 -13
- package/dist/nodefony/src/server/frameAdmission.js +27 -35
- package/dist/nodefony/src/server/presenceBus.js +486 -0
- package/dist/nodefony/src/server/realtimeShutdown.js +31 -0
- package/dist/nodefony/src/server/remotePresence.js +541 -0
- package/dist/nodefony/src/server/subscribedAck.js +3 -0
- package/dist/nodefony/src/service/RealtimeService.js +9 -0
- package/dist/nodefony/testing/backplaneConformance.js +79 -3
- package/dist/nodefony/testing/index.js +22 -8
- package/dist/nodefony/testing/presenceClient.js +54 -0
- package/dist/types/index.d.ts +6 -1
- package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +117 -16
- package/dist/types/nodefony/interfaces/IBackplane.d.ts +12 -1
- package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +10 -2
- package/dist/types/nodefony/src/backplane/controlOrder.d.ts +23 -0
- package/dist/types/nodefony/src/backplane/envelope.d.ts +50 -7
- package/dist/types/nodefony/src/backplane/ingress.d.ts +48 -0
- package/dist/types/nodefony/src/backplane/presenceMessage.d.ts +150 -0
- package/dist/types/nodefony/src/backplane/publishQueue.d.ts +25 -1
- package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +3 -0
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +19 -1
- package/dist/types/nodefony/src/server/RealtimeHub.d.ts +139 -30
- package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +9 -2
- package/dist/types/nodefony/src/server/channelPolicyResolution.d.ts +112 -0
- package/dist/types/nodefony/src/server/channelPresence.d.ts +155 -0
- package/dist/types/nodefony/src/server/contractRoutes.d.ts +1 -1
- package/dist/types/nodefony/src/server/frameAdmission.d.ts +14 -18
- package/dist/types/nodefony/src/server/presenceBus.d.ts +112 -0
- package/dist/types/nodefony/src/server/realtimeShutdown.d.ts +21 -0
- package/dist/types/nodefony/src/server/remotePresence.d.ts +151 -0
- package/dist/types/nodefony/src/server/subscribedAck.d.ts +3 -0
- package/dist/types/nodefony/src/service/RealtimeService.d.ts +8 -1
- package/dist/types/nodefony/testing/backplaneConformance.d.ts +14 -2
- package/dist/types/nodefony/testing/index.d.ts +1 -1
- package/dist/types/nodefony/testing/presenceClient.d.ts +20 -0
- package/docs/actions.md +1 -0
- package/docs/architecture.md +21 -6
- package/docs/canaux-a-membres.md +13 -3
- package/docs/cas-d-usage.md +4 -3
- package/docs/configuration.md +38 -2
- package/docs/cookbook-chat.md +400 -152
- package/docs/observabilite.md +28 -9
- package/docs/protocole.md +10 -10
- package/docs/securite.md +64 -10
- package/package.json +12 -12
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
import { isRecord } from "../guards.js";
|
|
2
|
+
// nodefony/src/server/channelPresence.ts
|
|
3
|
+
/**
|
|
4
|
+
* Fenêtre de regroupement des changements de présence vers le navigateur (ms) — front montant : le premier changement d'un canal calme part tout de suite, les suivants attendent la fin de la fenêtre (ADR-0025 P2 bis).
|
|
5
|
+
*/
|
|
6
|
+
const PRESENCE_WINDOW_MS = 250;
|
|
7
|
+
/** Seuil par défaut d'identités nommées au-delà duquel la trame ne porte que les nombres (ADR-0025 P2 bis). */
|
|
8
|
+
const DEFAULT_PRESENCE_MAX_NAMED = 1e3;
|
|
9
|
+
const PRESENCE_OPTION_KEYS = /* @__PURE__ */ new Set(["grace", "maxNamed"]);
|
|
10
|
+
/**
|
|
11
|
+
* Départ d'un PAIR retenu par la grâce (pod oublié, arrêté, remplacé) — ses onglets restent comptés jusqu'à l'échéance. Forme fixe : alloué au seul départ d'un pod, chemin froid.
|
|
12
|
+
*/
|
|
13
|
+
var RemoteDeparture = class {
|
|
14
|
+
deadline;
|
|
15
|
+
tabs;
|
|
16
|
+
constructor(deadline, tabs) {
|
|
17
|
+
this.deadline = deadline;
|
|
18
|
+
this.tabs = tabs;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
/** Entier sûr ≥ 0 ? */
|
|
22
|
+
function isCount(value) {
|
|
23
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Défaut de la valeur de la clé `presence` d'une politique, ou `null` si elle est recevable — en clair, pour que le refus au démarrage dise QUOI corriger.
|
|
27
|
+
*
|
|
28
|
+
* @param value - valeur de `policy.presence` (`unknown` : une politique se lit
|
|
29
|
+
* depuis une métadonnée publique).
|
|
30
|
+
*/
|
|
31
|
+
function presenceDefect(value) {
|
|
32
|
+
if (value === void 0 || typeof value === "boolean") return null;
|
|
33
|
+
if (!isRecord(value) || Array.isArray(value)) return "« presence » doit être un booléen ou un objet d'options";
|
|
34
|
+
for (const key of Object.keys(value)) if (!PRESENCE_OPTION_KEYS.has(key)) return `« presence » : option inconnue « ${key} » (options admises : ${[...PRESENCE_OPTION_KEYS].join(", ")})`;
|
|
35
|
+
if (value.grace !== void 0 && !isCount(value.grace)) return "« presence.grace » doit être un entier de millisecondes ≥ 0";
|
|
36
|
+
if (value.maxNamed !== void 0 && !isCount(value.maxNamed)) return "« presence.maxNamed » doit être un entier ≥ 0";
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Réglages résolus de la présence qu'une politique déclare, ou `null` si elle n'en déclare pas.
|
|
41
|
+
*
|
|
42
|
+
* Précondition : la politique a passé {@link presenceDefect}.
|
|
43
|
+
*/
|
|
44
|
+
function presenceSettingsOf(policy) {
|
|
45
|
+
const presence = policy?.presence;
|
|
46
|
+
if (presence === void 0 || presence === false) return null;
|
|
47
|
+
if (presence === true) return {
|
|
48
|
+
grace: 0,
|
|
49
|
+
maxNamed: DEFAULT_PRESENCE_MAX_NAMED
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
grace: presence.grace ?? 0,
|
|
53
|
+
maxNamed: presence.maxNamed ?? 1e3
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Deux politiques déclarent-elles la même présence ? Défauts appliqués : `true` et `{ grace: 0 }` sont la même.
|
|
58
|
+
*
|
|
59
|
+
* Sert à refuser deux déclarations d'un même nom qui ne diffèrent que par la
|
|
60
|
+
* présence — une seule s'appliquerait. Démarrage seulement.
|
|
61
|
+
*/
|
|
62
|
+
function samePresence(a, b) {
|
|
63
|
+
const x = presenceSettingsOf(a);
|
|
64
|
+
const y = presenceSettingsOf(b);
|
|
65
|
+
if (x === null || y === null) return x === y;
|
|
66
|
+
return x.grace === y.grace && x.maxNamed === y.maxNamed;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* État de présence LOCAL d'un canal à présence (ADR-0025 P2 bis, P5) — identités nommées et onglets, connexions non nommées, version, changements en attente de la fenêtre, départs en grâce.
|
|
70
|
+
*
|
|
71
|
+
* Alloué au premier client d'un canal qui déclare `presence`, jamais pour un
|
|
72
|
+
* autre canal. Ne connaît ni l'horloge ni le protocole : le hub lui passe
|
|
73
|
+
* l'instant et fabrique les trames. Les changements portent des valeurs
|
|
74
|
+
* ABSOLUES (nombre d'onglets, départ), donc rejouer un changement déjà inclus
|
|
75
|
+
* dans une liste ne change rien — c'est ce qui permet de servir à un nouvel
|
|
76
|
+
* abonné la liste prise au dernier envoi, encodée une fois.
|
|
77
|
+
*
|
|
78
|
+
* Sur un canal DIFFUSÉ entre pods, c'est aussi l'agrégat de la vue des pairs
|
|
79
|
+
* ({@link IPresenceAggregate}) : les compteurs portent le TOTAL (local + pairs),
|
|
80
|
+
* et une table à part garde la seule part locale — ce que ce pod annonce à ses
|
|
81
|
+
* pairs, en deltas par lot. Ailleurs, cette table n'existe pas.
|
|
82
|
+
*/
|
|
83
|
+
var ChannelPresence = class {
|
|
84
|
+
channel;
|
|
85
|
+
settings;
|
|
86
|
+
/** Canal diffusé entre pods : sa part locale part sur le bus, la vue des pairs s'y ajoute. */
|
|
87
|
+
distributed;
|
|
88
|
+
/** Fin de la fenêtre de regroupement en cours (horloge monotone, ms). */
|
|
89
|
+
windowEnd = Number.NEGATIVE_INFINITY;
|
|
90
|
+
/** Liste complète encodée, servie telle quelle jusqu'au prochain envoi. */
|
|
91
|
+
listFrame = null;
|
|
92
|
+
/** Le canal est-il encore tenu par le hub ? `false` après son retrait. */
|
|
93
|
+
live = true;
|
|
94
|
+
/**
|
|
95
|
+
* Fin de la synchronisation initiale avec les pairs (ms monotone) — la vue
|
|
96
|
+
* n'est que locale tant que la demande d'état du premier abonné n'a pas eu le
|
|
97
|
+
* temps d'être servie ; `0` = synchronisée (ou canal non diffusé).
|
|
98
|
+
*/
|
|
99
|
+
syncUntil = 0;
|
|
100
|
+
#named = /* @__PURE__ */ new Map();
|
|
101
|
+
#unnamed = 0;
|
|
102
|
+
#local;
|
|
103
|
+
#localUnnamed = 0;
|
|
104
|
+
#busBefore = null;
|
|
105
|
+
#busUnnamedBefore = -1;
|
|
106
|
+
#partial = false;
|
|
107
|
+
#version = 0;
|
|
108
|
+
#dirty = null;
|
|
109
|
+
#countsDirty = false;
|
|
110
|
+
#namedSent = true;
|
|
111
|
+
#resumeNamed;
|
|
112
|
+
#leaving = null;
|
|
113
|
+
#remoteLeaving = null;
|
|
114
|
+
/**
|
|
115
|
+
* @param distributed - le canal est diffusé entre pods (bus branché, préfixe
|
|
116
|
+
* diffusé) : la part locale est tenue à part pour le bus.
|
|
117
|
+
*/
|
|
118
|
+
constructor(channel, settings, distributed = false) {
|
|
119
|
+
this.channel = channel;
|
|
120
|
+
this.settings = settings;
|
|
121
|
+
this.distributed = distributed;
|
|
122
|
+
this.#local = distributed ? /* @__PURE__ */ new Map() : null;
|
|
123
|
+
this.#resumeNamed = settings.maxNamed - Math.floor(settings.maxNamed / 10);
|
|
124
|
+
}
|
|
125
|
+
/** La part locale a-t-elle changé depuis le dernier lot ? */
|
|
126
|
+
get busDirty() {
|
|
127
|
+
return this.#busBefore !== null || this.#busUnnamedBefore >= 0;
|
|
128
|
+
}
|
|
129
|
+
/** La vue est-elle connue incomplète (synchronisation initiale, trou chez un pair) ? */
|
|
130
|
+
get partial() {
|
|
131
|
+
return this.#partial;
|
|
132
|
+
}
|
|
133
|
+
/** Version courante de la vue (+1 par trame de changements). */
|
|
134
|
+
get version() {
|
|
135
|
+
return this.#version;
|
|
136
|
+
}
|
|
137
|
+
/** Des changements attendent-ils un envoi ? */
|
|
138
|
+
get dirty() {
|
|
139
|
+
return this.#dirty !== null || this.#countsDirty;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Une connexion entre.
|
|
143
|
+
*
|
|
144
|
+
* @param key - identité nommée, ou `null` pour une connexion non nommée.
|
|
145
|
+
*/
|
|
146
|
+
join(key) {
|
|
147
|
+
const local = this.#local;
|
|
148
|
+
if (key === null) {
|
|
149
|
+
this.#unnamed += 1;
|
|
150
|
+
this.#countsDirty = true;
|
|
151
|
+
if (local !== null) this.#addLocalUnnamed(1);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (this.#leaving?.delete(key) === true) return;
|
|
155
|
+
const named = this.#named;
|
|
156
|
+
const before = named.get(key) ?? 0;
|
|
157
|
+
const total = before - this.#cancelRemoteDeparture(key) + 1;
|
|
158
|
+
named.set(key, total);
|
|
159
|
+
if (total !== before) this.#markDirty(key);
|
|
160
|
+
if (local !== null) this.#setLocal(local, key, (local.get(key) ?? 0) + 1);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Une connexion sort.
|
|
164
|
+
*
|
|
165
|
+
* Un départ nommé attend la grâce du canal ; un départ non nommé est décompté
|
|
166
|
+
* tout de suite (rien ne reconnaîtrait son retour).
|
|
167
|
+
*
|
|
168
|
+
* @param key - même valeur qu'à {@link ChannelPresence.join}.
|
|
169
|
+
* @param now - instant monotone (ms), pour la grâce.
|
|
170
|
+
*/
|
|
171
|
+
leave(key, now) {
|
|
172
|
+
const local = this.#local;
|
|
173
|
+
if (key === null) {
|
|
174
|
+
this.#unnamed -= 1;
|
|
175
|
+
this.#countsDirty = true;
|
|
176
|
+
if (local !== null) this.#addLocalUnnamed(-1);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const grace = this.settings.grace;
|
|
180
|
+
const tabs = this.#named.get(key);
|
|
181
|
+
if (tabs === void 0) return;
|
|
182
|
+
if (tabs > 1) {
|
|
183
|
+
this.#named.set(key, tabs - 1);
|
|
184
|
+
this.#markDirty(key);
|
|
185
|
+
if (local !== null) {
|
|
186
|
+
const mine = local.get(key) ?? 0;
|
|
187
|
+
if (mine > 0) this.#setLocal(local, key, mine - 1);
|
|
188
|
+
}
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (grace > 0) {
|
|
192
|
+
(this.#leaving ??= /* @__PURE__ */ new Map()).set(key, now + grace);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
this.#named.delete(key);
|
|
196
|
+
this.#markDirty(key);
|
|
197
|
+
if (local !== null) this.#setLocal(local, key, 0);
|
|
198
|
+
}
|
|
199
|
+
/** Publie les départs dont la grâce est écoulée. */
|
|
200
|
+
expire(now) {
|
|
201
|
+
const leaving = this.#leaving;
|
|
202
|
+
const local = this.#local;
|
|
203
|
+
if (this.#remoteLeaving !== null) this.#expireRemote(now);
|
|
204
|
+
if (leaving === null) return;
|
|
205
|
+
for (const [key, deadline] of leaving) {
|
|
206
|
+
if (deadline > now) break;
|
|
207
|
+
leaving.delete(key);
|
|
208
|
+
this.#named.delete(key);
|
|
209
|
+
this.#markDirty(key);
|
|
210
|
+
if (local !== null) this.#setLocal(local, key, 0);
|
|
211
|
+
}
|
|
212
|
+
if (leaving.size === 0) this.#leaving = null;
|
|
213
|
+
}
|
|
214
|
+
/** Prochaine échéance de grâce (ms monotone), `Infinity` sans départ retenu — O(1). */
|
|
215
|
+
nextDeadline() {
|
|
216
|
+
let next = Number.POSITIVE_INFINITY;
|
|
217
|
+
if (this.#leaving !== null) for (const deadline of this.#leaving.values()) {
|
|
218
|
+
next = deadline;
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
if (this.#remoteLeaving !== null) for (const departure of this.#remoteLeaving.values()) {
|
|
222
|
+
if (departure.deadline < next) next = departure.deadline;
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
return next;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Trame complète à la version COURANTE — la liste, ou les nombres seuls au-delà du seuil.
|
|
229
|
+
*
|
|
230
|
+
* Une liste ne prend jamais de numéro : servie à un nouvel abonné, elle ferait
|
|
231
|
+
* voir un trou à tous les autres (ADR-0025 P2 bis).
|
|
232
|
+
*/
|
|
233
|
+
listParams() {
|
|
234
|
+
const named = this.#named;
|
|
235
|
+
const count = named.size;
|
|
236
|
+
const channel = this.channel;
|
|
237
|
+
const version = this.#version;
|
|
238
|
+
const unnamed = this.#unnamed;
|
|
239
|
+
if (!this.#wantsNamed(count)) return this.#partial ? {
|
|
240
|
+
channel,
|
|
241
|
+
version,
|
|
242
|
+
count,
|
|
243
|
+
unnamed,
|
|
244
|
+
partial: true
|
|
245
|
+
} : {
|
|
246
|
+
channel,
|
|
247
|
+
version,
|
|
248
|
+
count,
|
|
249
|
+
unnamed
|
|
250
|
+
};
|
|
251
|
+
const members = pairsOf(named);
|
|
252
|
+
return this.#partial ? {
|
|
253
|
+
channel,
|
|
254
|
+
version,
|
|
255
|
+
count,
|
|
256
|
+
unnamed,
|
|
257
|
+
members,
|
|
258
|
+
partial: true
|
|
259
|
+
} : {
|
|
260
|
+
channel,
|
|
261
|
+
version,
|
|
262
|
+
count,
|
|
263
|
+
unnamed,
|
|
264
|
+
members
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Consomme les changements en attente et rend la trame à envoyer à tous les membres, ou `null` s'il n'y en a pas.
|
|
269
|
+
*
|
|
270
|
+
* Sous le seuil : les changements, version +1. Au-delà : les nombres seuls,
|
|
271
|
+
* version inchangée (trame complète). Au retour sous le seuil : la liste.
|
|
272
|
+
* Toute trame envoyée périme la liste encodée.
|
|
273
|
+
*/
|
|
274
|
+
takeChanges() {
|
|
275
|
+
if (!this.dirty) return null;
|
|
276
|
+
const dirty = this.#dirty;
|
|
277
|
+
this.#dirty = null;
|
|
278
|
+
this.#countsDirty = false;
|
|
279
|
+
this.listFrame = null;
|
|
280
|
+
const named = this.#wantsNamed(this.#named.size);
|
|
281
|
+
if (!named || !this.#namedSent) {
|
|
282
|
+
this.#namedSent = named;
|
|
283
|
+
return this.listParams();
|
|
284
|
+
}
|
|
285
|
+
this.#version += 1;
|
|
286
|
+
let set = null;
|
|
287
|
+
let gone = null;
|
|
288
|
+
if (dirty !== null) for (const key of dirty) {
|
|
289
|
+
const tabs = this.#named.get(key);
|
|
290
|
+
if (tabs === void 0) (gone ??= []).push(key);
|
|
291
|
+
else (set ??= []).push(key, tabs);
|
|
292
|
+
}
|
|
293
|
+
const changes = set === null ? gone === null ? {} : { gone } : gone === null ? { set } : {
|
|
294
|
+
set,
|
|
295
|
+
gone
|
|
296
|
+
};
|
|
297
|
+
const channel = this.channel;
|
|
298
|
+
const version = this.#version;
|
|
299
|
+
const count = this.#named.size;
|
|
300
|
+
const unnamed = this.#unnamed;
|
|
301
|
+
return this.#partial ? {
|
|
302
|
+
channel,
|
|
303
|
+
version,
|
|
304
|
+
count,
|
|
305
|
+
unnamed,
|
|
306
|
+
changes,
|
|
307
|
+
partial: true
|
|
308
|
+
} : {
|
|
309
|
+
channel,
|
|
310
|
+
version,
|
|
311
|
+
count,
|
|
312
|
+
unnamed,
|
|
313
|
+
changes
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
/** Vue de l'observateur serveur : les membres toujours, une copie. */
|
|
317
|
+
view() {
|
|
318
|
+
const members = /* @__PURE__ */ new Map();
|
|
319
|
+
for (const [key, tabs] of this.#named) members.set(key, { tabs });
|
|
320
|
+
return {
|
|
321
|
+
count: this.#named.size,
|
|
322
|
+
unnamed: this.#unnamed,
|
|
323
|
+
members,
|
|
324
|
+
partial: this.#partial
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Pose l'état « incomplet » de la vue ; un changement fait partir une trame (la version suit) et périme la liste encodée.
|
|
329
|
+
*/
|
|
330
|
+
setPartial(partial) {
|
|
331
|
+
if (partial === this.#partial) return;
|
|
332
|
+
this.#partial = partial;
|
|
333
|
+
this.#countsDirty = true;
|
|
334
|
+
this.listFrame = null;
|
|
335
|
+
}
|
|
336
|
+
/** {@inheritDoc IPresenceAggregate.remoteTabs} */
|
|
337
|
+
remoteTabs(key, delta) {
|
|
338
|
+
const named = this.#named;
|
|
339
|
+
const before = named.get(key) ?? 0;
|
|
340
|
+
let total = before;
|
|
341
|
+
if (delta > 0) {
|
|
342
|
+
if (this.#leaving?.delete(key) === true) {
|
|
343
|
+
total -= 1;
|
|
344
|
+
const local = this.#local;
|
|
345
|
+
if (local !== null) this.#setLocal(local, key, 0);
|
|
346
|
+
} else total -= this.#cancelRemoteDeparture(key);
|
|
347
|
+
}
|
|
348
|
+
total += delta;
|
|
349
|
+
if (total > 0) named.set(key, total);
|
|
350
|
+
else named.delete(key);
|
|
351
|
+
if (total !== before) this.#markDirty(key);
|
|
352
|
+
}
|
|
353
|
+
/** {@inheritDoc IPresenceAggregate.remoteUnnamed} */
|
|
354
|
+
remoteUnnamed(delta) {
|
|
355
|
+
this.#unnamed += delta;
|
|
356
|
+
this.#countsDirty = true;
|
|
357
|
+
}
|
|
358
|
+
/** {@inheritDoc IPresenceAggregate.remoteLeave} */
|
|
359
|
+
remoteLeave(key, tabs, now) {
|
|
360
|
+
const grace = this.settings.grace;
|
|
361
|
+
if (grace === 0 || (this.#named.get(key) ?? 0) > tabs) {
|
|
362
|
+
this.remoteTabs(key, -tabs);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const leaving = this.#remoteLeaving ??= /* @__PURE__ */ new Map();
|
|
366
|
+
const pending = leaving.get(key);
|
|
367
|
+
if (pending !== void 0) pending.tabs += tabs;
|
|
368
|
+
else leaving.set(key, new RemoteDeparture(now + grace, tabs));
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Consomme les changements de la part locale depuis le dernier lot et rend l'entrée du lot, en valeurs absolues — `null` s'ils s'annulent tous.
|
|
372
|
+
*/
|
|
373
|
+
takeBusDelta() {
|
|
374
|
+
const local = this.#local;
|
|
375
|
+
const before = this.#busBefore;
|
|
376
|
+
const unnamedBefore = this.#busUnnamedBefore;
|
|
377
|
+
this.#busBefore = null;
|
|
378
|
+
this.#busUnnamedBefore = -1;
|
|
379
|
+
if (local === null) return null;
|
|
380
|
+
let set = null;
|
|
381
|
+
let gone = null;
|
|
382
|
+
if (before !== null) for (const [key, was] of before) {
|
|
383
|
+
const tabs = local.get(key) ?? 0;
|
|
384
|
+
if (tabs === was) continue;
|
|
385
|
+
if (tabs === 0) (gone ??= []).push(key);
|
|
386
|
+
else (set ??= []).push(key, tabs);
|
|
387
|
+
}
|
|
388
|
+
const unnamed = this.#localUnnamed;
|
|
389
|
+
if (set === null && gone === null && (unnamedBefore < 0 || unnamedBefore === unnamed)) return null;
|
|
390
|
+
return {
|
|
391
|
+
channel: this.channel,
|
|
392
|
+
set,
|
|
393
|
+
gone,
|
|
394
|
+
unnamed
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
/** État COMPLET de la part locale, pour une réponse d'état (paires à plat). */
|
|
398
|
+
localState() {
|
|
399
|
+
const local = this.#local;
|
|
400
|
+
return {
|
|
401
|
+
channel: this.channel,
|
|
402
|
+
members: local === null ? [] : pairsOf(local),
|
|
403
|
+
unnamed: this.#localUnnamed
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Des départs LOCAUX attendent-ils la fin de leur grâce ? Le dernier client
|
|
408
|
+
* parti d'un pod n'y échappe pas : la présence reste servie au bus jusqu'à
|
|
409
|
+
* l'échéance (ADR-0025 P5), puis se libère.
|
|
410
|
+
*/
|
|
411
|
+
get hasLocalGrace() {
|
|
412
|
+
return this.#leaving !== null;
|
|
413
|
+
}
|
|
414
|
+
/** La part locale compte-t-elle quelqu'un ? */
|
|
415
|
+
get hasLocal() {
|
|
416
|
+
return (this.#local?.size ?? 0) > 0 || this.#localUnnamed > 0;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Le canal n'est plus servi ici : toute la part locale part à zéro au prochain lot, pour que les pairs ne gardent aucun fantôme.
|
|
420
|
+
*/
|
|
421
|
+
release() {
|
|
422
|
+
const local = this.#local;
|
|
423
|
+
if (local === null) return;
|
|
424
|
+
for (const key of local.keys()) this.#setLocal(local, key, 0);
|
|
425
|
+
this.#leaving = null;
|
|
426
|
+
if (this.#localUnnamed !== 0) this.#addLocalUnnamed(-this.#localUnnamed);
|
|
427
|
+
this.#remoteLeaving = null;
|
|
428
|
+
}
|
|
429
|
+
/** Annule le départ en grâce d'un pair pour `key` ; rend les onglets qu'il retenait (`0` sans départ). */
|
|
430
|
+
#cancelRemoteDeparture(key) {
|
|
431
|
+
const leaving = this.#remoteLeaving;
|
|
432
|
+
if (leaving === null) return 0;
|
|
433
|
+
const departure = leaving.get(key);
|
|
434
|
+
if (departure === void 0) return 0;
|
|
435
|
+
leaving.delete(key);
|
|
436
|
+
if (leaving.size === 0) this.#remoteLeaving = null;
|
|
437
|
+
return departure.tabs;
|
|
438
|
+
}
|
|
439
|
+
/** Départs de pairs dont la grâce est écoulée — échéances croissantes, arrêt à la première non échue. */
|
|
440
|
+
#expireRemote(now) {
|
|
441
|
+
const leaving = this.#remoteLeaving;
|
|
442
|
+
if (leaving === null) return;
|
|
443
|
+
const named = this.#named;
|
|
444
|
+
for (const [key, departure] of leaving) {
|
|
445
|
+
if (departure.deadline > now) break;
|
|
446
|
+
leaving.delete(key);
|
|
447
|
+
const total = (named.get(key) ?? 0) - departure.tabs;
|
|
448
|
+
if (total > 0) named.set(key, total);
|
|
449
|
+
else named.delete(key);
|
|
450
|
+
this.#markDirty(key);
|
|
451
|
+
}
|
|
452
|
+
if (leaving.size === 0) this.#remoteLeaving = null;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* La trame complète doit-elle nommer `count` présents ? Nommée : tant qu'on ne
|
|
456
|
+
* dépasse pas `maxNamed`. En nombres seuls : seulement revenu au seuil de retour.
|
|
457
|
+
*/
|
|
458
|
+
#wantsNamed(count) {
|
|
459
|
+
return count <= (this.#namedSent ? this.settings.maxNamed : this.#resumeNamed);
|
|
460
|
+
}
|
|
461
|
+
#markDirty(key) {
|
|
462
|
+
(this.#dirty ??= /* @__PURE__ */ new Set()).add(key);
|
|
463
|
+
}
|
|
464
|
+
/** Pose les onglets LOCAUX d'une identité, en retenant la valeur du dernier lot. */
|
|
465
|
+
#setLocal(local, key, tabs) {
|
|
466
|
+
const before = local.get(key) ?? 0;
|
|
467
|
+
if (tabs === before) return;
|
|
468
|
+
const pending = this.#busBefore ??= /* @__PURE__ */ new Map();
|
|
469
|
+
if (!pending.has(key)) pending.set(key, before);
|
|
470
|
+
if (tabs === 0) local.delete(key);
|
|
471
|
+
else local.set(key, tabs);
|
|
472
|
+
}
|
|
473
|
+
#addLocalUnnamed(delta) {
|
|
474
|
+
if (this.#busUnnamedBefore < 0) this.#busUnnamedBefore = this.#localUnnamed;
|
|
475
|
+
this.#localUnnamed += delta;
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
/** Paires à plat `[identité, onglets, …]` d'une table — tableau pré-dimensionné, rempli en place. */
|
|
479
|
+
function pairsOf(table) {
|
|
480
|
+
const pairs = new Array(table.size * 2);
|
|
481
|
+
let i = 0;
|
|
482
|
+
for (const [key, tabs] of table) {
|
|
483
|
+
pairs[i++] = key;
|
|
484
|
+
pairs[i++] = tabs;
|
|
485
|
+
}
|
|
486
|
+
return pairs;
|
|
487
|
+
}
|
|
488
|
+
export { ChannelPresence, DEFAULT_PRESENCE_MAX_NAMED, PRESENCE_WINDOW_MS, presenceDefect, presenceSettingsOf, samePresence };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isRecord } from "../guards.js";
|
|
2
|
-
import { BootConfigurationError, isSimpleRouteTemplate } from "nodefony";
|
|
2
|
+
import { BootConfigurationError, bridgeServesVerb, isSimpleRouteTemplate } from "nodefony";
|
|
3
3
|
// nodefony/src/server/contractRoutes.ts
|
|
4
4
|
const BRIDGE_VERBS = /* @__PURE__ */ new Set([
|
|
5
5
|
"GET",
|
|
@@ -20,16 +20,6 @@ function normalizePath(path) {
|
|
|
20
20
|
return end === path.length ? path : path.slice(0, end);
|
|
21
21
|
}
|
|
22
22
|
/**
|
|
23
|
-
* La route sert-elle CE verbe par le pont ? Le pont résout sur le transport
|
|
24
|
-
* `WEBSOCKET` ; une écriture exige EN PLUS sa méthode logique
|
|
25
|
-
* (`Router.resolve`, `methodOverride`).
|
|
26
|
-
*/
|
|
27
|
-
function servesByBridge(methods, verb) {
|
|
28
|
-
if (methods === void 0) return true;
|
|
29
|
-
if (!methods.has("WEBSOCKET")) return false;
|
|
30
|
-
return verb === "GET" || methods.has(verb);
|
|
31
|
-
}
|
|
32
|
-
/**
|
|
33
23
|
* Lit la table `serverRoutes` qu'une classe de contrôleur déclare en statique —
|
|
34
24
|
* `null` quand elle n'en déclare pas (aucun contrôle).
|
|
35
25
|
*
|
|
@@ -73,8 +63,8 @@ function assertContractRoutes(owner, table, mounted) {
|
|
|
73
63
|
const path = normalizePath(rawPath);
|
|
74
64
|
const atPath = mounted.filter((r) => normalizePath(r.path) === path);
|
|
75
65
|
if (atPath.length === 0) throw new BootConfigurationError(`${where} : aucune route montée au chemin ${path}.`);
|
|
76
|
-
const route = atPath.find((r) =>
|
|
77
|
-
if (route === void 0) throw new BootConfigurationError(`${where} : la route ${path} ne sert pas ${verb} par la socket — ses méthodes doivent inclure ${verb === "GET" ? "" :
|
|
66
|
+
const route = atPath.find((r) => bridgeServesVerb(r.methods, verb));
|
|
67
|
+
if (route === void 0) throw new BootConfigurationError(`${where} : la route ${path} ne sert pas ${verb} par la socket — ses méthodes doivent inclure "${verb}" et "WEBSOCKET"${verb === "GET" ? " (ou « WEBSOCKET » seul)" : ""}.`);
|
|
78
68
|
for (const part of ROUTE_PARTS) {
|
|
79
69
|
const expected = declared[part];
|
|
80
70
|
const actual = route.schema?.[part];
|
|
@@ -50,20 +50,6 @@ function admittedTable(state, inbound) {
|
|
|
50
50
|
return admitted;
|
|
51
51
|
}
|
|
52
52
|
/**
|
|
53
|
-
* Admet les trames entrantes d'un canal que sa règle `allow` vient d'ouvrir à la connexion.
|
|
54
|
-
*
|
|
55
|
-
* Pas de plafond propre : le nombre de canaux ouverts l'est déjà. Sans effet si le
|
|
56
|
-
* canal n'accepte pas d'entrée client.
|
|
57
|
-
*
|
|
58
|
-
* @param state - état de la connexion.
|
|
59
|
-
* @param channel - canal ouvert par `allow`.
|
|
60
|
-
*/
|
|
61
|
-
function admitByRule(state, channel) {
|
|
62
|
-
const inbound = state.inbound;
|
|
63
|
-
if (inbound === null || !canAdmit(state, inbound, channel)) return;
|
|
64
|
-
admittedTable(state, inbound)[channel] = true;
|
|
65
|
-
}
|
|
66
|
-
/**
|
|
67
53
|
* Retire TOUTE admission d'un canal à la connexion — marque de sa règle ET verdict retenu du verrou — quand le serveur l'en éjecte ou le termine (ADR-0022 §6 décision 3).
|
|
68
54
|
*
|
|
69
55
|
* Plus fort que {@link releaseRuleAdmission}, qui garde le verdict du verrou :
|
|
@@ -85,29 +71,31 @@ function revokeAdmission(state, channel) {
|
|
|
85
71
|
if (admitted?.[channel] === true) admitted[channel] = false;
|
|
86
72
|
}
|
|
87
73
|
/**
|
|
88
|
-
* Retire l'admission d'un canal à la fin de son abonnement — celle
|
|
89
|
-
*
|
|
90
|
-
* l'identité et
|
|
74
|
+
* Retire l'admission d'un canal à la fin de son abonnement quand elle en dépendait — celle d'un canal à `allow`, dont l'abonnement est la condition.
|
|
75
|
+
*
|
|
76
|
+
* Un verdict du verrou sur un canal SANS `allow` ne tient qu'à l'identité et
|
|
77
|
+
* survit au désabonnement ; sur un canal à `allow`, il ne valait que le temps
|
|
78
|
+
* de l'abonnement (ADR-0022 §6, décisions 1 et 1 quater).
|
|
91
79
|
*
|
|
92
80
|
* @param state - état de la connexion.
|
|
93
81
|
* @param channel - canal quitté.
|
|
82
|
+
* @param hub - hub qui porte les règles d'admission.
|
|
94
83
|
*/
|
|
95
|
-
function releaseRuleAdmission(state, channel) {
|
|
96
|
-
|
|
97
|
-
if (
|
|
98
|
-
if (state.retained?.includes(channel) === true) return;
|
|
99
|
-
admitted[channel] = false;
|
|
84
|
+
function releaseRuleAdmission(state, channel, hub) {
|
|
85
|
+
if (state.admitted?.[channel] !== true) return;
|
|
86
|
+
if (hub.resolveChannelAdmission(channel) !== null) revokeAdmission(state, channel);
|
|
100
87
|
}
|
|
101
88
|
/**
|
|
102
|
-
* Retient le « oui » du verrou pour un canal entrant
|
|
89
|
+
* Retient le « oui » du verrou pour un canal entrant (jamais un refus).
|
|
103
90
|
*
|
|
104
|
-
* Au plus {@link MAX_RETAINED_FRAME_VERDICTS} par connexion
|
|
105
|
-
*
|
|
106
|
-
*
|
|
91
|
+
* Au plus {@link MAX_RETAINED_FRAME_VERDICTS} par connexion, sauf sur un canal
|
|
92
|
+
* à `allow` (`bySubscription`) : ceux-là sont déjà bornés par le plafond de
|
|
93
|
+
* canaux ouverts. La première retenue inscrit la connexion auprès du hub, qui
|
|
94
|
+
* pourra la vider si une politique arrive après coup.
|
|
107
95
|
*/
|
|
108
|
-
function retainVerdict(state, method, hub) {
|
|
96
|
+
function retainVerdict(state, method, hub, bySubscription) {
|
|
109
97
|
const retained = state.retained;
|
|
110
|
-
if (retained !== null && retained.length >= 64) return;
|
|
98
|
+
if (!bySubscription && retained !== null && retained.length >= 64) return;
|
|
111
99
|
const inbound = state.inbound;
|
|
112
100
|
if (inbound === null || !canAdmit(state, inbound, method)) return;
|
|
113
101
|
admittedTable(state, inbound)[method] = true;
|
|
@@ -118,8 +106,9 @@ function retainVerdict(state, method, hub) {
|
|
|
118
106
|
}
|
|
119
107
|
/**
|
|
120
108
|
* Oublie les verdicts du verrou retenus par la connexion — leur politique vient
|
|
121
|
-
* de changer.
|
|
122
|
-
*
|
|
109
|
+
* de changer. Ceux des canaux à `allow` compris : une écriture inscrite après
|
|
110
|
+
* coup doit être jugée à la trame suivante, abonnement ou pas. `allow`, lui,
|
|
111
|
+
* n'est pas redemandé — l'abonnement reste.
|
|
123
112
|
*
|
|
124
113
|
* @param state - état de la connexion.
|
|
125
114
|
*/
|
|
@@ -138,8 +127,10 @@ function dropRetainedVerdicts(state) {
|
|
|
138
127
|
* - `ping` et `unsubscribe` passent sans règle ;
|
|
139
128
|
* - `subscribe` passe par le verrou (`allow` est jugé après, à l'ouverture) ;
|
|
140
129
|
* - un nom hérité du prototype de la table entrante est refusé ;
|
|
141
|
-
* - un canal entrant à `allow`
|
|
142
|
-
*
|
|
130
|
+
* - un canal entrant à `allow` exige l'abonnement de CETTE connexion (`allow`
|
|
131
|
+
* n'est jamais rappelé), puis passe par le verrou comme les autres ;
|
|
132
|
+
* - un canal entrant passe par le verrou — lecture ET écriture —, dont le « oui »
|
|
133
|
+
* est retenu ;
|
|
143
134
|
* - toute autre notification passe par le verrou.
|
|
144
135
|
*
|
|
145
136
|
* Le premier test du contrôleur (`admitted[method] === true`) a déjà écarté les trames
|
|
@@ -160,9 +151,10 @@ function gateFrame(state, frame, hub) {
|
|
|
160
151
|
if (method in inbound) return false;
|
|
161
152
|
return hub.runAuthorizer(frame, state.peer);
|
|
162
153
|
}
|
|
163
|
-
|
|
154
|
+
const bySubscription = hub.resolveChannelAdmission(method) !== null;
|
|
155
|
+
if (bySubscription && !state.channels.has(method)) return false;
|
|
164
156
|
if (!hub.runAuthorizer(frame, state.peer)) return false;
|
|
165
|
-
retainVerdict(state, method, hub);
|
|
157
|
+
retainVerdict(state, method, hub, bySubscription);
|
|
166
158
|
return true;
|
|
167
159
|
}
|
|
168
|
-
export { MAX_RETAINED_FRAME_VERDICTS,
|
|
160
|
+
export { MAX_RETAINED_FRAME_VERDICTS, dropRetainedVerdicts, gateFrame, releaseRuleAdmission, revokeAdmission };
|