@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,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) => servesByBridge(r.methods, verb));
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" ? "" : `"${verb}" et `}"WEBSOCKET".`);
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 de sa règle
89
- * `allow`, que rien ne justifie plus. Un verdict du verrou, lui, ne tient qu'à
90
- * l'identité et survit au désabonnement.
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
- const admitted = state.admitted;
97
- if (admitted === null || admitted[channel] !== true) return;
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 à politique fixe (jamais un refus).
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. La première retenue
105
- * inscrit la connexion auprès du hub, qui pourra la vider si une politique arrive
106
- * après coup.
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. Les admissions par `allow` restent : elles valent autant que
122
- * l'abonnement qui les porte.
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` que l'abonnement n'a pas admis est refusé ;
142
- * - un canal entrant à politique fixe passe par le verrou, dont le « oui » est retenu ;
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
- if (hub.resolveChannelAdmission(method) !== null) return false;
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, admitByRule, dropRetainedVerdicts, gateFrame, releaseRuleAdmission, revokeAdmission };
160
+ export { MAX_RETAINED_FRAME_VERDICTS, dropRetainedVerdicts, gateFrame, releaseRuleAdmission, revokeAdmission };