@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,486 @@
1
+ import { MAX_PRESENCE_CHANNELS, isUnknownPresenceMessage, readPresenceAlive, readPresenceMessage } from "../backplane/presenceMessage.js";
2
+ import { RemotePresence } from "./remotePresence.js";
3
+ import { PLATFORM_BUS_CHANNELS } from "nodefony";
4
+ import { Buffer } from "node:buffer";
5
+ // nodefony/src/server/presenceBus.ts
6
+ /** Fenêtre des lots, demandes et réponses entre pods (ms) — ADR-0025 P2. */
7
+ const PRESENCE_BATCH_MS = 50;
8
+ /** Intervalle local de la preuve de vie (ms), annoncé aux pairs — ADR-0025 P2, « Oubli ». */
9
+ const PRESENCE_INTERVAL_MS = 5e3;
10
+ /** Délai d'oubli d'un pair muet, en intervalles ANNONCÉS (≥ 2) — ADR-0025 P2. */
11
+ const PRESENCE_FORGET_FACTOR = 3;
12
+ /** Octets UTF-8 au plus par message (estimés à l'encodage JSON), sous les 64 Ko de l'ADR. */
13
+ const PRESENCE_MESSAGE_BYTES = 6e4;
14
+ /** Coût fixe estimé d'une entrée de canal (clés, ponctuation, `unnamed`). */
15
+ const ENTRY_BYTES = 48;
16
+ const EMPTY_PAIRS = Object.freeze([]);
17
+ /** Coût estimé d'une paire `[identité, onglets]` sur le fil. */
18
+ function pairBytes(key) {
19
+ return (typeof key === "string" ? Buffer.byteLength(key, "utf8") : 0) + 10;
20
+ }
21
+ /**
22
+ * Découpe des entrées de canal en parts d'au plus {@link PRESENCE_MESSAGE_BYTES} octets estimés et {@link MAX_PRESENCE_CHANNELS} entrées ; une entrée trop grosse s'étend sur plusieurs parts (ses paires s'additionnent, valeurs absolues).
23
+ *
24
+ * @param pairsOf - les paires d'une entrée ; `rebuild` en fait une entrée
25
+ * réduite à une tranche de ces paires (`last` : la dernière tranche, qui
26
+ * garde ce qui n'est pas découpé).
27
+ */
28
+ function split(entries, pairsOf, rebuild) {
29
+ const parts = [];
30
+ let part = [];
31
+ let size = 0;
32
+ for (const entry of entries) {
33
+ const head = Buffer.byteLength(entry.channel, "utf8") + ENTRY_BYTES;
34
+ if (part.length >= 4096 || part.length > 0 && size + head > PRESENCE_MESSAGE_BYTES) {
35
+ parts.push(part);
36
+ part = [];
37
+ size = 0;
38
+ }
39
+ const pairs = pairsOf(entry);
40
+ let from = 0;
41
+ let cost = head;
42
+ for (let i = 0; i < pairs.length; i += 2) {
43
+ const bytes = pairBytes(pairs[i]);
44
+ if (size + cost + bytes > PRESENCE_MESSAGE_BYTES && i > from) {
45
+ part.push(rebuild(entry, pairs.slice(from, i), false));
46
+ parts.push(part);
47
+ part = [];
48
+ size = 0;
49
+ from = i;
50
+ cost = head;
51
+ }
52
+ cost += bytes;
53
+ }
54
+ part.push(from === 0 ? entry : rebuild(entry, pairs.slice(from), true));
55
+ size += cost;
56
+ }
57
+ if (part.length > 0 || parts.length === 0) parts.push(part);
58
+ return parts;
59
+ }
60
+ /**
61
+ * Présence répartie d'UN pod (ADR-0025 P2) — ce qu'il émet sur le bus (lots de 50 ms, demandes d'état, réponses, preuve de vie) et ce qu'il en reçoit (vue des pairs, {@link RemotePresence}).
62
+ *
63
+ * Sans minuteur ni horloge : le hub la fait avancer à son tic UNIQUE
64
+ * ({@link PresenceBus.tick}) et arme ce tic sur {@link PresenceBus.due}, O(1).
65
+ * Elle ne parle qu'à `IBackplane` par son hôte : un pilote neuf (Kafka,
66
+ * NATS…) n'écrit rien pour la présence.
67
+ *
68
+ * Coût : rien n'existe tant qu'aucun canal à présence n'est diffusé ; un
69
+ * changement local = un ajout dans un `Set` ; un lot = un parcours des seuls
70
+ * canaux changés.
71
+ */
72
+ var PresenceBus = class {
73
+ /** Vue des pairs. */
74
+ remote;
75
+ /** Vie de ce processus : `Date.now() × 1024 + aléa sur 10 bits` (entier sûr jusqu'en 2248). */
76
+ incarnation;
77
+ #host;
78
+ #interval;
79
+ #random;
80
+ #seq = 0;
81
+ #lastBatch = 0;
82
+ #dirty = null;
83
+ #ask = null;
84
+ #answer = null;
85
+ #answerDue = false;
86
+ #touched = null;
87
+ #syncing = null;
88
+ #syncHead = 0;
89
+ #gapped = false;
90
+ #gapTotalSeen = 0;
91
+ #batchAt = Number.POSITIVE_INFINITY;
92
+ #requestAt = Number.POSITIVE_INFINITY;
93
+ #aliveAt = Number.POSITIVE_INFINITY;
94
+ #expireAt = Number.POSITIVE_INFINITY;
95
+ #batchTotal = 0;
96
+ #requestTotal = 0;
97
+ #answerTotal = 0;
98
+ #aliveTotal = 0;
99
+ #echoSeen = -1;
100
+ #echoExpected = false;
101
+ #busLost = false;
102
+ #busLostTotal = 0;
103
+ #rearmTotal = 0;
104
+ #ended = false;
105
+ /**
106
+ * @throws RangeError si le délai d'oubli est sous 2 intervalles (cf `RemotePresence`).
107
+ */
108
+ constructor(host, options = {}) {
109
+ this.#host = host;
110
+ this.#interval = options.interval ?? 5e3;
111
+ this.#random = options.random ?? Math.random;
112
+ const wall = options.wallClock ?? Date.now;
113
+ this.incarnation = Math.floor(wall()) * 1024 + Math.floor(this.#random() * 1024);
114
+ this.remote = new RemotePresence({
115
+ lookup: (channel) => {
116
+ const presence = host.lookup(channel);
117
+ if (presence !== null && presence !== false) this.#touch(presence);
118
+ return presence;
119
+ },
120
+ factor: options.factor ?? 3,
121
+ interval: this.#interval
122
+ });
123
+ }
124
+ /** Prochain instant (ms monotone) où le bus a quelque chose à faire — O(1). */
125
+ due() {
126
+ let next = this.#batchAt;
127
+ if (this.#requestAt < next) next = this.#requestAt;
128
+ if (this.#aliveAt < next) next = this.#aliveAt;
129
+ if (this.#expireAt < next) next = this.#expireAt;
130
+ const syncing = this.#syncing;
131
+ if (syncing !== null) {
132
+ const head = syncing[this.#syncHead];
133
+ if (head !== void 0 && head.syncUntil < next) next = head.syncUntil;
134
+ }
135
+ return next;
136
+ }
137
+ /**
138
+ * Présences à rafraîchir depuis le dernier appel (agrégat changé, état incomplet changé) — `null` si aucune ; le hub les touche.
139
+ */
140
+ takeTouched() {
141
+ const touched = this.#touched;
142
+ this.#touched = null;
143
+ return touched;
144
+ }
145
+ /**
146
+ * Un canal à présence diffusé vient d'être servi (premier abonné local) : la vue reste incomplète le temps que la demande d'état groupée soit servie.
147
+ */
148
+ served(presence, now) {
149
+ if (this.#ended) return;
150
+ (this.#ask ??= /* @__PURE__ */ new Set()).add(presence.channel);
151
+ presence.syncUntil = now + 150;
152
+ presence.setPartial(true);
153
+ (this.#syncing ??= []).push(presence);
154
+ this.#armBatch(now);
155
+ this.#activate(now);
156
+ }
157
+ /** La part locale d'une présence a changé : elle part au prochain lot. */
158
+ changed(presence, now) {
159
+ if (this.#ended) return;
160
+ (this.#dirty ??= /* @__PURE__ */ new Set()).add(presence);
161
+ this.#armBatch(now);
162
+ this.#activate(now);
163
+ }
164
+ /**
165
+ * Applique un message du canal des lots, demandes et réponses.
166
+ *
167
+ * @returns `true` si c'est un type de message inconnu de cette version (le
168
+ * hub le compte dans `ingressUnknownDiscardedTotal`) ; `false` sinon — y
169
+ * compris refusé, compté ici (`presenceRejectedTotal`).
170
+ */
171
+ receive(origin, payload, now) {
172
+ if (this.#ended) return false;
173
+ const message = readPresenceMessage(payload);
174
+ if (message === null) {
175
+ if (isUnknownPresenceMessage(payload)) return true;
176
+ this.remote.countRejected();
177
+ return false;
178
+ }
179
+ const request = this.remote.receive(origin, message, now);
180
+ if (request !== null) this.#queueAnswer(request, now);
181
+ this.#afterReceive(now);
182
+ return false;
183
+ }
184
+ /** Applique une preuve de vie ; une forme fausse est refusée et comptée. */
185
+ receiveAlive(origin, payload, now) {
186
+ if (this.#ended) return;
187
+ const alive = readPresenceAlive(payload);
188
+ if (alive === null) {
189
+ this.remote.countRejected();
190
+ return;
191
+ }
192
+ this.remote.receiveAlive(origin, alive, now);
193
+ this.#afterReceive(now);
194
+ }
195
+ /** Fait avancer la présence répartie à l'instant `now` (tic unique du hub). */
196
+ tick(now) {
197
+ if (this.#ended) return;
198
+ const remote = this.remote;
199
+ if (now - this.due() > this.#interval) this.#rearm(now);
200
+ if (now >= this.#batchAt) {
201
+ this.#batchAt = Number.POSITIVE_INFINITY;
202
+ this.#flushBatch();
203
+ this.#flushAsk();
204
+ this.#flushAnswers();
205
+ }
206
+ const alive = now >= this.#aliveAt;
207
+ if (alive) this.#checkEcho(now);
208
+ if (now >= this.#expireAt && !this.#busLost) remote.expire(now);
209
+ if (now >= this.#requestAt) {
210
+ this.#requestAt = Number.POSITIVE_INFINITY;
211
+ this.#sendRequests(now);
212
+ }
213
+ if (alive) {
214
+ this.#sendAlive(false);
215
+ this.#aliveAt = this.#active() ? now + this.#interval : Number.POSITIVE_INFINITY;
216
+ }
217
+ const syncing = this.#syncing;
218
+ if (syncing !== null) {
219
+ let head = this.#syncHead;
220
+ while (head < syncing.length) {
221
+ const presence = syncing[head];
222
+ if (presence === void 0 || presence.syncUntil > now) break;
223
+ presence.syncUntil = 0;
224
+ if (presence.live) {
225
+ presence.setPartial(this.#incomplete());
226
+ this.#touch(presence);
227
+ }
228
+ head += 1;
229
+ }
230
+ if (head === syncing.length) {
231
+ this.#syncing = null;
232
+ this.#syncHead = 0;
233
+ } else this.#syncHead = head;
234
+ }
235
+ this.#expireAt = remote.size > 0 && !this.#busLost ? remote.nextDeadline() : Number.POSITIVE_INFINITY;
236
+ this.#refreshGaps(now);
237
+ }
238
+ /**
239
+ * Arrêt propre (ADR-0025 P5) : le dernier lot part, puis une preuve de vie FINALE — les pairs retirent aussitôt les présents de ce pod, sans attendre le délai d'oubli. Plus rien ne part ensuite ; idempotent.
240
+ *
241
+ * Rien n'est émis si ce pod ne s'est jamais annoncé (aucune preuve de vie
242
+ * émise ni vue des pairs) : personne n'a rien à retirer.
243
+ */
244
+ end() {
245
+ if (this.#ended) return;
246
+ if (this.#aliveTotal > 0 || this.#active()) {
247
+ this.#flushBatch();
248
+ this.#sendAlive(true);
249
+ }
250
+ this.#ended = true;
251
+ this.#batchAt = Number.POSITIVE_INFINITY;
252
+ this.#requestAt = Number.POSITIVE_INFINITY;
253
+ this.#aliveAt = Number.POSITIVE_INFINITY;
254
+ this.#expireAt = Number.POSITIVE_INFINITY;
255
+ this.#dirty = null;
256
+ this.#ask = null;
257
+ this.#answer = null;
258
+ this.#syncing = null;
259
+ this.#touched = null;
260
+ }
261
+ /** Arrêt propre émis ? */
262
+ get ended() {
263
+ return this.#ended;
264
+ }
265
+ /** Bus perdu, constaté par l'écho : la vue est figée et `partial` (instantané). */
266
+ get busLost() {
267
+ return this.#busLost;
268
+ }
269
+ /** Pertes du bus constatées par l'écho. */
270
+ get busLostTotal() {
271
+ return this.#busLostTotal;
272
+ }
273
+ /** Réarmements du délai d'oubli (tic en retard, retour de l'écho). */
274
+ get rearmTotal() {
275
+ return this.#rearmTotal;
276
+ }
277
+ /** Compteurs de la sonde. */
278
+ get batchTotal() {
279
+ return this.#batchTotal;
280
+ }
281
+ /** Demandes d'état émises (ciblées et non ciblées). */
282
+ get requestTotal() {
283
+ return this.#requestTotal;
284
+ }
285
+ /** Parts de réponse d'état émises. */
286
+ get answerTotal() {
287
+ return this.#answerTotal;
288
+ }
289
+ /** Preuves de vie émises. */
290
+ get aliveTotal() {
291
+ return this.#aliveTotal;
292
+ }
293
+ #touch(presence) {
294
+ (this.#touched ??= /* @__PURE__ */ new Set()).add(presence);
295
+ }
296
+ #armBatch(now) {
297
+ if (this.#batchAt === Number.POSITIVE_INFINITY) this.#batchAt = now + 50;
298
+ }
299
+ /** Bat-il ? Dès qu'il sert un canal à présence diffusé OU garde une vue des pairs. */
300
+ #active() {
301
+ return this.#host.servedCount() > 0 || this.remote.size > 0;
302
+ }
303
+ /** Arme le premier battement, avec une gigue de 0 à 25 % de l'intervalle. */
304
+ #activate(now) {
305
+ if (this.#aliveAt === Number.POSITIVE_INFINITY && this.#active()) this.#aliveAt = now + this.#interval * .25 * this.#random();
306
+ }
307
+ #afterReceive(now) {
308
+ const remote = this.remote;
309
+ if (remote.gapTotal !== this.#gapTotalSeen) {
310
+ this.#gapTotalSeen = remote.gapTotal;
311
+ const at = now + 50;
312
+ if (at < this.#requestAt) this.#requestAt = at;
313
+ }
314
+ const closer = remote.takeCloserDeadline();
315
+ if (closer < this.#expireAt && !this.#busLost) this.#expireAt = closer;
316
+ this.#activate(now);
317
+ this.#refreshGaps(now);
318
+ }
319
+ /** La vue des pairs est-elle incomplète (trou chez un pair) ou figée (bus perdu) ? */
320
+ #incomplete() {
321
+ return this.remote.gaps > 0 || this.#busLost;
322
+ }
323
+ /**
324
+ * Un trou s'ouvre ou se ferme chez un pair, le bus se perd ou revient : la vue des canaux servis change d'état.
325
+ */
326
+ #refreshGaps(now) {
327
+ if (this.remote.gaps > 0 && this.#requestAt === Number.POSITIVE_INFINITY) this.#requestAt = now + this.#interval;
328
+ const incomplete = this.#incomplete();
329
+ if (incomplete === this.#gapped) return;
330
+ this.#gapped = incomplete;
331
+ for (const presence of this.#host.servedPresences()) {
332
+ if (presence.syncUntil !== 0) continue;
333
+ presence.setPartial(incomplete);
334
+ this.#touch(presence);
335
+ }
336
+ }
337
+ /**
338
+ * Relève l'écho au tic de la preuve de vie : une preuve de vie partie au relevé précédent et aucun écho depuis = bus perdu ; l'écho qui revient réarme le délai d'oubli de tous les pairs.
339
+ */
340
+ #checkEcho(now) {
341
+ const total = this.#host.echoTotal();
342
+ if (total === void 0) return;
343
+ if (total !== this.#echoSeen) {
344
+ if (this.#busLost) {
345
+ this.#busLost = false;
346
+ this.#rearm(now);
347
+ }
348
+ } else if (this.#echoExpected && !this.#busLost) {
349
+ this.#busLost = true;
350
+ this.#busLostTotal += 1;
351
+ }
352
+ this.#echoSeen = total;
353
+ this.#echoExpected = true;
354
+ }
355
+ #rearm(now) {
356
+ if (this.remote.size === 0) return;
357
+ this.remote.rearm(now);
358
+ this.#rearmTotal += 1;
359
+ }
360
+ #queueAnswer(request, now) {
361
+ const targeted = request.to !== null;
362
+ if (targeted && request.to !== this.#host.originId) return;
363
+ const answer = this.#answer ??= /* @__PURE__ */ new Map();
364
+ for (const channel of request.channels) if (targeted || answer.get(channel) !== true) answer.set(channel, targeted);
365
+ if (targeted) this.#answerDue = true;
366
+ this.#armBatch(now);
367
+ }
368
+ #flushBatch() {
369
+ const dirty = this.#dirty;
370
+ if (dirty === null) return;
371
+ this.#dirty = null;
372
+ let entries = null;
373
+ for (const presence of dirty) {
374
+ const entry = presence.takeBusDelta();
375
+ if (entry !== null) (entries ??= []).push(entry);
376
+ }
377
+ if (entries === null) return;
378
+ const parts = split(entries, (entry) => entry.set ?? EMPTY_PAIRS, (entry, set, last) => last ? {
379
+ ...entry,
380
+ set
381
+ } : {
382
+ ...entry,
383
+ set,
384
+ gone: null
385
+ });
386
+ for (const channels of parts) {
387
+ const seq = ++this.#seq;
388
+ this.#host.publish(PLATFORM_BUS_CHANNELS.presence, {
389
+ op: "batch",
390
+ incarnation: this.incarnation,
391
+ seq,
392
+ prev: this.#lastBatch,
393
+ channels
394
+ });
395
+ this.#lastBatch = seq;
396
+ this.#batchTotal += 1;
397
+ }
398
+ }
399
+ #flushAsk() {
400
+ const ask = this.#ask;
401
+ if (ask === null) return;
402
+ this.#ask = null;
403
+ this.#request([...ask], null);
404
+ }
405
+ #sendRequests(now) {
406
+ const remote = this.remote;
407
+ if (remote.gaps === 0) return;
408
+ const channels = [];
409
+ for (const presence of this.#host.servedPresences()) channels.push(presence.channel);
410
+ const targets = remote.takeRequests(now, channels);
411
+ if (targets !== null) for (const to of targets) this.#request(channels, to);
412
+ if (remote.gaps > 0) this.#requestAt = now + this.#interval;
413
+ }
414
+ /** Publie une demande, découpée par {@link MAX_PRESENCE_CHANNELS} canaux. */
415
+ #request(channels, to) {
416
+ for (let from = 0; from < channels.length || from === 0;) {
417
+ const slice = channels.length <= 4096 ? channels : channels.slice(from, from + MAX_PRESENCE_CHANNELS);
418
+ this.#host.publish(PLATFORM_BUS_CHANNELS.presence, {
419
+ op: "request",
420
+ incarnation: this.incarnation,
421
+ seq: ++this.#seq,
422
+ channels: slice,
423
+ to
424
+ });
425
+ this.#requestTotal += 1;
426
+ from += MAX_PRESENCE_CHANNELS;
427
+ }
428
+ }
429
+ #flushAnswers() {
430
+ const answer = this.#answer;
431
+ if (answer === null) return;
432
+ const due = this.#answerDue;
433
+ this.#answer = null;
434
+ this.#answerDue = false;
435
+ const entries = [];
436
+ for (const [channel, named] of answer) {
437
+ const presence = this.#host.served(channel);
438
+ if (presence?.hasLocal === true) entries.push(presence.localState());
439
+ else if (named) entries.push({
440
+ channel,
441
+ members: EMPTY_PAIRS,
442
+ unnamed: 0
443
+ });
444
+ }
445
+ if (entries.length === 0 && !due) return;
446
+ const parts = split(entries, (entry) => entry.members, (entry, members) => ({
447
+ channel: entry.channel,
448
+ members,
449
+ unnamed: entry.unnamed
450
+ }));
451
+ const n = parts.length;
452
+ const last = this.#lastBatch;
453
+ for (let i = 0; i < n; i += 1) {
454
+ this.#host.publish(PLATFORM_BUS_CHANNELS.presence, {
455
+ op: "state",
456
+ incarnation: this.incarnation,
457
+ seq: ++this.#seq,
458
+ last,
459
+ i,
460
+ n,
461
+ channels: parts[i]
462
+ });
463
+ this.#answerTotal += 1;
464
+ }
465
+ }
466
+ #sendAlive(final) {
467
+ const incarnation = this.incarnation;
468
+ const seq = ++this.#seq;
469
+ const last = this.#lastBatch;
470
+ const interval = this.#interval;
471
+ this.#host.publish(PLATFORM_BUS_CHANNELS.presenceAlive, final ? {
472
+ incarnation,
473
+ seq,
474
+ last,
475
+ interval,
476
+ final: true
477
+ } : {
478
+ incarnation,
479
+ seq,
480
+ last,
481
+ interval
482
+ });
483
+ this.#aliveTotal += 1;
484
+ }
485
+ };
486
+ export { PRESENCE_BATCH_MS, PRESENCE_FORGET_FACTOR, PRESENCE_INTERVAL_MS, PresenceBus };
@@ -0,0 +1,31 @@
1
+ import { withTimeout } from "nodefony";
2
+ // nodefony/src/server/realtimeShutdown.ts
3
+ /**
4
+ * Délai laissé au bus pour faire partir ce qui est déjà émis à l'arrêt (ms) — sous la limite d'arrêt du noyau (15 s) et sous le délai de grâce d'un orchestrateur.
5
+ */
6
+ const BACKPLANE_STOP_TIMEOUT_MS = 2e3;
7
+ /**
8
+ * Arrêt propre du temps réel d'un processus — la présence répartie annonce sa fin (dernier lot, puis preuve de vie FINALE, ADR-0025 P5), puis le bus POSSÉDÉ s'arrête en laissant partir ce qui est émis, dans une limite de temps.
9
+ *
10
+ * Sans l'attente du bus, la sortie du processus au tick suivant perdait la
11
+ * preuve de vie finale sur un bus réseau (Redis) : les pairs gardaient ce
12
+ * serveur jusqu'au délai d'oubli. Un bus fourni par l'application (`owned`
13
+ * `null`) n'est pas arrêté ici : c'est à elle de le faire.
14
+ *
15
+ * @param hub - le hub du processus.
16
+ * @param owned - le bus que l'appelant a créé et doit arrêter, `null` sinon.
17
+ * @param timeoutMs - limite de l'arrêt du bus.
18
+ * @returns `false` si le bus n'a pas fini dans la limite (ou a échoué) — ce
19
+ * qui restait peut être perdu, les pairs oublieront ce serveur au délai.
20
+ */
21
+ async function shutdownRealtime(hub, owned, timeoutMs = BACKPLANE_STOP_TIMEOUT_MS) {
22
+ hub.endPresence();
23
+ if (owned === null) return true;
24
+ try {
25
+ await withTimeout(Promise.resolve(owned.stop()), timeoutMs, "realtime backplane stop");
26
+ return true;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+ export { BACKPLANE_STOP_TIMEOUT_MS, shutdownRealtime };