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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +55 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +91 -12
  4. package/dist/nodefony/config/config.js +7 -0
  5. package/dist/nodefony/decorators/realtimeDecorators.js +341 -43
  6. package/dist/nodefony/interfaces/IRealtimeConnection.js +1 -0
  7. package/dist/nodefony/src/backplane/ClusterBackplane.js +32 -4
  8. package/dist/nodefony/src/backplane/RedisBackplane.js +146 -41
  9. package/dist/nodefony/src/backplane/controlOrder.js +86 -0
  10. package/dist/nodefony/src/backplane/envelope.js +505 -27
  11. package/dist/nodefony/src/backplane/ingress.js +93 -0
  12. package/dist/nodefony/src/backplane/presenceMessage.js +205 -0
  13. package/dist/nodefony/src/backplane/publishQueue.js +97 -3
  14. package/dist/nodefony/src/cluster/ClusterProbeClient.js +2 -0
  15. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
  16. package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
  17. package/dist/nodefony/src/server/RealtimeConnection.js +135 -0
  18. package/dist/nodefony/src/server/RealtimeController.js +409 -107
  19. package/dist/nodefony/src/server/RealtimeHub.js +1158 -111
  20. package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
  21. package/dist/nodefony/src/server/admissionBudget.js +176 -0
  22. package/dist/nodefony/src/server/channelPolicyRegistry.js +95 -0
  23. package/dist/nodefony/src/server/channelPolicyResolution.js +191 -0
  24. package/dist/nodefony/src/server/channelPresence.js +488 -0
  25. package/dist/nodefony/src/server/connectionAdmission.js +242 -0
  26. package/dist/nodefony/src/server/contractRoutes.js +75 -0
  27. package/dist/nodefony/src/server/deniedDetail.js +10 -3
  28. package/dist/nodefony/src/server/frameAdmission.js +160 -0
  29. package/dist/nodefony/src/server/presenceBus.js +486 -0
  30. package/dist/nodefony/src/server/realtimeShutdown.js +31 -0
  31. package/dist/nodefony/src/server/remotePresence.js +541 -0
  32. package/dist/nodefony/src/server/subscribedAck.js +13 -1
  33. package/dist/nodefony/src/server/syslogUplink.js +3 -4
  34. package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
  35. package/dist/nodefony/src/service/RealtimeService.js +51 -1
  36. package/dist/nodefony/src/service/publicOrigin.js +40 -0
  37. package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
  38. package/dist/nodefony/testing/backplaneConformance.js +347 -0
  39. package/dist/nodefony/testing/index.js +43 -12
  40. package/dist/nodefony/testing/presenceClient.js +54 -0
  41. package/dist/types/index.d.ts +39 -7
  42. package/dist/types/nodefony/config/config.d.ts +9 -0
  43. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +198 -17
  44. package/dist/types/nodefony/interfaces/IBackplane.d.ts +46 -51
  45. package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
  46. package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
  47. package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
  48. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
  49. package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
  50. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
  51. package/dist/types/nodefony/interfaces/index.d.ts +2 -1
  52. package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
  53. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +89 -18
  54. package/dist/types/nodefony/src/backplane/controlOrder.d.ts +75 -0
  55. package/dist/types/nodefony/src/backplane/envelope.d.ts +77 -0
  56. package/dist/types/nodefony/src/backplane/ingress.d.ts +48 -0
  57. package/dist/types/nodefony/src/backplane/presenceMessage.d.ts +150 -0
  58. package/dist/types/nodefony/src/backplane/publishQueue.d.ts +25 -1
  59. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +10 -0
  60. package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
  61. package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +20 -0
  62. package/dist/types/nodefony/src/server/RealtimeController.d.ts +110 -12
  63. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +448 -42
  64. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
  65. package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
  66. package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +41 -0
  67. package/dist/types/nodefony/src/server/channelPolicyResolution.d.ts +112 -0
  68. package/dist/types/nodefony/src/server/channelPresence.d.ts +155 -0
  69. package/dist/types/nodefony/src/server/connectionAdmission.d.ts +68 -0
  70. package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
  71. package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
  72. package/dist/types/nodefony/src/server/frameAdmission.d.ts +66 -0
  73. package/dist/types/nodefony/src/server/presenceBus.d.ts +112 -0
  74. package/dist/types/nodefony/src/server/realtimeShutdown.d.ts +21 -0
  75. package/dist/types/nodefony/src/server/remotePresence.d.ts +151 -0
  76. package/dist/types/nodefony/src/server/subscribedAck.d.ts +10 -0
  77. package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
  78. package/dist/types/nodefony/src/service/RealtimeService.d.ts +17 -2
  79. package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
  80. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
  81. package/dist/types/nodefony/testing/backplaneConformance.d.ts +78 -0
  82. package/dist/types/nodefony/testing/index.d.ts +13 -2
  83. package/dist/types/nodefony/testing/presenceClient.d.ts +20 -0
  84. package/docs/actions.md +420 -18
  85. package/docs/architecture.md +101 -11
  86. package/docs/cadence-et-contre-pression.md +87 -11
  87. package/docs/canaux-a-membres.md +277 -0
  88. package/docs/cas-d-usage.md +1703 -0
  89. package/docs/configuration.md +175 -19
  90. package/docs/cookbook-chat.md +701 -224
  91. package/docs/index.md +11 -1
  92. package/docs/observabilite.md +97 -21
  93. package/docs/protocole.md +142 -33
  94. package/docs/securite.md +347 -41
  95. package/docs/vocabulaire.md +13 -12
  96. package/package.json +16 -13
@@ -0,0 +1,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,10 +20,22 @@ 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
  */
26
29
  function subscribedAckFrame(channel) {
27
30
  return SUBSCRIBED_FRAME_PREFIX + JSON.stringify(channel) + "}}";
28
31
  }
29
- export { subscribedAckFrame };
32
+ /**
33
+ * Sérialise `realtime:subscribed { channel, imposed: true }` — l'acquittement d'un abonnement imposé par le serveur.
34
+ *
35
+ * @param channel - le canal imposé (échappé par `JSON.stringify`).
36
+ * @returns la trame JSON-RPC 2.0 prête à écrire sur le transport.
37
+ */
38
+ function imposedSubscribedAckFrame(channel) {
39
+ return SUBSCRIBED_FRAME_PREFIX + JSON.stringify(channel) + ",\"imposed\":true}}";
40
+ }
41
+ export { imposedSubscribedAckFrame, subscribedAckFrame };
@@ -75,11 +75,10 @@ function createSyslogUplinkHandler(opts) {
75
75
  let windowStart = 0;
76
76
  let windowCount = 0;
77
77
  return function onSyslogUplink(params) {
78
- if (params === null || typeof params !== "object") return;
79
- const batch = params;
80
- const entries = batch.entries;
78
+ if (!isRecord(params)) return;
79
+ const entries = params.entries;
81
80
  if (!Array.isArray(entries) || entries.length === 0) return;
82
- const pageId = safeRequestId(batch.pageId, maxRequestIdLength);
81
+ const pageId = safeRequestId(params.pageId, maxRequestIdLength);
83
82
  if (pageId === void 0) return;
84
83
  const now = Date.now();
85
84
  if (now - windowStart > windowMs) {
@@ -0,0 +1,37 @@
1
+ // nodefony/src/server/welcomeIdentity.ts
2
+ /**
3
+ * Vue PUBLIQUE de l'identité d'une connexion, annoncée au client par `realtime:welcome` — tirée de l'identité d'admission quand le jeton en produit une.
4
+ *
5
+ * Un objet NEUF, champ par champ, et jamais l'identité d'admission elle-même :
6
+ * celle-ci porte `issuer` et `account`, qui restent au serveur — la sérialiser
7
+ * telle quelle les enverrait au client (ADR-0021 §S10 point 1). Les tableaux
8
+ * de rôles et de scopes, eux, sont repris par RÉFÉRENCE : gelés une fois à la
9
+ * construction de l'identité, ils ne se recopient pas.
10
+ *
11
+ * Un jeton sans `getIdentity` (authentificateur tiers) annonce `id: null` : son
12
+ * identifiant stable n'est pas connu, et le déduire du login serait le défaut
13
+ * même que `id` corrige.
14
+ *
15
+ * @param token - jeton figé à la poignée de main.
16
+ * @returns l'identité à placer dans le `welcome`.
17
+ */
18
+ function welcomeIdentityOf(token) {
19
+ const who = token.getIdentity?.();
20
+ if (who !== void 0) return {
21
+ type: who.type,
22
+ authenticated: who.authenticated,
23
+ id: who.id,
24
+ userIdentifier: who.userIdentifier,
25
+ roles: who.roles,
26
+ scopes: who.scopes
27
+ };
28
+ return {
29
+ type: token.type,
30
+ authenticated: token.isAuthenticated(),
31
+ id: null,
32
+ userIdentifier: token.getUserIdentifier(),
33
+ roles: token.getRoles(),
34
+ scopes: token.getScopes()
35
+ };
36
+ }
37
+ export { welcomeIdentityOf };