@nodefony/realtime 10.0.0-beta.6 → 10.0.0-beta.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
- package/dist/index.js +15 -3
- package/dist/nodefony/decorators/realtimeDecorators.js +231 -39
- package/dist/nodefony/src/backplane/RedisBackplane.js +33 -40
- package/dist/nodefony/src/backplane/controlOrder.js +34 -5
- package/dist/nodefony/src/backplane/envelope.js +422 -45
- package/dist/nodefony/src/backplane/ingress.js +93 -0
- package/dist/nodefony/src/backplane/presenceMessage.js +205 -0
- package/dist/nodefony/src/backplane/publishQueue.js +97 -3
- package/dist/nodefony/src/server/RealtimeConnection.js +31 -6
- package/dist/nodefony/src/server/RealtimeController.js +87 -44
- package/dist/nodefony/src/server/RealtimeHub.js +631 -127
- package/dist/nodefony/src/server/admissionBudget.js +8 -8
- package/dist/nodefony/src/server/channelPolicyRegistry.js +35 -13
- package/dist/nodefony/src/server/channelPolicyResolution.js +191 -0
- package/dist/nodefony/src/server/channelPresence.js +488 -0
- package/dist/nodefony/src/server/contractRoutes.js +3 -13
- package/dist/nodefony/src/server/frameAdmission.js +27 -35
- package/dist/nodefony/src/server/presenceBus.js +486 -0
- package/dist/nodefony/src/server/realtimeShutdown.js +31 -0
- package/dist/nodefony/src/server/remotePresence.js +541 -0
- package/dist/nodefony/src/server/subscribedAck.js +3 -0
- package/dist/nodefony/src/service/RealtimeService.js +9 -0
- package/dist/nodefony/testing/backplaneConformance.js +79 -3
- package/dist/nodefony/testing/index.js +22 -8
- package/dist/nodefony/testing/presenceClient.js +54 -0
- package/dist/types/index.d.ts +6 -1
- package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +117 -16
- package/dist/types/nodefony/interfaces/IBackplane.d.ts +12 -1
- package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +10 -2
- package/dist/types/nodefony/src/backplane/controlOrder.d.ts +23 -0
- package/dist/types/nodefony/src/backplane/envelope.d.ts +50 -7
- package/dist/types/nodefony/src/backplane/ingress.d.ts +48 -0
- package/dist/types/nodefony/src/backplane/presenceMessage.d.ts +150 -0
- package/dist/types/nodefony/src/backplane/publishQueue.d.ts +25 -1
- package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +3 -0
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +19 -1
- package/dist/types/nodefony/src/server/RealtimeHub.d.ts +139 -30
- package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +9 -2
- package/dist/types/nodefony/src/server/channelPolicyResolution.d.ts +112 -0
- package/dist/types/nodefony/src/server/channelPresence.d.ts +155 -0
- package/dist/types/nodefony/src/server/contractRoutes.d.ts +1 -1
- package/dist/types/nodefony/src/server/frameAdmission.d.ts +14 -18
- package/dist/types/nodefony/src/server/presenceBus.d.ts +112 -0
- package/dist/types/nodefony/src/server/realtimeShutdown.d.ts +21 -0
- package/dist/types/nodefony/src/server/remotePresence.d.ts +151 -0
- package/dist/types/nodefony/src/server/subscribedAck.d.ts +3 -0
- package/dist/types/nodefony/src/service/RealtimeService.d.ts +8 -1
- package/dist/types/nodefony/testing/backplaneConformance.d.ts +14 -2
- package/dist/types/nodefony/testing/index.d.ts +1 -1
- package/dist/types/nodefony/testing/presenceClient.d.ts +20 -0
- package/docs/actions.md +1 -0
- package/docs/architecture.md +21 -6
- package/docs/canaux-a-membres.md +13 -3
- package/docs/cas-d-usage.md +4 -3
- package/docs/configuration.md +38 -2
- package/docs/cookbook-chat.md +400 -152
- package/docs/observabilite.md +28 -9
- package/docs/protocole.md +10 -10
- package/docs/securite.md +64 -10
- package/package.json +12 -12
|
@@ -0,0 +1,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
|
|
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
|
-
*
|
|
100
|
-
* l'
|
|
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();
|