@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
@@ -1,7 +1,9 @@
1
- import { isCallable, isRecord, isThenable } from "../guards.js";
2
1
  import resolveBackplaneOriginId from "./originId.js";
3
- import { openBackplaneEnvelope, sealBackplaneEnvelope } from "./envelope.js";
2
+ import { isCallable, isRecord, isThenable } from "../guards.js";
3
+ import { sealBackplaneEnvelope, sealBackplaneEnvelopeBytes } from "./envelope.js";
4
+ import { BackplaneIngress } from "./ingress.js";
4
5
  import { BackplanePublishQueue } from "./publishQueue.js";
6
+ import { containsBytes } from "nodefony";
5
7
  // nodefony/src/backplane/RedisBackplane.ts
6
8
  /**
7
9
  * BASE du canal Redis **dédié** transportant les enveloppes realtime entre pods.
@@ -28,22 +30,18 @@ function resolveRedisChannel(namespace) {
28
30
  return namespace ? `${REDIS_RT_CHANNEL}:${namespace}` : REDIS_RT_CHANNEL;
29
31
  }
30
32
  /**
31
- * Adapte deux clients Redis (publisher + subscriber dédié) au seam
32
- * {@link IRedisBackplaneTransport}. SEUL point de contact avec la lib `redis` —
33
- * et même ici le couplage est **structurel** (aucun import). Le consommateur
34
- * branche typiquement les connexions du module `@nodefony/redis` :
33
+ * Construit le **sujet binaire** du bus (ADR-0021 S6) : le canal texte suffixé du
34
+ * protocole du codec. Un second sujet, versionné par son nom, que les exemplaires
35
+ * plus anciens n'écoutent pas — le message texte n'est pas touché, et un codec
36
+ * binaire d'un autre format aurait son propre sujet.
35
37
  *
36
- * ```ts
37
- * const redis = kernel.get("redis"); // RedisService
38
- * const bp = new RedisBackplane(
39
- * createRedisServiceTransport(redis.getClient("publish")!, redis.getClient("subscribe")!),
40
- * );
41
- * defineRealtimeConfig({ backplane: { driver: "redis" } }, { backplane: bp });
42
- * ```
43
- *
44
- * Le listener Redis est conservé pour un `unsubscribe(channel, listener)` ciblé
45
- * (n'arrache pas d'éventuels autres abonnés du même client).
38
+ * @param redisChannel - le canal texte effectif ({@link resolveRedisChannel}).
39
+ * @param codec - le codec binaire du serveur.
40
+ * @returns le nom du canal Redis qui porte les enveloppes binaires.
46
41
  */
42
+ function resolveRedisBytesChannel(redisChannel, codec) {
43
+ return `${redisChannel}:${codec.protocol}`;
44
+ }
47
45
  /**
48
46
  * Vrai si la valeur expose la surface d'un client Redis publisher — vérifié au
49
47
  * démarrage, sur le client que rend le service `redis`.
@@ -55,27 +53,77 @@ function isRedisPublisher(value) {
55
53
  function isRedisSubscriber(value) {
56
54
  return isRecord(value) && isCallable(value.subscribe) && isCallable(value.unsubscribe);
57
55
  }
56
+ /**
57
+ * Vrai si la valeur expose la surface d'un client abonné capable du mode octets.
58
+ *
59
+ * La forme ne dit pas si le troisième argument est compris : ce garde vaut pour
60
+ * le client que rend le service `redis` (node-redis, qui l'accepte). Un client qui
61
+ * l'ignorerait livrerait du texte au sujet binaire — illisible pour le codec,
62
+ * donc jeté, compté sous un secret (`sealRejectedTotal`), jamais réinjecté.
63
+ */
64
+ function isRedisBytesSubscriber(value) {
65
+ return isRedisSubscriber(value);
66
+ }
58
67
  /** Ramène l'acquittement d'une commande Redis au contrat du transport. */
59
68
  function settled(ack) {
60
69
  return isThenable(ack) ? ack.then(() => void 0) : void 0;
61
70
  }
62
- function createRedisServiceTransport(publisher, subscriber) {
63
- let listener = null;
64
- return {
71
+ /**
72
+ * Adapte deux clients Redis (publisher + subscriber dédié) au seam
73
+ * {@link IRedisBackplaneTransport}. SEUL point de contact avec la lib `redis` —
74
+ * et même ici le couplage est **structurel** (aucun import). Le consommateur
75
+ * branche typiquement les connexions du module `@nodefony/redis` :
76
+ *
77
+ * ```ts
78
+ * const redis = kernel.get("redis"); // RedisService
79
+ * const bp = new RedisBackplane(
80
+ * createRedisServiceTransport(redis.getClient("publish")!, redis.getClient("subscribe")!),
81
+ * );
82
+ * defineRealtimeConfig({ backplane: { driver: "redis" } }, { backplane: bp });
83
+ * ```
84
+ *
85
+ * Chaque listener Redis est conservé, par canal, pour un `unsubscribe(channel,
86
+ * listener)` ciblé (n'arrache pas d'éventuels autres abonnés du même client).
87
+ *
88
+ * @param publisher - connexion de publication.
89
+ * @param subscriber - connexion d'abonnement dédiée.
90
+ * @param bytesSubscriber - la MÊME connexion d'abonnement, vue en mode octets
91
+ * ({@link IRedisBytesSubscriber}) ; absente, le transport n'offre pas de sujet
92
+ * binaire et les octets restent sur le serveur qui les publie.
93
+ * @returns le transport du {@link RedisBackplane}.
94
+ */
95
+ function createRedisServiceTransport(publisher, subscriber, bytesSubscriber) {
96
+ const listeners = /* @__PURE__ */ new Map();
97
+ const transport = {
65
98
  publish(channel, message) {
66
- const ack = publisher.publish(channel, message);
99
+ const ack = publisher.publish(channel, typeof message === "string" ? message : Buffer.from(message.buffer, message.byteOffset, message.byteLength));
67
100
  return isThenable(ack) ? ack : void 0;
68
101
  },
69
102
  subscribe(channel, onMessage) {
70
- listener = (message) => onMessage(message);
71
- return settled(subscriber.subscribe(channel, listener));
103
+ const fn = (message) => onMessage(message);
104
+ listeners.set(channel, {
105
+ bytes: false,
106
+ fn
107
+ });
108
+ return settled(subscriber.subscribe(channel, fn));
72
109
  },
73
110
  unsubscribe(channel) {
74
- const current = listener;
75
- listener = null;
76
- return settled(current ? subscriber.unsubscribe(channel, current) : subscriber.unsubscribe(channel));
111
+ const current = listeners.get(channel);
112
+ listeners.delete(channel);
113
+ if (current === void 0) return settled(subscriber.unsubscribe(channel));
114
+ if (!current.bytes) return settled(subscriber.unsubscribe(channel, current.fn));
115
+ return settled(bytesSubscriber?.unsubscribe(channel, current.fn, true));
77
116
  }
78
117
  };
118
+ if (bytesSubscriber !== void 0) transport.subscribeBytes = (channel, onMessage) => {
119
+ const fn = (message) => onMessage(message);
120
+ listeners.set(channel, {
121
+ bytes: true,
122
+ fn
123
+ });
124
+ return settled(bytesSubscriber.subscribe(channel, fn, true));
125
+ };
126
+ return transport;
79
127
  }
80
128
  /**
81
129
  * Backplane **Redis pub/sub** (P13.5) — implémentation du port {@link IBackplane}
@@ -119,14 +167,24 @@ var RedisBackplane = class RedisBackplane {
119
167
  #secret;
120
168
  /** Borne mémoire des publications en vol (cf {@link BackplanePublishQueue}). */
121
169
  #queue;
170
+ #notice;
171
+ /** Tri de l'entrée : écho, sceau, refus comptés (brique commune aux pilotes). */
172
+ #ingress;
122
173
  #handler = null;
123
174
  #started = false;
175
+ /**
176
+ * Sujet binaire : son codec et son canal Redis ({@link resolveRedisBytesChannel}) ;
177
+ * `null` tant que {@link enableBytes} n'a pas armé les octets.
178
+ */
179
+ #bytes = null;
124
180
  constructor(transport, originId = resolveBackplaneOriginId(), redisChannel = REDIS_RT_CHANNEL, secret = null, options = {}) {
125
181
  this.#transport = transport;
126
182
  this.originId = originId;
127
183
  this.#redisChannel = redisChannel;
128
184
  this.#secret = secret;
129
- this.#queue = new BackplanePublishQueue(options.maxQueueBytes ?? 8388608, options.onNotice ?? null);
185
+ this.#notice = options.onNotice ?? null;
186
+ this.#queue = new BackplanePublishQueue(options.maxQueueBytes ?? 8388608, this.#notice);
187
+ this.#ingress = new BackplaneIngress(originId, secret, this.#notice);
130
188
  }
131
189
  /**
132
190
  * Abonne au canal Redis dédié. Idempotent (le flag est posé avant l'`await` →
@@ -135,7 +193,32 @@ var RedisBackplane = class RedisBackplane {
135
193
  async start() {
136
194
  if (this.#started) return;
137
195
  this.#started = true;
138
- await this.#transport.subscribe(this.#redisChannel, (raw) => this.#ingress(raw));
196
+ await Promise.all([this.#transport.subscribe(this.#redisChannel, (raw) => this.#receive(raw)), this.#subscribeBytes()]);
197
+ }
198
+ /**
199
+ * Arme le sujet binaire (ADR-0021 S6) : les charges qui contiennent des octets
200
+ * partent en enveloppe encodée par `codec` sur un second canal Redis, et ce
201
+ * canal est écouté. Les charges sans octets gardent le sujet texte, intact pour
202
+ * les exemplaires plus anciens. Idempotent.
203
+ *
204
+ * @param codec - le codec binaire du serveur.
205
+ * @returns `false` si le transport ne sait pas s'abonner en octets.
206
+ */
207
+ enableBytes(codec) {
208
+ if (this.#transport.subscribeBytes === void 0) return false;
209
+ if (this.#bytes !== null) return true;
210
+ this.#bytes = {
211
+ codec,
212
+ channel: resolveRedisBytesChannel(this.#redisChannel, codec)
213
+ };
214
+ if (this.#started) this.#subscribeBytes().catch((err) => this.#notice?.(`backplane : abonnement au sujet binaire en échec — les octets des autres exemplaires ne seront pas reçus (${err instanceof Error ? err.message : String(err)})`, "WARNING"));
215
+ return true;
216
+ }
217
+ /** Abonne le sujet binaire s'il est armé ; sinon rien. */
218
+ async #subscribeBytes() {
219
+ const bytes = this.#bytes;
220
+ if (bytes === null) return;
221
+ await this.#transport.subscribeBytes?.(bytes.channel, (raw) => this.#receiveBytes(raw, bytes.codec));
139
222
  }
140
223
  /**
141
224
  * Propage une publication locale aux autres pods via Redis. NE refait PAS le
@@ -144,7 +227,8 @@ var RedisBackplane = class RedisBackplane {
144
227
  * L'envoi passe par la **file bornée** : si le bus n'acquitte plus, les
145
228
  * publications suivantes sont jetées et comptées plutôt que d'empiler des
146
229
  * mégaoctets dans le client Redis (sémantique at-most-once déjà assumée par le
147
- * port ; le client realtime re-synchronise).
230
+ * port ; le client realtime re-synchronise). Un ordre de contrôle, lui, part
231
+ * toujours (cf {@link BackplanePublishQueue.send}).
148
232
  */
149
233
  publish(channel, payload) {
150
234
  const env = {
@@ -152,30 +236,48 @@ var RedisBackplane = class RedisBackplane {
152
236
  payload,
153
237
  originId: this.originId
154
238
  };
239
+ const bytes = this.#bytes;
240
+ if (bytes !== null && containsBytes(payload)) {
241
+ const bin = sealBackplaneEnvelopeBytes(env, bytes.codec, this.#secret);
242
+ this.#queue.send(channel, bin.byteLength, () => this.#transport.publish(bytes.channel, bin));
243
+ return;
244
+ }
155
245
  const raw = sealBackplaneEnvelope(env, this.#secret);
156
- this.#queue.send(Buffer.byteLength(raw), () => this.#transport.publish(this.#redisChannel, raw));
246
+ this.#queue.send(channel, Buffer.byteLength(raw), () => this.#transport.publish(this.#redisChannel, raw));
157
247
  }
158
248
  /** Un seul handler d'ingress ; un appel ultérieur remplace le précédent. */
159
249
  onMessage(handler) {
160
250
  this.#handler = handler;
161
251
  }
162
252
  /**
163
- * Ouverture d'enveloppe (parse + vérification du sceau) + anti-echo + délégation.
164
- * Robuste par construction : un message malformé, non scellé alors qu'un secret
165
- * est exigé, ou au sceau invalide, est **ignoré** — jamais réinjecté, jamais fatal.
253
+ * Remet au hub ce que le tri d'entrée admet ({@link BackplaneIngress}) : l'écho
254
+ * de ce pod (Redis renvoie à l'émetteur) est compté sans être décodé ; un message
255
+ * malformé ou au sceau refusé est **ignoré** — jamais réinjecté, jamais fatal —,
256
+ * compté et annoncé la première fois puis une fois sur cent (ADR-0021 S6).
166
257
  */
167
- #ingress(raw) {
168
- const msg = openBackplaneEnvelope(raw, this.#secret);
169
- if (msg === null) return;
170
- if (msg.originId === this.originId) return;
171
- this.#handler?.(msg);
258
+ #receive(raw) {
259
+ const msg = this.#ingress.text(raw);
260
+ if (msg !== null) this.#handler?.(msg);
261
+ }
262
+ /** Même tri que {@link #receive}, sur le sujet binaire. */
263
+ #receiveBytes(raw, codec) {
264
+ const msg = this.#ingress.bytes(raw, codec);
265
+ if (msg !== null) this.#handler?.(msg);
172
266
  }
173
- /** Désabonne du canal Redis et détache le handler. Idempotent. */
267
+ /**
268
+ * Laisse partir les publications déjà émises (acquittées ou refusées par Redis), puis désabonne du canal et détache le handler. Idempotent.
269
+ *
270
+ * Sans l'attente, un arrêt suivi de la sortie du processus perdait ce qui
271
+ * était encore dans la file — la preuve de vie FINALE de la présence comprise,
272
+ * et les pairs gardaient alors ce pod jusqu'au délai d'oubli. Un Redis qui ne
273
+ * répond plus fait attendre : l'appelant borne (l'arrêt du module le fait).
274
+ */
174
275
  async stop() {
175
276
  this.#handler = null;
176
277
  if (!this.#started) return;
177
278
  this.#started = false;
178
- await this.#transport.unsubscribe(this.#redisChannel);
279
+ await this.#queue.settled();
280
+ await Promise.all([this.#transport.unsubscribe(this.#redisChannel), this.#bytes !== null ? this.#transport.unsubscribe(this.#bytes.channel) : void 0]);
179
281
  }
180
282
  describe() {
181
283
  return {
@@ -185,8 +287,11 @@ var RedisBackplane = class RedisBackplane {
185
287
  crossPod: true,
186
288
  channel: this.#redisChannel,
187
289
  sealed: this.#secret !== null,
188
- queue: this.#queue.describe()
290
+ ...this.#secret !== null && { sealRejectedTotal: this.#ingress.sealRejectedTotal },
291
+ echoTotal: this.#ingress.echoTotal,
292
+ queue: this.#queue.describe(),
293
+ ...this.#bytes !== null && { bytes: true }
189
294
  };
190
295
  }
191
296
  };
192
- export { REDIS_RT_CHANNEL, RedisBackplane, RedisBackplane as default, createRedisServiceTransport, isRedisPublisher, isRedisSubscriber, resolveRedisChannel };
297
+ export { REDIS_RT_CHANNEL, RedisBackplane, RedisBackplane as default, createRedisServiceTransport, isRedisBytesSubscriber, isRedisPublisher, isRedisSubscriber, resolveRedisBytesChannel, resolveRedisChannel };
@@ -0,0 +1,86 @@
1
+ import { ownField } from "nodefony";
2
+ // nodefony/src/backplane/controlOrder.ts
3
+ /**
4
+ * Ordres de contrôle entre serveurs — la LISTE FERMÉE de ce qui circule sur le
5
+ * canal `PLATFORM_BUS_CHANNELS.control` (ADR-0022 §6 décision 4).
6
+ *
7
+ * Un ordre ne fait qu'ENLEVER : terminer un canal, éjecter un compte d'un
8
+ * canal. Aucun n'admet — seul `allow` fait entrer. Il voyage dans la charge
9
+ * d'un message ordinaire du bus, donc sous le sceau de l'enveloppe
10
+ * (`envelope.ts#sealBackplaneEnvelope`), sans champ neuf dans l'enveloppe ni
11
+ * méthode neuve dans `IBackplane` : un driver tiers le transporte sans le savoir.
12
+ */
13
+ /** Longueur maximale d'un champ texte d'un ordre (nom de canal, identifiant de compte). */
14
+ const MAX_CONTROL_FIELD_LENGTH = 1024;
15
+ /**
16
+ * Un texte venu du bus est-il recevable — chaîne non vide d'au plus {@link MAX_CONTROL_FIELD_LENGTH} caractères ?
17
+ *
18
+ * Seule borne des textes du bus (nom de canal, identifiant de compte), lue par
19
+ * les ordres de contrôle ET par les messages de présence (ADR-0025 P2).
20
+ *
21
+ * @param value - champ reçu (forme inconnue).
22
+ */
23
+ function isBoundedBusText(value) {
24
+ return typeof value === "string" && value.length > 0 && value.length <= 1024;
25
+ }
26
+ /** Les types d'ordre que cette version sait appliquer — liste fermée. */
27
+ function isKnownOp(op) {
28
+ return op === "close" || op === "evictUser";
29
+ }
30
+ /**
31
+ * La charge est-elle un ordre d'un TYPE que cette version ne connaît pas — `op` lisible, mais hors de la liste fermée ?
32
+ *
33
+ * C'est ce qu'émet un serveur d'une version plus récente pendant un
34
+ * déploiement progressif (ADR-0025, « Règle de lecture du bus ») : l'ordre
35
+ * n'est pas appliqué, mais il ne se compte pas comme une injection. Rien
36
+ * d'autre que `op` n'est lu — ses autres champs appartiennent à une version
37
+ * qui n'est pas celle-ci. Une charge sans `op` textuel, ou d'un type CONNU de
38
+ * forme fausse, n'est pas « inconnue » : elle est invalide.
39
+ *
40
+ * @param payload - la charge du message reçu (forme inconnue).
41
+ * @returns `true` si le type d'ordre est inconnu de cette version.
42
+ */
43
+ function isUnknownControlOrder(payload) {
44
+ const op = ownField(payload, "op");
45
+ return isBoundedBusText(op) && !isKnownOp(op);
46
+ }
47
+ /**
48
+ * Lit un ordre de contrôle dans une charge venue du bus — `null` si la forme n'est pas celle d'un ordre connu.
49
+ *
50
+ * Une seule garde pour les deux bords : l'émetteur la passe avant de publier
51
+ * (il n'émet jamais ce que ses pairs refuseraient), le récepteur avant
52
+ * d'appliquer. Lecture par champs PROPRES (`ownField`) : une charge dont le
53
+ * prototype porterait `op` ne passe pas. Un champ inconnu est toléré — c'est
54
+ * ce qui laisse une version suivante ajouter un champ sans faire refuser ses
55
+ * ordres pendant un déploiement progressif ; un champ CONNU de forme fausse
56
+ * fait refuser l'ordre entier.
57
+ *
58
+ * @param payload - la charge du message reçu (forme inconnue).
59
+ * @returns l'ordre, ou `null`.
60
+ */
61
+ function readControlOrder(payload) {
62
+ const channel = ownField(payload, "channel");
63
+ if (!isBoundedBusText(channel)) return null;
64
+ const op = ownField(payload, "op");
65
+ if (!isKnownOp(op)) return null;
66
+ if (op === "close") return {
67
+ op,
68
+ channel
69
+ };
70
+ const user = ownField(payload, "user");
71
+ if (!isBoundedBusText(user)) return null;
72
+ const generation = ownField(payload, "generation");
73
+ if (generation === void 0) return {
74
+ op,
75
+ channel,
76
+ user
77
+ };
78
+ if (typeof generation !== "number" || !Number.isSafeInteger(generation) || generation < 0) return null;
79
+ return {
80
+ op,
81
+ channel,
82
+ user,
83
+ generation
84
+ };
85
+ }
86
+ export { MAX_CONTROL_FIELD_LENGTH, isBoundedBusText, isUnknownControlOrder, readControlOrder };