@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.
- package/README.md +55 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
- package/dist/index.js +91 -12
- package/dist/nodefony/config/config.js +7 -0
- package/dist/nodefony/decorators/realtimeDecorators.js +341 -43
- package/dist/nodefony/interfaces/IRealtimeConnection.js +1 -0
- package/dist/nodefony/src/backplane/ClusterBackplane.js +32 -4
- package/dist/nodefony/src/backplane/RedisBackplane.js +146 -41
- package/dist/nodefony/src/backplane/controlOrder.js +86 -0
- package/dist/nodefony/src/backplane/envelope.js +505 -27
- 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/cluster/ClusterProbeClient.js +2 -0
- package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
- package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
- package/dist/nodefony/src/server/RealtimeConnection.js +135 -0
- package/dist/nodefony/src/server/RealtimeController.js +409 -107
- package/dist/nodefony/src/server/RealtimeHub.js +1158 -111
- package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
- package/dist/nodefony/src/server/admissionBudget.js +176 -0
- package/dist/nodefony/src/server/channelPolicyRegistry.js +95 -0
- package/dist/nodefony/src/server/channelPolicyResolution.js +191 -0
- package/dist/nodefony/src/server/channelPresence.js +488 -0
- package/dist/nodefony/src/server/connectionAdmission.js +242 -0
- package/dist/nodefony/src/server/contractRoutes.js +75 -0
- package/dist/nodefony/src/server/deniedDetail.js +10 -3
- package/dist/nodefony/src/server/frameAdmission.js +160 -0
- 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 +13 -1
- package/dist/nodefony/src/server/syslogUplink.js +3 -4
- package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
- package/dist/nodefony/src/service/RealtimeService.js +51 -1
- package/dist/nodefony/src/service/publicOrigin.js +40 -0
- package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
- package/dist/nodefony/testing/backplaneConformance.js +347 -0
- package/dist/nodefony/testing/index.js +43 -12
- package/dist/nodefony/testing/presenceClient.js +54 -0
- package/dist/types/index.d.ts +39 -7
- package/dist/types/nodefony/config/config.d.ts +9 -0
- package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +198 -17
- package/dist/types/nodefony/interfaces/IBackplane.d.ts +46 -51
- package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
- package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
- package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
- package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
- package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
- package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
- package/dist/types/nodefony/interfaces/index.d.ts +2 -1
- package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
- package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +89 -18
- package/dist/types/nodefony/src/backplane/controlOrder.d.ts +75 -0
- package/dist/types/nodefony/src/backplane/envelope.d.ts +77 -0
- 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/AnonymousRealtimeToken.d.ts +10 -0
- package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
- package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +20 -0
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +110 -12
- package/dist/types/nodefony/src/server/RealtimeHub.d.ts +448 -42
- package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
- package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
- package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +41 -0
- 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/connectionAdmission.d.ts +68 -0
- package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
- package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
- package/dist/types/nodefony/src/server/frameAdmission.d.ts +66 -0
- 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 +10 -0
- package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
- package/dist/types/nodefony/src/service/RealtimeService.d.ts +17 -2
- package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
- package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
- package/dist/types/nodefony/testing/backplaneConformance.d.ts +78 -0
- package/dist/types/nodefony/testing/index.d.ts +13 -2
- package/dist/types/nodefony/testing/presenceClient.d.ts +20 -0
- package/docs/actions.md +420 -18
- package/docs/architecture.md +101 -11
- package/docs/cadence-et-contre-pression.md +87 -11
- package/docs/canaux-a-membres.md +277 -0
- package/docs/cas-d-usage.md +1703 -0
- package/docs/configuration.md +175 -19
- package/docs/cookbook-chat.md +701 -224
- package/docs/index.md +11 -1
- package/docs/observabilite.md +97 -21
- package/docs/protocole.md +142 -33
- package/docs/securite.md +347 -41
- package/docs/vocabulaire.md +13 -12
- 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` (
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
|
208
|
-
|
|
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 };
|