@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,8 +1,11 @@
1
- import { isRealtimeConfig } from "../../config/defineModuleConfig.js";
2
1
  import { RESERVED_SYSTEM_PREFIXES, getRealtimeHub } from "../server/RealtimeHub.js";
2
+ import { realtimeConnectSource } from "./publicOrigin.js";
3
3
  import { applyDeclaredBroadcastPrefixes } from "../../decorators/realtimeDecorators.js";
4
+ import { isRealtimeConfig } from "../../config/defineModuleConfig.js";
4
5
  import { Service } from "nodefony";
5
6
  // nodefony/src/service/RealtimeService.ts
7
+ /** Nom sous lequel ce module contribue à la politique de contenu. */
8
+ const CSP_CONTRIBUTOR = "realtime";
6
9
  const serviceName = "realtimeService";
7
10
  /**
8
11
  * Façade DI publique de la **socket Nodefony** côté serveur.
@@ -70,7 +73,17 @@ var RealtimeService = class extends Service {
70
73
  }
71
74
  hub.setOriginGuard(buildOriginGuard(this.#config));
72
75
  hub.setMaxChannelsPerConnection(this.#config.limits.maxChannelsPerConnection);
76
+ hub.setAdmissionLimits({
77
+ authenticateTimeoutMs: this.#config.authenticateTimeoutMs,
78
+ allowTimeoutMs: this.#config.allowTimeoutMs,
79
+ maxPendingAllowsPerConnection: this.#config.maxPendingAllowsPerConnection,
80
+ maxPendingAllowsTotal: this.#config.maxPendingAllowsTotal
81
+ });
73
82
  hub.setSlowConsumerBytes(this.#config.slowConsumer.bytes);
83
+ if (this.#config.codec === "cbor") {
84
+ const { CBOR_FRAME_CODEC } = await import("nodefony/cbor");
85
+ hub.setFrameCodec(CBOR_FRAME_CODEC);
86
+ } else hub.setFrameCodec();
74
87
  hub.setClientLogsLimits(this.#config.clientLogs.enabled ? {
75
88
  maxEntriesPerBatch: this.#config.clientLogs.maxEntriesPerBatch,
76
89
  maxEntriesPerWindow: this.#config.clientLogs.maxEntriesPerWindow,
@@ -78,8 +91,26 @@ var RealtimeService = class extends Service {
78
91
  maxStringLength: this.#config.clientLogs.maxStringLength
79
92
  } : null);
80
93
  applyDeclaredBroadcastPrefixes(hub);
94
+ if (this.#config.publicOrigin !== void 0) this.#declareCspOrigin(this.#config.publicOrigin);
81
95
  return this;
82
96
  }
97
+ /**
98
+ * Déclare au pare-feu l'origine publique de la socket dans `connect-src`.
99
+ *
100
+ * Différé à `onReady` : au moment de `init`, le module de sécurité peut ne pas
101
+ * avoir encore enregistré son pare-feu (ordre des modules). Sans pare-feu
102
+ * (application sans `@nodefony/security`), aucune politique n'est posée :
103
+ * rien à déclarer.
104
+ *
105
+ * @param publicOrigin - origine nue validée par le schéma
106
+ */
107
+ #declareCspOrigin(publicOrigin) {
108
+ const source = realtimeConnectSource(publicOrigin);
109
+ if (source === null) return;
110
+ this.kernel?.once("onReady", () => {
111
+ this.get("firewall")?.registerCspOrigins?.(CSP_CONTRIBUTOR, { "connect-src": [source] });
112
+ });
113
+ }
83
114
  /** Configuration realtime gelée (lue par Studio / introspection). */
84
115
  getConfig() {
85
116
  if (!this.#config) throw new Error(`${serviceName}: not initialized`);
@@ -89,6 +120,14 @@ var RealtimeService = class extends Service {
89
120
  getHub() {
90
121
  return this.#hub ??= getRealtimeHub();
91
122
  }
123
+ /**
124
+ * Le codec texte des connexions socket, lu PAR NOM par le REST du plan
125
+ * d'administration (`AdminApiController`, framework ne dépend pas de realtime) :
126
+ * les octets d'une réponse `GET` y prennent la même forme que sur la socket.
127
+ */
128
+ get textFrameCodec() {
129
+ return this.getHub().textFrameCodec;
130
+ }
92
131
  /** Backplane actuellement branché sur le hub (ou null = Loopback implicite). */
93
132
  getBackplane() {
94
133
  return this.getHub().backplane;
@@ -96,6 +135,8 @@ var RealtimeService = class extends Service {
96
135
  /**
97
136
  * Publie une charge sur un canal — fan-out local + propagation backplane si
98
137
  * canal broadcast. Délégué à `RealtimeHub.publish`.
138
+ *
139
+ * @throws TypeError si la charge est un scalaire ou `null` (cf `isRealtimePayload`).
99
140
  */
100
141
  publish(channel, payload) {
101
142
  this.getHub().publish(channel, payload);
@@ -207,6 +248,15 @@ var RealtimeService = class extends Service {
207
248
  return this.getHub().resolveChannelPolicy(channel);
208
249
  }
209
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
+ /**
210
260
  * Renvoie le token associé à un peer (posé au handshake par le hub). Jamais
211
261
  * `null` (Zero Trust : `ANONYMOUS_REALTIME_TOKEN` si aucun authenticator n'a
212
262
  * capturé la connexion). Lookup O(1) via WeakMap interne. Branchement P6 :
@@ -0,0 +1,40 @@
1
+ import { parseBareOrigin } from "nodefony";
2
+ // nodefony/src/service/publicOrigin.ts
3
+ /**
4
+ * Schémas qu'accepte `realtime.publicOrigin` : celui de la socket (`ws:`,
5
+ * `wss:`) ou celui de l'hôte qui la sert (`http:`, `https:`), ramené au
6
+ * premier.
7
+ */
8
+ const REALTIME_ORIGIN_PROTOCOLS = [
9
+ "ws:",
10
+ "wss:",
11
+ "http:",
12
+ "https:"
13
+ ];
14
+ /** Le schéma de socket qui correspond à chaque schéma accepté. */
15
+ const SOCKET_SCHEME = {
16
+ "ws:": "ws:",
17
+ "wss:": "wss:",
18
+ "http:": "ws:",
19
+ "https:": "wss:"
20
+ };
21
+ /**
22
+ * Rend la source `connect-src` qui autorise la socket servie à `publicOrigin`,
23
+ * toujours sous son schéma de socket.
24
+ *
25
+ * Une source `wss://hôte` couvre `wss:` ET `https:` du même hôte (CSP Level 3,
26
+ * « scheme-part match ») ; une source `https://hôte` ne couvrirait PAS la
27
+ * socket. D'où la conversion `https:` → `wss:`, `http:` → `ws:`.
28
+ *
29
+ * @param publicOrigin - origine nue où la socket est servie
30
+ * @returns la source à déclarer (`wss://rt.example.com:8443`), ou `null` si la
31
+ * valeur n'est pas une origine nue d'un schéma accepté
32
+ */
33
+ function realtimeConnectSource(publicOrigin) {
34
+ const url = parseBareOrigin(publicOrigin, REALTIME_ORIGIN_PROTOCOLS);
35
+ if (url === null) return null;
36
+ const scheme = SOCKET_SCHEME[url.protocol];
37
+ if (scheme === void 0) return null;
38
+ return `${scheme}//${url.host}`;
39
+ }
40
+ export { REALTIME_ORIGIN_PROTOCOLS, realtimeConnectSource };
@@ -24,6 +24,16 @@ import { decideSend } from "@nodefony/http";
24
24
  * la fermeture est signalée par `fireClose()` (hook `onFinish` du Context). Le
25
25
  * `connect`/`onOpen` sont des no-op (la connexion est déjà ouverte au handshake).
26
26
  */
27
+ /**
28
+ * Vrai si les octets reposent sur un `ArrayBuffer` ordinaire — ce que le pair
29
+ * et ses codecs reçoivent (`RealtimeFrameData`), jamais un `SharedArrayBuffer`.
30
+ *
31
+ * @param bytes - les octets reçus.
32
+ * @returns `true` si la vue porte sur un `ArrayBuffer`.
33
+ */
34
+ function isOwnedBytes(bytes) {
35
+ return bytes.buffer instanceof ArrayBuffer;
36
+ }
27
37
  var WsConnectionTransport = class {
28
38
  conn;
29
39
  _onMessage = null;
@@ -78,7 +88,7 @@ var WsConnectionTransport = class {
78
88
  * Compte une trame ENTRANTE écartée et rend le cumul de sa nature — `1` dit
79
89
  * « première », le seul instant où l'appelant journalise.
80
90
  *
81
- * @param kind - `binary` (sans lecteur) ou `malformed` (texte non JSON).
91
+ * @param kind - `binary` (sans lecteur) ou `malformed` (illisible par le codec).
82
92
  * @returns le cumul de cette nature, après incrément.
83
93
  */
84
94
  discard(kind) {
@@ -88,7 +98,7 @@ var WsConnectionTransport = class {
88
98
  get binaryDiscarded() {
89
99
  return this._binaryDiscarded;
90
100
  }
91
- /** Cumul de trames TEXTE reçues et écartées parce que non JSON — monotone. */
101
+ /** Cumul de trames reçues et écartées parce qu'illisibles par le codec de la connexion — monotone. */
92
102
  get malformedDiscarded() {
93
103
  return this._malformedDiscarded;
94
104
  }
@@ -122,9 +132,22 @@ var WsConnectionTransport = class {
122
132
  this._onClose = cb;
123
133
  }
124
134
  onError() {}
125
- /** Pump entrant : le pipeline framework pousse ici chaque message reçu. */
135
+ /**
136
+ * Pump entrant : le pipeline framework pousse ici chaque frame reçue — texte,
137
+ * ou octets d'une connexion qui a négocié un codec binaire (ADR-0022 §2).
138
+ *
139
+ * Une vue sur un `SharedArrayBuffer` n'est pas une trame : `ws` livre
140
+ * toujours un `Buffer` sur un `ArrayBuffer`, le type de Node l'admet
141
+ * seulement. Elle est comptée comme illisible, jamais transmise au pair.
142
+ *
143
+ * @param raw - la trame telle que reçue.
144
+ */
126
145
  feed(raw) {
127
- this._onMessage?.(raw);
146
+ if (typeof raw === "string" || isOwnedBytes(raw)) {
147
+ this._onMessage?.(raw);
148
+ return;
149
+ }
150
+ this._malformedDiscarded += 1;
128
151
  }
129
152
  /** Fermeture : appelée par le owner sur `onFinish` (close WS). */
130
153
  fireClose(code = 1e3, reason = "") {
@@ -0,0 +1,347 @@
1
+ import { RealtimeHub } from "../src/server/RealtimeHub.js";
2
+ import { readControlOrder } from "../src/backplane/controlOrder.js";
3
+ import "../src/backplane/publishQueue.js";
4
+ import { presenceClient, presenceView } from "./presenceClient.js";
5
+ import { PLATFORM_BUS_CHANNELS, PLATFORM_CHANNELS, ownField } from "nodefony";
6
+ import { randomBytes, randomUUID } from "node:crypto";
7
+ import assert from "node:assert/strict";
8
+ // nodefony/testing/backplaneConformance.ts
9
+ /**
10
+ * Suite de conformité d'un **driver de backplane** — la définition exécutable du
11
+ * contrat {@link IBackplane}, une seule pour tous les drivers (ADR-0022 §6,
12
+ * décision 4).
13
+ *
14
+ * Un driver écrit par un tiers (journal partitionné, bus de messages…) compile et
15
+ * démarre même mal réglé : un groupe de lecteurs partagé entre serveurs répartit
16
+ * les messages au lieu de les diffuser, une lecture depuis le début du journal
17
+ * rejoue des fins de partie sur des parties rouvertes. Rien ne lève, tout se perd
18
+ * en silence. Cette suite rend ces défauts visibles : elle relie plusieurs pairs du
19
+ * driver sur un même bus et vérifie ce que le hub tient pour acquis.
20
+ *
21
+ * ```ts
22
+ * import { describe, it } from "vitest";
23
+ * import { runBackplaneConformance } from "@nodefony/realtime/testing";
24
+ *
25
+ * runBackplaneConformance("mon-bus", (secret) => new MonBackplane({ url, secret }), { describe, it });
26
+ * ```
27
+ *
28
+ * Le lanceur de tests est PASSÉ, jamais importé : le paquet publié ne dépend
29
+ * d'aucun lanceur, et la suite tourne sous vitest, `node:test` ou `bun test`. Les
30
+ * assertions passent par `node:assert`, qu'ils rapportent tous.
31
+ *
32
+ * @packageDocumentation
33
+ */
34
+ /** Noms des cas — stables : un test négatif les désigne. */
35
+ const BACKPLANE_CONFORMANCE_CASES = {
36
+ fanout: "diffusion : chaque pair reçoit TOUT, l'émetteur ne reçoit pas son écho",
37
+ order: "ordre par émetteur : les publications d'un pair arrivent dans l'ordre",
38
+ noReplay: "pas de rejeu : un pair démarré après une publication ne la reçoit pas",
39
+ seal: "sceau : un message non scellé ou mal scellé est refusé",
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",
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",
45
+ replaceHandler: "onMessage remplace le handler précédent",
46
+ stop: "stop coupe la réception, et se rappelle sans erreur"
47
+ };
48
+ const POLL_MS = 5;
49
+ /** Charges de contrôle de fidélité : objet imbriqué, tableau, texte non ASCII. */
50
+ function sample(seq) {
51
+ return {
52
+ seq,
53
+ text: "é — 😀",
54
+ list: [
55
+ seq,
56
+ "a",
57
+ null,
58
+ { deep: true }
59
+ ]
60
+ };
61
+ }
62
+ function sleep(ms) {
63
+ return new Promise((resolve) => setTimeout(resolve, ms));
64
+ }
65
+ function seqOf(payload) {
66
+ return typeof payload === "object" && payload !== null && "seq" in payload ? payload.seq : void 0;
67
+ }
68
+ function isFrom(payload, from) {
69
+ return typeof payload === "object" && payload !== null && "from" in payload && payload.from === from;
70
+ }
71
+ /** Attend qu'une condition tienne, ou échoue en nommant ce qui manquait (lu à l'échec). */
72
+ async function until(done, timeoutMs, what) {
73
+ const deadline = Date.now() + timeoutMs;
74
+ while (!done()) {
75
+ if (Date.now() > deadline) assert.fail(`délai dépassé (${timeoutMs} ms) : ${what()}`);
76
+ await sleep(POLL_MS);
77
+ }
78
+ }
79
+ /**
80
+ * Boîte de réception d'un pair, filtrée sur les canaux du cas courant. Posée
81
+ * AVANT `start`, comme le hub le fait : un driver qui livrerait dès l'abonnement
82
+ * (un rejeu, par exemple) est vu.
83
+ */
84
+ var Inbox = class {
85
+ messages = [];
86
+ #prefix;
87
+ constructor(peer, prefix) {
88
+ this.#prefix = prefix;
89
+ peer.onMessage((msg) => {
90
+ if (msg.channel.startsWith(this.#prefix)) this.messages.push(msg);
91
+ });
92
+ }
93
+ payloads() {
94
+ return this.messages.map((m) => m.payload);
95
+ }
96
+ };
97
+ /**
98
+ * Déclare la suite de conformité d'un driver de backplane auprès du lanceur de
99
+ * tests de l'appelant.
100
+ *
101
+ * Vérifie, sur plusieurs pairs reliés au même bus : la diffusion à TOUS les pairs
102
+ * sans écho, l'ordre par émetteur, l'absence de rejeu au démarrage, le sceau
103
+ * (message non scellé ou mal scellé refusé), le transport des ordres de contrôle,
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.
107
+ *
108
+ * @param name - nom du driver, affiché dans le rapport.
109
+ * @param makePeer - fabrique d'un pair relié au bus commun.
110
+ * @param options - le lanceur (`describe`, `it`) et les réglages.
111
+ */
112
+ function runBackplaneConformance(name, makePeer, options) {
113
+ const sealed = options.sealed ?? true;
114
+ const quietMs = options.quietMs ?? 100;
115
+ const timeoutMs = options.timeoutMs ?? 2e3;
116
+ const floodBytes = options.floodBytes ?? 16777216;
117
+ const secret = sealed ? randomBytes(32).toString("hex") : null;
118
+ const C = BACKPLANE_CONFORMANCE_CASES;
119
+ /**
120
+ * Cadre d'un cas : pairs ouverts puis tous arrêtés, et un préfixe de canal
121
+ * unique — un bus partagé entre cas ne mélange jamais leurs messages.
122
+ */
123
+ const scenario = (body) => {
124
+ return async () => {
125
+ const peers = [];
126
+ const prefix = `conformance:${randomUUID()}:`;
127
+ const open = async (peerSecret = secret) => {
128
+ const peer = await makePeer(peerSecret);
129
+ peers.push(peer);
130
+ const inbox = new Inbox(peer, prefix);
131
+ await peer.start();
132
+ return {
133
+ peer,
134
+ inbox
135
+ };
136
+ };
137
+ try {
138
+ await body(open, prefix);
139
+ } finally {
140
+ await Promise.all(peers.map(async (p) => {
141
+ await p.stop();
142
+ }));
143
+ }
144
+ };
145
+ };
146
+ options.describe(`conformité du backplane « ${name} »`, () => {
147
+ options.it(C.fanout, scenario(async (open, prefix) => {
148
+ const a = await open();
149
+ const b = await open();
150
+ const c = await open();
151
+ const sent = Array.from({ length: 10 }, (_, i) => sample(i));
152
+ for (const p of sent) a.peer.publish(`${prefix}fanout`, p);
153
+ await until(() => b.inbox.messages.length >= sent.length && c.inbox.messages.length >= sent.length, timeoutMs, () => `B et C attendaient ${sent.length} messages, reçus ${b.inbox.messages.length} et ${c.inbox.messages.length} — le bus répartit-il les messages entre pairs ?`);
154
+ b.peer.publish(`${prefix}fanout`, { seq: -1 });
155
+ await until(() => a.inbox.messages.some((m) => m.originId === b.peer.originId), timeoutMs, () => "A n'a pas reçu la sentinelle de B");
156
+ await sleep(quietMs);
157
+ assert.deepEqual(b.inbox.payloads(), sent, "B : charges reçues fidèlement");
158
+ assert.deepEqual(c.inbox.messages.filter((m) => m.originId === a.peer.originId).map((m) => m.payload), sent, "C : charges reçues fidèlement");
159
+ for (const m of b.inbox.messages) assert.equal(m.originId, a.peer.originId);
160
+ assert.ok(a.inbox.messages.every((m) => m.originId === b.peer.originId), "l'émetteur a reçu son propre écho");
161
+ }));
162
+ options.it(C.order, scenario(async (open, prefix) => {
163
+ const a = await open();
164
+ const b = await open();
165
+ const count = 100;
166
+ for (let i = 0; i < count; i += 1) a.peer.publish(`${prefix}order`, { seq: i });
167
+ await until(() => b.inbox.messages.length >= count, timeoutMs, () => `B attendait ${count} messages, reçus ${b.inbox.messages.length}`);
168
+ assert.deepEqual(b.inbox.payloads().map(seqOf), Array.from({ length: count }, (_, i) => i), "ordre d'émission perdu — la clé de partition doit être l'émetteur, jamais le canal");
169
+ }));
170
+ options.it(C.noReplay, scenario(async (open, prefix) => {
171
+ const a = await open();
172
+ const witness = await open();
173
+ a.peer.publish(`${prefix}replay`, { seq: 0 });
174
+ await until(() => witness.inbox.messages.length === 1, timeoutMs, () => "le témoin n'a pas reçu la première publication");
175
+ const late = await open();
176
+ a.peer.publish(`${prefix}replay`, { seq: 1 });
177
+ await until(() => late.inbox.messages.some((m) => seqOf(m.payload) === 1), timeoutMs, () => "le pair démarré en retard n'a pas reçu la publication suivante");
178
+ await sleep(quietMs);
179
+ assert.deepEqual(late.inbox.payloads().map(seqOf), [1], "rejeu au démarrage — lire à partir du dernier message, jamais depuis le début");
180
+ }));
181
+ options.it(C.seal, scenario(async (open, prefix) => {
182
+ if (!sealed) {
183
+ const { peer } = await open(null);
184
+ assert.notEqual(peer.describe().crossPod, true, "un bus qui quitte la machine (crossPod) doit sceller : retirer `sealed: false`");
185
+ return;
186
+ }
187
+ const receiver = await open();
188
+ const bare = await open(null);
189
+ const forged = await open(randomBytes(32).toString("hex"));
190
+ const genuine = await open();
191
+ bare.peer.publish(`${prefix}seal`, { from: "non scellé" });
192
+ forged.peer.publish(`${prefix}seal`, { from: "autre secret" });
193
+ genuine.peer.publish(`${prefix}seal`, { from: "scellé" });
194
+ await until(() => receiver.inbox.messages.some((m) => isFrom(m.payload, "scellé")), timeoutMs, () => "le message scellé du bon secret n'est pas arrivé");
195
+ await sleep(quietMs);
196
+ assert.deepEqual(receiver.inbox.payloads(), [{ from: "scellé" }], "un message non scellé ou mal scellé a été accepté");
197
+ }));
198
+ options.it(C.control, scenario(async (open, prefix) => {
199
+ const a = await open();
200
+ const b = await open();
201
+ const got = [];
202
+ b.peer.onMessage((msg) => {
203
+ if (msg.channel !== PLATFORM_BUS_CHANNELS.control) return;
204
+ const order = readControlOrder(msg.payload);
205
+ if (order?.channel.startsWith(prefix) === true) got.push(order);
206
+ });
207
+ const orders = [{
208
+ op: "close",
209
+ channel: `${prefix}room`
210
+ }, {
211
+ op: "evictUser",
212
+ channel: `${prefix}chat`,
213
+ user: "u-42",
214
+ generation: 3
215
+ }];
216
+ for (const o of orders) a.peer.publish(PLATFORM_BUS_CHANNELS.control, o);
217
+ await until(() => got.length >= orders.length, timeoutMs, () => `ordres de contrôle reçus : ${got.length} sur ${orders.length}`);
218
+ assert.deepEqual(got, orders);
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."));
251
+ options.it(C.reserved, scenario(async (open, prefix) => {
252
+ const a = await open();
253
+ const b = await open();
254
+ const hubA = new RealtimeHub();
255
+ const hubB = new RealtimeHub();
256
+ try {
257
+ hubA.setBackplane(a.peer);
258
+ hubB.setBackplane(b.peer);
259
+ hubA.markBroadcastChannel(prefix);
260
+ hubB.markBroadcastChannel(prefix);
261
+ const noop = () => () => {};
262
+ const channel = `${prefix}room`;
263
+ const gotA = [];
264
+ const gotB = [];
265
+ const audit = [];
266
+ hubA.subscribe(channel, (p) => gotA.push(p), noop);
267
+ hubB.subscribe(channel, (p) => gotB.push(p), noop);
268
+ hubB.subscribe(PLATFORM_CHANNELS.audit, (p) => audit.push(p), noop);
269
+ a.peer.publish(PLATFORM_CHANNELS.audit, { forged: "faux évènement d'audit" });
270
+ hubA.publish(channel, { ok: 1 });
271
+ await until(() => gotB.length > 0, timeoutMs, () => "la diffusion déclarée n'a pas atteint le hub B");
272
+ await sleep(quietMs);
273
+ assert.deepEqual(audit, [], "un canal réservé a atteint les abonnés locaux");
274
+ assert.equal(hubB.probe().ingressRejectedTotal, 1, "rejet non compté");
275
+ assert.deepEqual(gotB, [{ ok: 1 }]);
276
+ assert.deepEqual(gotA, [{ ok: 1 }], "message renvoyé sur le bus (boucle)");
277
+ } finally {
278
+ hubA.clear();
279
+ hubB.clear();
280
+ }
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
+ }));
320
+ options.it(C.replaceHandler, scenario(async (open, prefix) => {
321
+ const a = await open();
322
+ const b = await open();
323
+ const second = [];
324
+ b.peer.onMessage((m) => {
325
+ if (m.channel.startsWith(prefix)) second.push(m.payload);
326
+ });
327
+ a.peer.publish(`${prefix}handler`, { seq: 0 });
328
+ await until(() => second.length > 0, timeoutMs, () => "le second handler n'a rien reçu");
329
+ await sleep(quietMs);
330
+ assert.equal(b.inbox.messages.length, 0, "l'ancien handler reçoit encore");
331
+ assert.deepEqual(second, [{ seq: 0 }]);
332
+ }));
333
+ options.it(C.stop, scenario(async (open, prefix) => {
334
+ const a = await open();
335
+ const b = await open();
336
+ const witness = await open();
337
+ await b.peer.stop();
338
+ await b.peer.stop();
339
+ const after = new Inbox(b.peer, prefix);
340
+ a.peer.publish(`${prefix}stop`, { seq: 0 });
341
+ await until(() => witness.inbox.messages.length > 0, timeoutMs, () => "le témoin n'a pas reçu la publication");
342
+ await sleep(quietMs);
343
+ assert.equal(after.messages.length, 0, "un pair arrêté reçoit encore");
344
+ }));
345
+ });
346
+ }
347
+ export { BACKPLANE_CONFORMANCE_CASES, runBackplaneConformance };
@@ -1,6 +1,9 @@
1
+ import "../src/server/RealtimeController.js";
1
2
  import { isRecord } from "../src/guards.js";
3
+ import { createAdmissionResolver, registerControllerPolicies } from "../src/server/channelPolicyRegistry.js";
2
4
  import { getRealtimeHub } from "../src/server/RealtimeHub.js";
3
- import { RequestContext } from "nodefony";
5
+ import { BACKPLANE_CONFORMANCE_CASES, runBackplaneConformance } from "./backplaneConformance.js";
6
+ import { IDENTITY_DEAD_CLOSE_CODE, ORIGIN_REFUSED_CLOSE_CODE, RequestContext } from "nodefony";
4
7
  // nodefony/testing/index.ts
5
8
  /**
6
9
  * `@nodefony/realtime/testing` — **harnais de test d'un controller temps réel**.
@@ -61,7 +64,8 @@ async function waitFor(predicate, ticks) {
61
64
  const DENIED_REASONS = {
62
65
  forbidden: true,
63
66
  limit: true,
64
- unknown: true
67
+ unknown: true,
68
+ unavailable: true
65
69
  };
66
70
  function isDeniedReason(reason) {
67
71
  return Object.hasOwn(DENIED_REASONS, reason);
@@ -114,6 +118,8 @@ function readDenied(params) {
114
118
  };
115
119
  throw new Error(`createRealtimeHarness: realtime:denied malformé — ${JSON.stringify(params)}`);
116
120
  }
121
+ /** Compteur des vhosts de harnais — un par harnais à identité, jamais réutilisé. */
122
+ let harnessCount = 0;
117
123
  /**
118
124
  * Monte un controller temps réel sur une fausse connexion et rend de quoi le
119
125
  * piloter.
@@ -129,9 +135,18 @@ function readDenied(params) {
129
135
  function createRealtimeHarness(create, options = {}) {
130
136
  const hub = getRealtimeHub();
131
137
  if (options.resetHub !== false) hub.clear();
138
+ let headers = options.headers ?? {};
132
139
  if (options.identity !== void 0) {
133
140
  const token = options.identity;
134
- hub.useAuthenticator({ pattern: /.*/u }, {
141
+ const host = headers["host"] ?? `harness-${++harnessCount}.invalid`;
142
+ headers = {
143
+ ...headers,
144
+ host
145
+ };
146
+ hub.useAuthenticator({
147
+ pattern: /.*/u,
148
+ host
149
+ }, {
135
150
  name: "harness",
136
151
  supports: () => true,
137
152
  authenticate: () => Promise.resolve(token)
@@ -159,12 +174,13 @@ function createRealtimeHarness(create, options = {}) {
159
174
  };
160
175
  const controller = create({
161
176
  type: "websocket",
177
+ outboundTracer: () => null,
162
178
  connection,
163
179
  once: (event, fn) => {
164
180
  if (event === "onFinish") onFinish = fn;
165
181
  },
166
182
  request: {
167
- headers: options.headers ?? {},
183
+ headers,
168
184
  url
169
185
  },
170
186
  cookies: options.cookies ?? {},
@@ -172,6 +188,8 @@ function createRealtimeHarness(create, options = {}) {
172
188
  remoteAddress: "127.0.0.1",
173
189
  origin: options.origin ?? ""
174
190
  });
191
+ const admissions = options.admissions;
192
+ if (admissions !== void 0) registerControllerPolicies(hub, controller.constructor, createAdmissionResolver((_key, admissionClass) => admissions.find((admission) => admission instanceof admissionClass), () => {}));
175
193
  const bridge = controller;
176
194
  let nextId = 1;
177
195
  const identity = options.identity;
@@ -197,15 +215,28 @@ function createRealtimeHarness(create, options = {}) {
197
215
  closes,
198
216
  notices,
199
217
  async connect() {
200
- await feed(null);
201
- hub.onPlatformNotice((message) => {
202
- notices.push(message);
203
- });
204
- await waitFor(() => received.some((f) => f.method === "realtime:welcome"), 10);
218
+ const failures = [];
219
+ const syslog = controller.syslog;
220
+ const onLog = (pdu) => {
221
+ const payload = pdu.payload;
222
+ if (pdu.severityName === "ERROR" && typeof payload === "string" && payload.startsWith("WS realtime handshake failed")) failures.push(payload);
223
+ };
224
+ syslog?.on("onLog", onLog);
225
+ try {
226
+ await feed(null);
227
+ hub.onPlatformNotice((message) => {
228
+ notices.push(message);
229
+ });
230
+ await waitFor(() => received.some((f) => f.method === "realtime:welcome") || failures.length > 0, 10);
231
+ } finally {
232
+ syslog?.removeListener("onLog", onLog);
233
+ }
205
234
  const welcome = received.find((f) => f.method === "realtime:welcome");
206
235
  if (!welcome) {
207
- const cause = closes.length > 0 ? `socket fermée (code ${String(closes[0]?.code)})` : "aucune frame émise";
208
- throw new Error(`createRealtimeHarness: pas de realtime:welcome — ${cause}. Vérifier l'Origin (seam #4) et l'authenticator (seam #2).`);
236
+ const code = closes[0]?.code;
237
+ const cause = closes.length > 0 ? `socket fermée (code ${String(code)})` : "aucune frame émise";
238
+ const hint = failures.length > 0 ? `le serveur a levé pendant la poignée de main : ${failures[0] ?? ""}` : code === ORIGIN_REFUSED_CLOSE_CODE ? "Origin refusée (option `origin` du harnais)" : code === IDENTITY_DEAD_CLOSE_CODE ? "identité refusée (option `identity` du harnais)" : "vérifier ce que le controller construit à la connexion (services, admissions, base)";
239
+ throw new Error(`createRealtimeHarness: pas de realtime:welcome — ${cause}. ${hint}.`);
209
240
  }
210
241
  return welcome;
211
242
  },
@@ -275,4 +306,4 @@ function createRealtimeHarness(create, options = {}) {
275
306
  }
276
307
  };
277
308
  }
278
- export { createRealtimeHarness };
309
+ export { BACKPLANE_CONFORMANCE_CASES, createRealtimeHarness, runBackplaneConformance };