@nodefony/realtime 10.0.0-beta.6 → 10.0.0-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +5 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +15 -3
  4. package/dist/nodefony/decorators/realtimeDecorators.js +231 -39
  5. package/dist/nodefony/src/backplane/RedisBackplane.js +33 -40
  6. package/dist/nodefony/src/backplane/controlOrder.js +34 -5
  7. package/dist/nodefony/src/backplane/envelope.js +422 -45
  8. package/dist/nodefony/src/backplane/ingress.js +93 -0
  9. package/dist/nodefony/src/backplane/presenceMessage.js +205 -0
  10. package/dist/nodefony/src/backplane/publishQueue.js +97 -3
  11. package/dist/nodefony/src/server/RealtimeConnection.js +31 -6
  12. package/dist/nodefony/src/server/RealtimeController.js +87 -44
  13. package/dist/nodefony/src/server/RealtimeHub.js +631 -127
  14. package/dist/nodefony/src/server/admissionBudget.js +8 -8
  15. package/dist/nodefony/src/server/channelPolicyRegistry.js +35 -13
  16. package/dist/nodefony/src/server/channelPolicyResolution.js +191 -0
  17. package/dist/nodefony/src/server/channelPresence.js +488 -0
  18. package/dist/nodefony/src/server/contractRoutes.js +3 -13
  19. package/dist/nodefony/src/server/frameAdmission.js +27 -35
  20. package/dist/nodefony/src/server/presenceBus.js +486 -0
  21. package/dist/nodefony/src/server/realtimeShutdown.js +31 -0
  22. package/dist/nodefony/src/server/remotePresence.js +541 -0
  23. package/dist/nodefony/src/server/subscribedAck.js +3 -0
  24. package/dist/nodefony/src/service/RealtimeService.js +9 -0
  25. package/dist/nodefony/testing/backplaneConformance.js +79 -3
  26. package/dist/nodefony/testing/index.js +22 -8
  27. package/dist/nodefony/testing/presenceClient.js +54 -0
  28. package/dist/types/index.d.ts +6 -1
  29. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +117 -16
  30. package/dist/types/nodefony/interfaces/IBackplane.d.ts +12 -1
  31. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +10 -2
  32. package/dist/types/nodefony/src/backplane/controlOrder.d.ts +23 -0
  33. package/dist/types/nodefony/src/backplane/envelope.d.ts +50 -7
  34. package/dist/types/nodefony/src/backplane/ingress.d.ts +48 -0
  35. package/dist/types/nodefony/src/backplane/presenceMessage.d.ts +150 -0
  36. package/dist/types/nodefony/src/backplane/publishQueue.d.ts +25 -1
  37. package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +3 -0
  38. package/dist/types/nodefony/src/server/RealtimeController.d.ts +19 -1
  39. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +139 -30
  40. package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +9 -2
  41. package/dist/types/nodefony/src/server/channelPolicyResolution.d.ts +112 -0
  42. package/dist/types/nodefony/src/server/channelPresence.d.ts +155 -0
  43. package/dist/types/nodefony/src/server/contractRoutes.d.ts +1 -1
  44. package/dist/types/nodefony/src/server/frameAdmission.d.ts +14 -18
  45. package/dist/types/nodefony/src/server/presenceBus.d.ts +112 -0
  46. package/dist/types/nodefony/src/server/realtimeShutdown.d.ts +21 -0
  47. package/dist/types/nodefony/src/server/remotePresence.d.ts +151 -0
  48. package/dist/types/nodefony/src/server/subscribedAck.d.ts +3 -0
  49. package/dist/types/nodefony/src/service/RealtimeService.d.ts +8 -1
  50. package/dist/types/nodefony/testing/backplaneConformance.d.ts +14 -2
  51. package/dist/types/nodefony/testing/index.d.ts +1 -1
  52. package/dist/types/nodefony/testing/presenceClient.d.ts +20 -0
  53. package/docs/actions.md +1 -0
  54. package/docs/architecture.md +21 -6
  55. package/docs/canaux-a-membres.md +13 -3
  56. package/docs/cas-d-usage.md +4 -3
  57. package/docs/configuration.md +38 -2
  58. package/docs/cookbook-chat.md +400 -152
  59. package/docs/observabilite.md +28 -9
  60. package/docs/protocole.md +10 -10
  61. package/docs/securite.md +64 -10
  62. package/package.json +12 -12
@@ -0,0 +1,541 @@
1
+ import "../backplane/presenceMessage.js";
2
+ import { classifyNumberedChange } from "nodefony";
3
+ // nodefony/src/server/remotePresence.ts
4
+ /** Ce qu'un pod annonce sur UN canal : identités nommées → onglets, et connexions non nommées. */
5
+ var RemoteChannel = class {
6
+ members = /* @__PURE__ */ new Map();
7
+ unnamed = 0;
8
+ };
9
+ /**
10
+ * Ce que CE pod sait d'un pair — une vie (`originId`, incarnation), ses numéros, son dernier signe de vie, ses tables par canal.
11
+ *
12
+ * Classe à forme fixe : un pair lu à chaque message, des champs jamais ajoutés
13
+ * après coup.
14
+ */
15
+ var RemotePod = class {
16
+ origin;
17
+ incarnation;
18
+ /** `seq` du dernier lot appliqué ; `null` = trou (vue à resynchroniser). */
19
+ reference = null;
20
+ /** Plus haut `seq` vu sur le canal des lots, demandes et réponses. */
21
+ messageSeq = 0;
22
+ /**
23
+ * Plus haut `seq` vu sur le canal de la preuve de vie — relevé À PART : la
24
+ * preuve de vie passe hors quota de la file d'envoi et peut doubler un lot
25
+ * encore en attente ; un relevé commun jetterait ce lot comme un rejeu.
26
+ */
27
+ aliveSeq = 0;
28
+ /** Intervalle ANNONCÉ par le pair (ms). */
29
+ interval;
30
+ /** Dernier signe de vie, horloge monotone LOCALE (ms). */
31
+ lastSeen;
32
+ /** Instant de la dernière demande d'état pendant un trou ; `-Infinity` = à faire. */
33
+ requestedAt = Number.NEGATIVE_INFINITY;
34
+ /** Canaux nommés par la demande en cours — les réponses ne ferment le trou que si elles les couvrent. */
35
+ asked = null;
36
+ /**
37
+ * Canaux nommés pendant le trou par les réponses au même `last` — elles décrivent le même état, leurs couvertures s'additionnent ; `null` hors trou.
38
+ */
39
+ covered = null;
40
+ /** `last` des réponses relevées dans {@link RemotePod.covered}. */
41
+ coveredLast = 0;
42
+ /** Pierre tombale : arrêt propre reçu, plus rien de cette vie ne s'applique. */
43
+ ended = false;
44
+ /** Tables par canal servi ; `null` tant que le pair n'a présenté personne ici. */
45
+ channels = null;
46
+ partSeq = 0;
47
+ partCount = 0;
48
+ partLast = 0;
49
+ parts = null;
50
+ constructor(origin, incarnation, interval, now) {
51
+ this.origin = origin;
52
+ this.incarnation = incarnation;
53
+ this.interval = interval;
54
+ this.lastSeen = now;
55
+ }
56
+ };
57
+ /** Entrées d'une table, retirées de l'agrégat quand le pod PART (grâce du canal pour les identités nommées). */
58
+ function withdraw(table, aggregate, now) {
59
+ for (const [key, tabs] of table.members) aggregate.remoteLeave(key, tabs, now);
60
+ if (table.unnamed !== 0) aggregate.remoteUnnamed(-table.unnamed);
61
+ }
62
+ /**
63
+ * Pose les paires à plat `[identité, onglets, …]` dans `table`, EN PLACE — une lecture par membre, l'agrégat n'est appelé que si le nombre d'onglets change.
64
+ */
65
+ function upsertPairs(table, pairs, aggregate) {
66
+ const members = table.members;
67
+ for (let i = 0; i < pairs.length; i += 2) {
68
+ const key = pairs[i];
69
+ const tabs = pairs[i + 1];
70
+ if (typeof key !== "string" || typeof tabs !== "number") continue;
71
+ const old = members.get(key);
72
+ if (old === tabs) continue;
73
+ members.set(key, tabs);
74
+ aggregate.remoteTabs(key, old === void 0 ? tabs : tabs - old);
75
+ }
76
+ }
77
+ /**
78
+ * Retire de `table` les identités qu'une réponse d'état ne nomme plus — chemin RARE : il ne tourne que si la table compte plus d'identités que la réponse n'en nomme.
79
+ *
80
+ * Un émetteur conforme ne nomme jamais deux fois la même identité ; un qui le
81
+ * ferait (il détient alors le secret du bus) peut laisser une entrée jusqu'à
82
+ * l'état suivant ou à l'oubli — rien de plus que ce qu'il peut déjà forger.
83
+ */
84
+ function dropAbsentIn(table, channel, parts, aggregate) {
85
+ const named = /* @__PURE__ */ new Set();
86
+ for (const entries of parts) for (const entry of entries) {
87
+ if (entry.channel !== channel) continue;
88
+ const pairs = entry.members;
89
+ for (let i = 0; i < pairs.length; i += 2) {
90
+ const key = pairs[i];
91
+ if (typeof key === "string") named.add(key);
92
+ }
93
+ }
94
+ const members = table.members;
95
+ for (const [key, tabs] of members) {
96
+ if (named.has(key)) continue;
97
+ members.delete(key);
98
+ aggregate.remoteTabs(key, -tabs);
99
+ }
100
+ }
101
+ /**
102
+ * Vue de la présence des PAIRS, rangée par pod puis par canal (ADR-0025 P2) — la règle répartie, sans horloge, sans minuteur, sans bus.
103
+ *
104
+ * Elle applique ce que le bus apporte — lots chaînés par `seq`/`prev`, réponses
105
+ * d'état en parts appliquées d'un bloc, preuves de vie —, détecte les trous
106
+ * (règle pure `classifyNumberedChange`, RFC 5263 §4.5), départage les vies d'un
107
+ * même `originId` par l'incarnation, oublie un pair muet au bout de `factor`
108
+ * intervalles ANNONCÉS, et ne transmet aux agrégats que des DELTAS. L'instant
109
+ * (`now`, horloge monotone) est passé à chaque appel : la règle se prouve à
110
+ * l'horloge simulée, au compte exact.
111
+ *
112
+ * Coût : un canal non servi ne stocke rien ; oublier un pod libère ses tables
113
+ * d'un coup ; un message ne parcourt que ses propres entrées. Aucun parcours de
114
+ * tous les pods hors de {@link RemotePresence.expire},
115
+ * {@link RemotePresence.nextDeadline}, {@link RemotePresence.rearm} et
116
+ * {@link RemotePresence.takeRequests}, appelés au tic.
117
+ */
118
+ var RemotePresence = class {
119
+ #lookup;
120
+ #factor;
121
+ #interval;
122
+ #pods = /* @__PURE__ */ new Map();
123
+ #gaps = 0;
124
+ #rejectedTotal = 0;
125
+ #staleTotal = 0;
126
+ #forgottenTotal = 0;
127
+ #gapTotal = 0;
128
+ #endedTotal = 0;
129
+ #closer = Number.POSITIVE_INFINITY;
130
+ /**
131
+ * @throws RangeError si `factor` < 2 (Phoenix Tracker exige le même plancher :
132
+ * sous deux intervalles, une seule preuve de vie retardée fait oublier un pair
133
+ * vivant) ou si `interval` n'est pas un entier > 0.
134
+ */
135
+ constructor(options) {
136
+ if (!Number.isSafeInteger(options.factor) || options.factor < 2) throw new RangeError(`présence : le délai d'oubli doit valoir au moins 2 intervalles (reçu ${String(options.factor)})`);
137
+ if (!Number.isSafeInteger(options.interval) || options.interval <= 0) throw new RangeError(`présence : l'intervalle doit être un entier de millisecondes > 0 (reçu ${String(options.interval)})`);
138
+ this.#lookup = options.lookup;
139
+ this.#factor = options.factor;
140
+ this.#interval = options.interval;
141
+ }
142
+ /** Pairs connus (pierres tombales comprises). */
143
+ get size() {
144
+ return this.#pods.size;
145
+ }
146
+ /** Pairs dont la vue est en trou — la présence servie est alors `partial`. */
147
+ get gaps() {
148
+ return this.#gaps;
149
+ }
150
+ /** Messages refusés : canal non déclaré à présence ici, plafond dépassé (`presenceRejectedTotal`). */
151
+ get rejectedTotal() {
152
+ return this.#rejectedTotal;
153
+ }
154
+ /** Messages déjà vus ou d'une vie plus ancienne (`presenceStaleDiscardedTotal`). */
155
+ get staleTotal() {
156
+ return this.#staleTotal;
157
+ }
158
+ /** Pairs oubliés faute de signe de vie. */
159
+ get forgottenTotal() {
160
+ return this.#forgottenTotal;
161
+ }
162
+ /** Arrêts propres reçus (preuve de vie `final`) — présents retirés sans attendre l'oubli. */
163
+ get endedTotal() {
164
+ return this.#endedTotal;
165
+ }
166
+ /** Trous ouverts depuis le début (lot perdu, pair inconnu, vie neuve). */
167
+ get gapTotal() {
168
+ return this.#gapTotal;
169
+ }
170
+ /**
171
+ * Compte un message de présence REFUSÉ par le lecteur à liste fermée (forme invalide) — même compteur que les refus de la vue.
172
+ */
173
+ countRejected() {
174
+ this.#rejectedTotal += 1;
175
+ }
176
+ /**
177
+ * Applique un message du canal des lots, demandes et réponses.
178
+ *
179
+ * @param origin - `originId` de l'émetteur, lu dans l'enveloppe.
180
+ * @param message - message validé par `readPresenceMessage`.
181
+ * @param now - instant monotone local (ms).
182
+ * @returns la demande d'état si le message en est une, FRAÎCHE, à servir par
183
+ * le répondeur ; `null` sinon.
184
+ */
185
+ receive(origin, message, now) {
186
+ const pod = this.#podFor(origin, message.incarnation, message.op === "batch" && message.prev === 0, now);
187
+ if (pod === null) return null;
188
+ if (message.seq <= pod.messageSeq) {
189
+ this.#staleTotal += 1;
190
+ return null;
191
+ }
192
+ pod.messageSeq = message.seq;
193
+ pod.lastSeen = now;
194
+ if (message.op === "batch") this.#batch(pod, message);
195
+ else if (message.op === "state") this.#part(pod, message);
196
+ else return message;
197
+ return null;
198
+ }
199
+ /**
200
+ * Applique une preuve de vie.
201
+ *
202
+ * @param origin - `originId` de l'émetteur.
203
+ * @param alive - preuve de vie validée par `readPresenceAlive`.
204
+ * @param now - instant monotone local (ms).
205
+ */
206
+ receiveAlive(origin, alive, now) {
207
+ const pod = this.#podFor(origin, alive.incarnation, alive.last === 0, now);
208
+ if (pod === null) return;
209
+ if (alive.seq <= pod.aliveSeq) {
210
+ this.#staleTotal += 1;
211
+ return;
212
+ }
213
+ pod.aliveSeq = alive.seq;
214
+ pod.lastSeen = now;
215
+ pod.interval = alive.interval;
216
+ this.#closerAt(now + this.#factor * alive.interval);
217
+ if (alive.final) {
218
+ this.#bury(pod, now);
219
+ return;
220
+ }
221
+ const reference = pod.reference;
222
+ if (reference === null) {
223
+ if (alive.last === 0) this.#close(pod, 0);
224
+ } else if (alive.last > reference) this.#openGap(pod);
225
+ }
226
+ /**
227
+ * Pairs à qui demander leur état MAINTENANT — en trou, et pas encore redemandés depuis un intervalle local ; ils sont marqués demandés.
228
+ *
229
+ * @param now - instant monotone local (ms).
230
+ * @param served - canaux à présence servis ici, nommés par la demande ; une
231
+ * réponse ne ferme le trou que si elle les couvre tous.
232
+ * @returns les `originId` à viser (`to`), `null` s'il n'y en a pas.
233
+ */
234
+ takeRequests(now, served) {
235
+ if (this.#gaps === 0) return null;
236
+ let out = null;
237
+ for (const pod of this.#pods.values()) {
238
+ if (pod.reference !== null || pod.ended) continue;
239
+ if (now - pod.requestedAt < this.#interval) continue;
240
+ pod.requestedAt = now;
241
+ pod.asked = served;
242
+ (out ??= []).push(pod.origin);
243
+ }
244
+ return out;
245
+ }
246
+ /**
247
+ * Oublie les pairs muets depuis `factor` intervalles ANNONCÉS, et retire les pierres tombales échues.
248
+ *
249
+ * @param now - instant monotone local (ms).
250
+ * @returns le nombre de pairs VIVANTS oubliés (hors pierres tombales).
251
+ */
252
+ expire(now) {
253
+ let forgotten = 0;
254
+ for (const pod of this.#pods.values()) {
255
+ if (now - pod.lastSeen < this.#factor * pod.interval) continue;
256
+ if (!pod.ended) {
257
+ this.#withdrawPod(pod, now);
258
+ forgotten += 1;
259
+ }
260
+ this.#pods.delete(pod.origin);
261
+ }
262
+ this.#forgottenTotal += forgotten;
263
+ return forgotten;
264
+ }
265
+ /**
266
+ * Plus proche échéance d'oubli APPARUE depuis le dernier appel — celle d'une preuve de vie, dont l'intervalle annoncé peut être plus court que le local ; `Infinity` sinon. O(1), relevée à chaque réception pour que le porteur avance son tic sans attendre le suivant.
267
+ *
268
+ * Tout autre message recule l'échéance, et celle d'un pair neuf tombe au
269
+ * moins deux intervalles LOCAUX plus loin : le tic suivant la relève à temps.
270
+ * {@link RemotePresence.nextDeadline} reste la valeur exacte, recalculée au tic.
271
+ */
272
+ takeCloserDeadline() {
273
+ const at = this.#closer;
274
+ this.#closer = Number.POSITIVE_INFINITY;
275
+ return at;
276
+ }
277
+ /** Prochaine échéance d'oubli (ms monotone), `Infinity` sans pair. */
278
+ nextDeadline() {
279
+ let next = Number.POSITIVE_INFINITY;
280
+ for (const pod of this.#pods.values()) {
281
+ const deadline = pod.lastSeen + this.#factor * pod.interval;
282
+ if (deadline < next) next = deadline;
283
+ }
284
+ return next;
285
+ }
286
+ /**
287
+ * Réarme le délai d'oubli de TOUS les pairs — après un tic en retard (récepteur gelé) ou au retour de l'écho (bus revenu).
288
+ *
289
+ * Sans lui, un pod bloqué 20 s oublierait d'un coup tous les pairs vivants
290
+ * dont les preuves de vie attendent encore dans son tampon : les minuteurs
291
+ * passent avant la lecture des sockets (Lifeguard).
292
+ *
293
+ * @param now - instant monotone local (ms).
294
+ */
295
+ rearm(now) {
296
+ for (const pod of this.#pods.values()) pod.lastSeen = now;
297
+ }
298
+ /**
299
+ * Retire un canal de toutes les vues — il n'est plus servi ici (dernier abonné local parti). L'agrégat n'est pas notifié : il disparaît avec le canal.
300
+ */
301
+ dropChannel(channel) {
302
+ for (const pod of this.#pods.values()) {
303
+ const channels = pod.channels;
304
+ if (channels === null) continue;
305
+ channels.delete(channel);
306
+ if (channels.size === 0) pod.channels = null;
307
+ }
308
+ }
309
+ /** Oublie tout, sans notifier les agrégats (arrêt, réinitialisation). */
310
+ clear() {
311
+ this.#pods.clear();
312
+ this.#gaps = 0;
313
+ this.#closer = Number.POSITIVE_INFINITY;
314
+ }
315
+ /**
316
+ * Le pair `origin` dans la vie `incarnation`, créé s'il est inconnu ; `null` si le message est d'une vie plus ancienne ou enterrée (compté).
317
+ *
318
+ * @param empty - le message prouve que le pair n'a jamais émis de lot (vue
319
+ * vide connue sans demande).
320
+ */
321
+ #podFor(origin, incarnation, empty, now) {
322
+ let pod = this.#pods.get(origin);
323
+ if (pod !== void 0) {
324
+ if (incarnation === pod.incarnation) {
325
+ if (!pod.ended) return pod;
326
+ this.#staleTotal += 1;
327
+ return null;
328
+ }
329
+ if (incarnation < pod.incarnation) {
330
+ this.#staleTotal += 1;
331
+ return null;
332
+ }
333
+ if (!pod.ended) this.#withdrawPod(pod, now);
334
+ this.#pods.delete(origin);
335
+ }
336
+ pod = new RemotePod(origin, incarnation, this.#interval, now);
337
+ this.#pods.set(origin, pod);
338
+ if (empty) pod.reference = 0;
339
+ else this.#enterGap(pod);
340
+ return pod;
341
+ }
342
+ /** Applique un lot : seulement au numéro attendu, sinon trou (le numéro est retenu). */
343
+ #batch(pod, batch) {
344
+ const reference = pod.reference;
345
+ if (reference === null) return;
346
+ if (classifyNumberedChange(reference, batch.seq, batch.prev) !== "applied") {
347
+ this.#openGap(pod);
348
+ return;
349
+ }
350
+ pod.reference = batch.seq;
351
+ let rejected = false;
352
+ for (const entry of batch.channels) {
353
+ const aggregate = this.#lookup(entry.channel);
354
+ if (aggregate === null) continue;
355
+ if (aggregate === false) {
356
+ rejected = true;
357
+ continue;
358
+ }
359
+ const channels = pod.channels ??= /* @__PURE__ */ new Map();
360
+ let table = channels.get(entry.channel);
361
+ const set = entry.set;
362
+ if (set !== null && (table?.members.size ?? 0) + set.length / 2 > 1e5) {
363
+ rejected = true;
364
+ continue;
365
+ }
366
+ if (table === void 0) {
367
+ table = new RemoteChannel();
368
+ channels.set(entry.channel, table);
369
+ }
370
+ const members = table.members;
371
+ if (set !== null) for (let i = 0; i < set.length; i += 2) {
372
+ const key = set[i];
373
+ const tabs = set[i + 1];
374
+ if (typeof key !== "string" || typeof tabs !== "number") continue;
375
+ const delta = tabs - (members.get(key) ?? 0);
376
+ members.set(key, tabs);
377
+ if (delta !== 0) aggregate.remoteTabs(key, delta);
378
+ }
379
+ const gone = entry.gone;
380
+ if (gone !== null) for (const key of gone) {
381
+ const tabs = members.get(key);
382
+ if (tabs === void 0) continue;
383
+ members.delete(key);
384
+ aggregate.remoteTabs(key, -tabs);
385
+ }
386
+ const unnamed = entry.unnamed - table.unnamed;
387
+ if (unnamed !== 0) {
388
+ table.unnamed = entry.unnamed;
389
+ aggregate.remoteUnnamed(unnamed);
390
+ }
391
+ if (members.size === 0 && table.unnamed === 0) {
392
+ channels.delete(entry.channel);
393
+ if (channels.size === 0) pod.channels = null;
394
+ }
395
+ }
396
+ if (rejected) this.#rejectedTotal += 1;
397
+ }
398
+ /** Range une part de réponse ; la réponse s'applique d'un bloc à sa dernière part. */
399
+ #part(pod, part) {
400
+ let parts = pod.parts;
401
+ if (part.i === 0) {
402
+ pod.partSeq = part.seq;
403
+ pod.partCount = part.n;
404
+ pod.partLast = part.last;
405
+ parts = pod.parts = [part.channels];
406
+ } else {
407
+ if (parts === null || part.i !== parts.length || part.seq !== pod.partSeq + part.i || part.n !== pod.partCount || part.last !== pod.partLast) {
408
+ pod.parts = null;
409
+ return;
410
+ }
411
+ parts.push(part.channels);
412
+ }
413
+ if (parts.length < pod.partCount) return;
414
+ pod.parts = null;
415
+ this.#applyState(pod, parts, pod.partLast);
416
+ }
417
+ /** Applique une réponse complète : remplace la vue de chaque canal servi qu'elle nomme, puis ferme le trou si elle couvre la demande. */
418
+ #applyState(pod, parts, last) {
419
+ const reference = pod.reference;
420
+ if (reference !== null && last < reference) return;
421
+ let pending = null;
422
+ let rejected = false;
423
+ for (const entries of parts) for (const entry of entries) {
424
+ let state = pending?.get(entry.channel);
425
+ if (state === void 0) {
426
+ const aggregate = this.#lookup(entry.channel);
427
+ if (aggregate === null) continue;
428
+ if (aggregate === false) {
429
+ rejected = true;
430
+ continue;
431
+ }
432
+ state = {
433
+ aggregate,
434
+ table: null,
435
+ count: 0,
436
+ unnamed: 0
437
+ };
438
+ (pending ??= /* @__PURE__ */ new Map()).set(entry.channel, state);
439
+ }
440
+ state.count += entry.members.length >> 1;
441
+ state.unnamed = entry.unnamed;
442
+ }
443
+ if (pending !== null) {
444
+ const channels = pod.channels ??= /* @__PURE__ */ new Map();
445
+ for (const [channel, state] of pending) {
446
+ if (state.count > 1e5) {
447
+ rejected = true;
448
+ continue;
449
+ }
450
+ let table = channels.get(channel);
451
+ if (table === void 0) {
452
+ table = new RemoteChannel();
453
+ channels.set(channel, table);
454
+ }
455
+ state.table = table;
456
+ }
457
+ for (const entries of parts) for (const entry of entries) {
458
+ const state = pending.get(entry.channel);
459
+ if (state?.table == null) continue;
460
+ upsertPairs(state.table, entry.members, state.aggregate);
461
+ }
462
+ for (const [channel, state] of pending) {
463
+ const table = state.table;
464
+ if (table === null) continue;
465
+ if (table.members.size > state.count) dropAbsentIn(table, channel, parts, state.aggregate);
466
+ const unnamed = state.unnamed - table.unnamed;
467
+ if (unnamed !== 0) {
468
+ table.unnamed = state.unnamed;
469
+ state.aggregate.remoteUnnamed(unnamed);
470
+ }
471
+ if (table.members.size === 0 && table.unnamed === 0) channels.delete(channel);
472
+ }
473
+ if (channels.size === 0) pod.channels = null;
474
+ }
475
+ if (rejected) this.#rejectedTotal += 1;
476
+ if (reference === null) {
477
+ if (covers(pod, parts, last)) this.#close(pod, last);
478
+ } else if (last > reference) this.#openGap(pod);
479
+ }
480
+ #enterGap(pod) {
481
+ pod.reference = null;
482
+ pod.requestedAt = Number.NEGATIVE_INFINITY;
483
+ pod.asked = null;
484
+ this.#gaps += 1;
485
+ this.#gapTotal += 1;
486
+ }
487
+ #openGap(pod) {
488
+ if (pod.reference !== null) this.#enterGap(pod);
489
+ }
490
+ #close(pod, last) {
491
+ pod.reference = last;
492
+ pod.asked = null;
493
+ pod.covered = null;
494
+ this.#gaps -= 1;
495
+ }
496
+ /** Une échéance d'oubli neuve : retenue si elle précède celles déjà annoncées au porteur. */
497
+ #closerAt(deadline) {
498
+ if (deadline < this.#closer) this.#closer = deadline;
499
+ }
500
+ /** Retire toutes les contributions d'un pod des agrégats. */
501
+ #withdrawPod(pod, now) {
502
+ if (pod.reference === null) this.#gaps -= 1;
503
+ const channels = pod.channels;
504
+ pod.channels = null;
505
+ pod.parts = null;
506
+ pod.covered = null;
507
+ if (channels === null) return;
508
+ for (const [channel, table] of channels) {
509
+ const aggregate = this.#lookup(channel);
510
+ if (aggregate !== null && aggregate !== false) withdraw(table, aggregate, now);
511
+ }
512
+ }
513
+ /** Arrêt propre : présents retirés aussitôt, vie enterrée jusqu'au délai d'oubli. */
514
+ #bury(pod, now) {
515
+ this.#withdrawPod(pod, now);
516
+ this.#endedTotal += 1;
517
+ pod.ended = true;
518
+ pod.reference = 0;
519
+ }
520
+ };
521
+ /**
522
+ * Les réponses reçues pendant le trou au même `last` nomment-elles, ENSEMBLE, tous les canaux demandés — servis ou non depuis ? Chemin froid : seulement pendant un trou.
523
+ *
524
+ * Deux réponses au même `last` décrivent l'état du pair au même lot : leurs
525
+ * canaux s'additionnent. Un `last` différent repart de zéro — des lots ont
526
+ * changé l'état entre les deux, et ces lots, ignorés pendant le trou, auraient
527
+ * pu toucher les canaux de la première.
528
+ */
529
+ function covers(pod, parts, last) {
530
+ let named = pod.covered;
531
+ if (named === null || pod.coveredLast !== last) {
532
+ named = pod.covered = /* @__PURE__ */ new Set();
533
+ pod.coveredLast = last;
534
+ }
535
+ for (const entries of parts) for (const entry of entries) named.add(entry.channel);
536
+ const asked = pod.asked;
537
+ if (asked === null) return false;
538
+ for (const channel of asked) if (!named.has(channel)) return false;
539
+ return true;
540
+ }
541
+ export { RemotePresence };
@@ -20,6 +20,9 @@ const SUBSCRIBED_FRAME_PREFIX = "{\"jsonrpc\":\"2.0\",\"method\":\"realtime:subs
20
20
  /**
21
21
  * Sérialise la notification `realtime:subscribed { channel }`.
22
22
  *
23
+ * Connexion TEXTE seulement : sous codec binaire, l'accusé passe par
24
+ * `peer.notify` (`RealtimeController.ts#RealtimeController.acknowledgeSubscription`).
25
+ *
23
26
  * @param channel - le canal acquitté (entrée réseau : échappé par `JSON.stringify`).
24
27
  * @returns la trame JSON-RPC 2.0 prête à écrire sur le transport.
25
28
  */
@@ -248,6 +248,15 @@ var RealtimeService = class extends Service {
248
248
  return this.getHub().resolveChannelPolicy(channel);
249
249
  }
250
250
  /**
251
+ * **Seam #1b** — lecture ET écriture distincte d'un nom, en une résolution (ADR-0022 §6, décision 1 quater). Lu par `@nodefony/security` sur chaque trame jugée.
252
+ *
253
+ * @param channel - nom du canal, de la méthode entrante ou de l'action.
254
+ * @returns les deux politiques, ou `null` si aucune ne couvre ce nom.
255
+ */
256
+ resolveChannelPolicies(channel) {
257
+ return this.getHub().resolveChannelPolicies(channel);
258
+ }
259
+ /**
251
260
  * Renvoie le token associé à un peer (posé au handshake par le hub). Jamais
252
261
  * `null` (Zero Trust : `ANONYMOUS_REALTIME_TOKEN` si aucun authenticator n'a
253
262
  * capturé la connexion). Lookup O(1) via WeakMap interne. Branchement P6 :
@@ -1,6 +1,8 @@
1
1
  import { RealtimeHub } from "../src/server/RealtimeHub.js";
2
2
  import { readControlOrder } from "../src/backplane/controlOrder.js";
3
- import { PLATFORM_BUS_CHANNELS, PLATFORM_CHANNELS } from "nodefony";
3
+ import "../src/backplane/publishQueue.js";
4
+ import { presenceClient, presenceView } from "./presenceClient.js";
5
+ import { PLATFORM_BUS_CHANNELS, PLATFORM_CHANNELS, ownField } from "nodefony";
4
6
  import { randomBytes, randomUUID } from "node:crypto";
5
7
  import assert from "node:assert/strict";
6
8
  // nodefony/testing/backplaneConformance.ts
@@ -36,7 +38,10 @@ const BACKPLANE_CONFORMANCE_CASES = {
36
38
  noReplay: "pas de rejeu : un pair démarré après une publication ne la reçoit pas",
37
39
  seal: "sceau : un message non scellé ou mal scellé est refusé",
38
40
  control: "ordres de contrôle : le canal de contrôle traverse intact",
41
+ controlUnderLoad: "ordre sous saturation : un ordre de contrôle n'est jamais jeté pour faire place aux publications",
42
+ aliveUnderLoad: "preuve de vie sous saturation : la preuve de vie de la présence n'est jamais jetée pour faire place aux publications",
39
43
  reserved: "espace réservé : refusé et compté à l'entrée du hub, la diffusion passe sans revenir",
44
+ presence: "présence entre deux pods : chaque hub voit les présents de l'autre, l'arrêt propre les retire sans attendre l'oubli, sans doublon ni rejeu",
40
45
  replaceHandler: "onMessage remplace le handler précédent",
41
46
  stop: "stop coupe la réception, et se rappelle sans erreur"
42
47
  };
@@ -96,8 +101,9 @@ var Inbox = class {
96
101
  * Vérifie, sur plusieurs pairs reliés au même bus : la diffusion à TOUS les pairs
97
102
  * sans écho, l'ordre par émetteur, l'absence de rejeu au démarrage, le sceau
98
103
  * (message non scellé ou mal scellé refusé), le transport des ordres de contrôle,
99
- * le refus de l'espace réservé à l'entrée du hub, le remplacement du handler et
100
- * l'arrêt.
104
+ * même quand une rafale de publications sature la file d'envoi, le refus de
105
+ * l'espace réservé à l'entrée du hub, la présence répartie entre deux hubs
106
+ * (ADR-0025), le remplacement du handler et l'arrêt.
101
107
  *
102
108
  * @param name - nom du driver, affiché dans le rapport.
103
109
  * @param makePeer - fabrique d'un pair relié au bus commun.
@@ -107,6 +113,7 @@ function runBackplaneConformance(name, makePeer, options) {
107
113
  const sealed = options.sealed ?? true;
108
114
  const quietMs = options.quietMs ?? 100;
109
115
  const timeoutMs = options.timeoutMs ?? 2e3;
116
+ const floodBytes = options.floodBytes ?? 16777216;
110
117
  const secret = sealed ? randomBytes(32).toString("hex") : null;
111
118
  const C = BACKPLANE_CONFORMANCE_CASES;
112
119
  /**
@@ -210,6 +217,37 @@ function runBackplaneConformance(name, makePeer, options) {
210
217
  await until(() => got.length >= orders.length, timeoutMs, () => `ordres de contrôle reçus : ${got.length} sur ${orders.length}`);
211
218
  assert.deepEqual(got, orders);
212
219
  }));
220
+ /**
221
+ * Rafale qui sature la file d'envoi de A, puis UN message sur un canal
222
+ * prioritaire : il doit arriver chez B. `pick` reconnaît le message du cas.
223
+ */
224
+ const underLoad = (channel, payload, pick, lost) => scenario(async (open, prefix) => {
225
+ const a = await open();
226
+ const b = await open();
227
+ const got = [];
228
+ b.peer.onMessage((msg) => {
229
+ if (msg.channel === channel && pick(msg.payload, prefix)) got.push(msg.payload);
230
+ });
231
+ const chunk = Math.min(65536, Math.ceil(floodBytes / 4));
232
+ const pad = "x".repeat(chunk);
233
+ const count = Math.ceil(floodBytes / chunk);
234
+ for (let i = 0; i < count; i += 1) a.peer.publish(`${prefix}flood`, {
235
+ seq: i,
236
+ pad
237
+ });
238
+ const sent = payload(prefix);
239
+ a.peer.publish(channel, sent);
240
+ const queue = a.peer.describe().queue;
241
+ if (queue !== void 0 && queue.maxBytes > 0) assert.ok(queue.droppedTotal > 0, `la rafale de ${floodBytes} octets n'a pas saturé la file (seuil ${queue.maxBytes}) : relever floodBytes, sinon ce cas ne prouve rien`);
242
+ await until(() => got.length > 0, timeoutMs, () => lost);
243
+ assert.deepEqual(got, [sent]);
244
+ });
245
+ options.it(C.controlUnderLoad, underLoad(PLATFORM_BUS_CHANNELS.control, (prefix) => ({
246
+ op: "evictUser",
247
+ channel: `${prefix}chat`,
248
+ user: "mallory"
249
+ }), (msg, prefix) => readControlOrder(msg)?.channel.startsWith(prefix) === true, "l'ordre d'éjection émis sous saturation n'est pas arrivé — la file l'a-t-elle jeté avec les publications ?"));
250
+ options.it(C.aliveUnderLoad, underLoad(PLATFORM_BUS_CHANNELS.presenceAlive, (prefix) => ({ probe: prefix }), (msg, prefix) => ownField(msg, "probe") === prefix, "la preuve de vie émise sous saturation n'est pas arrivée — la file l'a-t-elle jetée avec les publications ? Un pair oublierait ce pod vivant."));
213
251
  options.it(C.reserved, scenario(async (open, prefix) => {
214
252
  const a = await open();
215
253
  const b = await open();
@@ -241,6 +279,44 @@ function runBackplaneConformance(name, makePeer, options) {
241
279
  hubB.clear();
242
280
  }
243
281
  }));
282
+ options.it(C.presence, scenario(async (open, prefix) => {
283
+ const a = await open();
284
+ const b = await open();
285
+ const hubA = new RealtimeHub();
286
+ const hubB = new RealtimeHub();
287
+ try {
288
+ for (const hub of [hubA, hubB]) {
289
+ hub.declareChannelPolicy(`${prefix}*`, { presence: true }, null);
290
+ hub.markBroadcastChannel(prefix);
291
+ }
292
+ hubA.setBackplane(a.peer);
293
+ hubB.setBackplane(b.peer);
294
+ const channel = `${prefix}room`;
295
+ const same = (hub, want) => JSON.stringify(presenceView(hub, channel)) === JSON.stringify(want);
296
+ presenceClient(hubA, channel, "alice");
297
+ presenceClient(hubA, channel, null);
298
+ presenceClient(hubB, channel, "alice");
299
+ presenceClient(hubB, channel, "bob");
300
+ const both = {
301
+ alice: 2,
302
+ bob: 1
303
+ };
304
+ await until(() => same(hubB, both) && hubB.presence(channel)?.unnamed === 1 && hubB.presence(channel)?.partial === false && same(hubA, both), timeoutMs, () => `présence attendue ${JSON.stringify(both)} — A voit ${JSON.stringify(presenceView(hubA, channel))}, B voit ${JSON.stringify(presenceView(hubB, channel))} (lots, demandes ou réponses de présence perdus par le bus ?)`);
305
+ hubA.endPresence();
306
+ await until(() => same(hubB, {
307
+ alice: 1,
308
+ bob: 1
309
+ }) && hubB.presence(channel)?.unnamed === 0, timeoutMs, () => `après l'arrêt propre de A, B voit encore ${JSON.stringify(presenceView(hubB, channel))} — la preuve de vie finale a-t-elle traversé le bus ?`);
310
+ const probe = hubB.probe().presence;
311
+ assert.equal(probe?.endedTotal, 1, "arrêt propre non reçu");
312
+ assert.equal(probe.forgottenTotal, 0, "A oublié au lieu d'être retiré");
313
+ assert.equal(probe.presenceStaleDiscardedTotal, 0, "messages de présence reçus en double ou rejoués — le bus livre-t-il deux fois ?");
314
+ assert.equal(probe.presenceRejectedTotal, 0, "messages de présence refusés — la charge a-t-elle été altérée en route ?");
315
+ } finally {
316
+ hubA.clear();
317
+ hubB.clear();
318
+ }
319
+ }));
244
320
  options.it(C.replaceHandler, scenario(async (open, prefix) => {
245
321
  const a = await open();
246
322
  const b = await open();