@nodefony/realtime 10.0.0-beta.2 → 10.0.0-beta.4
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 +2 -2
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/index.js +18 -13
- package/dist/nodefony/config/config.js +7 -5
- package/dist/nodefony/config/configSchema.js +18 -0
- package/dist/nodefony/config/defineModuleConfig.js +43 -17
- package/dist/nodefony/decorators/realtimeDecorators.js +69 -19
- package/dist/nodefony/interfaces/IRealtimeConnectionState.js +1 -0
- package/dist/nodefony/src/backplane/ClusterBackplane.js +1 -2
- package/dist/nodefony/src/backplane/LoopbackBackplane.js +1 -2
- package/dist/nodefony/src/backplane/RedisBackplane.js +22 -6
- package/dist/nodefony/src/backplane/backplaneRegistry.js +1 -2
- package/dist/nodefony/src/backplane/bootNotices.js +42 -0
- package/dist/nodefony/src/backplane/envelope.js +1 -2
- package/dist/nodefony/src/backplane/originId.js +1 -2
- package/dist/nodefony/src/backplane/publishQueue.js +1 -5
- package/dist/nodefony/src/cluster/ClusterProbeClient.js +16 -6
- package/dist/nodefony/src/guards.js +40 -0
- package/dist/nodefony/src/server/AnonymousRealtimeToken.js +2 -6
- package/dist/nodefony/src/server/RealtimeAdminApi.js +1 -2
- package/dist/nodefony/src/server/RealtimeController.js +226 -82
- package/dist/nodefony/src/server/RealtimeHub.js +32 -54
- package/dist/nodefony/src/server/ServerRealtimeSocket.js +30 -13
- package/dist/nodefony/src/server/deniedDetail.js +1 -2
- package/dist/nodefony/src/server/subscribedAck.js +29 -0
- package/dist/nodefony/src/server/syslogUplink.js +5 -4
- package/dist/nodefony/src/server/welcomeEnv.js +1 -2
- package/dist/nodefony/src/service/RealtimeService.js +4 -4
- package/dist/nodefony/src/transport/WsConnectionTransport.js +42 -2
- package/dist/nodefony/testing/index.js +118 -5
- package/dist/types/index.d.ts +1 -0
- package/dist/types/nodefony/config/configSchema.d.ts +12 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +29 -6
- package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +19 -2
- package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +40 -0
- package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +1 -1
- package/dist/types/nodefony/interfaces/IRealtimeHandshake.d.ts +1 -1
- package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +13 -0
- package/dist/types/nodefony/interfaces/index.d.ts +1 -0
- package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +7 -0
- package/dist/types/nodefony/src/backplane/bootNotices.d.ts +25 -0
- package/dist/types/nodefony/src/guards.d.ts +32 -0
- package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +13 -0
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +82 -4
- package/dist/types/nodefony/src/server/RealtimeHub.d.ts +22 -16
- package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +18 -8
- package/dist/types/nodefony/src/server/subscribedAck.d.ts +23 -0
- package/dist/types/nodefony/src/service/RealtimeService.d.ts +1 -1
- package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +26 -1
- package/dist/types/nodefony/testing/index.d.ts +8 -3
- package/docs/actions.md +129 -45
- package/docs/architecture.md +143 -135
- package/docs/cadence-et-contre-pression.md +643 -0
- package/docs/configuration.md +126 -97
- package/docs/cookbook-chat.md +50 -43
- package/docs/index.md +63 -38
- package/docs/observabilite.md +58 -56
- package/docs/protocole.md +110 -76
- package/docs/securite.md +128 -117
- package/docs/vocabulaire.md +133 -129
- package/package.json +17 -13
- package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
- package/docs/couverture-bancs.md +0 -139
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { LoopbackBackplane } from "../backplane/LoopbackBackplane.js";
|
|
2
2
|
import { ANONYMOUS_REALTIME_TOKEN } from "./AnonymousRealtimeToken.js";
|
|
3
|
-
import { NODEFONY_CHANNEL_NAMESPACE, isPlatformChannel } from "nodefony";
|
|
4
|
-
|
|
3
|
+
import { IDENTITY_REFRESH_CLOSE_CODE, NODEFONY_CHANNEL_NAMESPACE, REVOCATION_REVALIDATE_MS, RevocationWatch, isPlatformChannel } from "nodefony";
|
|
4
|
+
// nodefony/src/server/RealtimeHub.ts
|
|
5
5
|
/**
|
|
6
6
|
* Seuil d'alerte slow-consumer (octets de `bufferedAmount`). Au-delà, la connexion
|
|
7
7
|
* est comptée comme « lente » par la sonde (PAS encore de drop/close — la sonde MESURE
|
|
@@ -45,17 +45,6 @@ const RESERVED_SYSTEM_PREFIXES = [NODEFONY_CHANNEL_NAMESPACE];
|
|
|
45
45
|
*/
|
|
46
46
|
const isReservedSystemChannel = isPlatformChannel;
|
|
47
47
|
/**
|
|
48
|
-
* Période de re-validation des identités RÉVOCABLES — F4 (revue 0.6).
|
|
49
|
-
* Le verrou de frame est SYNC par doctrine (identité figée au handshake, cf
|
|
50
|
-
* {@link FrameAuthorizer}) → il ne peut pas re-lire la session par frame. Un socket
|
|
51
|
-
* survivrait donc à sa session (`subscribe` garderait ses flux après un logout HTTP),
|
|
52
|
-
* là où `api.request` re-valide déjà par requête (`isValid()`). Ce tick ferme
|
|
53
|
-
* l'écart : toutes les `REVOCATION_REVALIDATE_MS`, les tokens révocables sont
|
|
54
|
-
* re-validés et la connexion fermée si l'identité n'est plus valide (≤ 1 fenêtre de
|
|
55
|
-
* délai). Ordre de grandeur aligné sur le heartbeat WS (`keepaliveInterval` 20 s).
|
|
56
|
-
*/
|
|
57
|
-
const REVOCATION_REVALIDATE_MS = 3e4;
|
|
58
|
-
/**
|
|
59
48
|
* Le MOTIF de famille d'un canal — son dernier segment remplacé par `*`.
|
|
60
49
|
*
|
|
61
50
|
* `"studio:metrics:1s"` → `"studio:metrics:*"`, `"alerts"` → `"*"`. Sert à
|
|
@@ -99,7 +88,7 @@ function channelFamily(channel) {
|
|
|
99
88
|
* multiplexe des canaux ; le hub aiguille entre les sockets. La façade *consommateur*
|
|
100
89
|
* côté serveur EXISTE — {@link ServerRealtimeSocket} (`subscribe/on/publish`), exportée
|
|
101
90
|
* par le barrel du module : c'est elle qu'un service back tient, jamais ce hub
|
|
102
|
-
* directement. Même rôle que `
|
|
91
|
+
* directement. Même rôle que `NodefonySocket` côté navigateur.
|
|
103
92
|
*/
|
|
104
93
|
var RealtimeHub = class RealtimeHub {
|
|
105
94
|
#channels = null;
|
|
@@ -111,8 +100,12 @@ var RealtimeHub = class RealtimeHub {
|
|
|
111
100
|
#notice = null;
|
|
112
101
|
#noticed = null;
|
|
113
102
|
#connections = null;
|
|
114
|
-
#
|
|
115
|
-
|
|
103
|
+
#revocation = new RevocationWatch({
|
|
104
|
+
isValid: (entry) => entry.token.isValid?.(),
|
|
105
|
+
revoke: (entry) => entry.close(4001, "session revoked"),
|
|
106
|
+
identityOf: (entry) => entry.token.getUserIdentifier(),
|
|
107
|
+
refresh: (entry) => entry.close(IDENTITY_REFRESH_CLOSE_CODE, "identity changed")
|
|
108
|
+
});
|
|
116
109
|
#backplane = null;
|
|
117
110
|
#authenticators = null;
|
|
118
111
|
#peerTokens = null;
|
|
@@ -433,51 +426,36 @@ var RealtimeHub = class RealtimeHub {
|
|
|
433
426
|
* non révocable (anonyme/JWT sans revalidation) ne doit pas y entrer.
|
|
434
427
|
*/
|
|
435
428
|
registerRevocable(entry) {
|
|
436
|
-
|
|
437
|
-
if (this.#revalidateTimer === null) {
|
|
438
|
-
this.#revalidateTimer = setInterval(() => {
|
|
439
|
-
this.revalidateRevocable();
|
|
440
|
-
}, REVOCATION_REVALIDATE_MS);
|
|
441
|
-
this.#revalidateTimer.unref();
|
|
442
|
-
}
|
|
429
|
+
this.#revocation.register(entry);
|
|
443
430
|
}
|
|
444
431
|
/**
|
|
445
432
|
* Retire une connexion du registre de révocation (au close). Arrête le tick dès que
|
|
446
433
|
* le registre est vide (0 timer au repos). No-op si absente.
|
|
447
434
|
*/
|
|
448
435
|
unregisterRevocable(entry) {
|
|
449
|
-
|
|
450
|
-
this.#revocable.delete(entry);
|
|
451
|
-
if (this.#revocable.size === 0 && this.#revalidateTimer !== null) {
|
|
452
|
-
clearInterval(this.#revalidateTimer);
|
|
453
|
-
this.#revalidateTimer = null;
|
|
454
|
-
}
|
|
436
|
+
this.#revocation.unregister(entry);
|
|
455
437
|
}
|
|
456
438
|
/**
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
* `onFinish`). Exposé (public) pour un test déterministe sans fake timers.
|
|
439
|
+
* Re-valide toutes les connexions révocables et ferme (`4001`) celles dont
|
|
440
|
+
* l'identité est morte : session détruite ou passée à un autre compte, jeton
|
|
441
|
+
* expiré ou révoqué. Fail-closed : une re-validation qui lève ferme aussi. Le
|
|
442
|
+
* registre est celui des flux SSE ({@link RevocationWatch}) — une garde, deux
|
|
443
|
+
* transports. Exposé (public) pour un test déterministe sans faux minuteur.
|
|
463
444
|
*/
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
} catch {}
|
|
479
|
-
}
|
|
480
|
-
}
|
|
445
|
+
revalidateRevocable() {
|
|
446
|
+
return this.#revocation.revalidate();
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Ferme tout de suite les connexions qui ont figé cette identité — ses droits
|
|
450
|
+
* ont changé (`IDENTITY_CHANGED_EVENT`). Code {@link IDENTITY_REFRESH_CLOSE_CODE},
|
|
451
|
+
* reconnectable : le client revient avec ses rôles frais. Le registre est
|
|
452
|
+
* celui des flux SSE — une règle, deux transports.
|
|
453
|
+
*
|
|
454
|
+
* @param identifier - identifiant de connexion de l'identité changée.
|
|
455
|
+
* @returns le nombre de connexions fermées.
|
|
456
|
+
*/
|
|
457
|
+
refreshIdentity(identifier) {
|
|
458
|
+
return this.#revocation.refreshIdentity(identifier);
|
|
481
459
|
}
|
|
482
460
|
/**
|
|
483
461
|
* Compte une frame entrante full-duplex (canaux gated SIP/bridge). Appelé par le
|
|
@@ -811,9 +789,10 @@ var RealtimeHub = class RealtimeHub {
|
|
|
811
789
|
* @param channel - le canal servi dynamiquement, cité en exemple dans l'alerte.
|
|
812
790
|
*/
|
|
813
791
|
noticeUnguardedDynamicChannel(channel) {
|
|
792
|
+
if (isReservedSystemChannel(channel)) return;
|
|
814
793
|
if (!this.hasChannelPolicies()) return;
|
|
815
794
|
if (this.resolveChannelPolicy(channel) !== null) return;
|
|
816
|
-
this.#notifyOnce("unguarded-dynamic-channel", `le canal "${channel}"
|
|
795
|
+
this.#notifyOnce("unguarded-dynamic-channel", `le canal "${channel}" n'a aucune règle d'accès : tout client connecté peut s'y abonner. Il est produit par createRealtimeChannel(), et une règle déclarée sur un nom exact ne couvre pas ses variantes (suffixe de cadence, identifiant). Pour le protéger : @RealtimeChannel("${channelFamily(channel)}", { roles: [ … ] }), ou vérifier les droits dans createRealtimeChannel().`);
|
|
817
796
|
}
|
|
818
797
|
/**
|
|
819
798
|
* Enregistre la **factory d'un canal système** (plateforme) — un module bas
|
|
@@ -943,5 +922,4 @@ let _hub = null;
|
|
|
943
922
|
function getRealtimeHub() {
|
|
944
923
|
return _hub ??= new RealtimeHub();
|
|
945
924
|
}
|
|
946
|
-
//#endregion
|
|
947
925
|
export { RESERVED_SYSTEM_PREFIXES, REVOCATION_REVALIDATE_MS, RealtimeHub, RealtimeHub as default, SLOW_CONSUMER_BYTES, channelFamily, getRealtimeHub, isReservedSystemChannel };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getRealtimeHub } from "./RealtimeHub.js";
|
|
2
|
-
|
|
2
|
+
// nodefony/src/server/ServerRealtimeSocket.ts
|
|
3
3
|
/**
|
|
4
4
|
* **L4 — façade serveur « la socket Nodefony ».** Un handle SERVEUR qui implémente
|
|
5
5
|
* le contrat ISOMORPHE {@link IRealtimeSocket} au-dessus du {@link RealtimeHub} : un
|
|
@@ -17,8 +17,9 @@ import { getRealtimeHub } from "./RealtimeHub.js";
|
|
|
17
17
|
* l'état créé est passif, et c'est la fabrique du controller qui tranchera quand
|
|
18
18
|
* une connexion cliente le demandera. Un service n'invente donc jamais un canal
|
|
19
19
|
* pour les autres.
|
|
20
|
-
* - `request` : **non supporté** — un handle au-dessus du hub n'a PAS de
|
|
21
|
-
* (le hub est multi-clients)
|
|
20
|
+
* - `request` : **non supporté, et déprécié** — un handle au-dessus du hub n'a PAS de
|
|
21
|
+
* pair unique (le hub est multi-clients), et le contrat commun `IRealtimeSocket` ne
|
|
22
|
+
* le promet plus. Pour un RPC serveur→client 1-1, utiliser
|
|
22
23
|
* `RealtimeController.requestClient` (par connexion, L1).
|
|
23
24
|
*
|
|
24
25
|
* Lazy : aucune structure allouée tant que le service n'`on`/`subscribe`/`publish` pas.
|
|
@@ -72,8 +73,22 @@ var ServerRealtimeSocket = class {
|
|
|
72
73
|
sinks.delete(c);
|
|
73
74
|
}
|
|
74
75
|
}
|
|
75
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* Branche un handler de réception sur un canal. Renvoie un `dispose`.
|
|
78
|
+
*
|
|
79
|
+
* Le handler typé par `Listen` se range sans conversion sous
|
|
80
|
+
* `RealtimeHandler`, qui porte la frontière du contrat typé : il reçoit ce
|
|
81
|
+
* que le hub publie, dont `Listen` décrit la forme sans la valider.
|
|
82
|
+
*/
|
|
76
83
|
on(channel, handler) {
|
|
84
|
+
return this.#addHandler(channel, handler);
|
|
85
|
+
}
|
|
86
|
+
/** Retire un handler d'un canal — même frontière que {@link on}. */
|
|
87
|
+
off(channel, handler) {
|
|
88
|
+
this.#removeHandler(channel, handler);
|
|
89
|
+
}
|
|
90
|
+
/** Porte non typée de {@link on} : un nom de canal quelconque, un handler `unknown`. */
|
|
91
|
+
#addHandler(channel, handler) {
|
|
77
92
|
const handlers = this.#handlers ??= /* @__PURE__ */ new Map();
|
|
78
93
|
let set = handlers.get(channel);
|
|
79
94
|
if (!set) {
|
|
@@ -81,16 +96,19 @@ var ServerRealtimeSocket = class {
|
|
|
81
96
|
handlers.set(channel, set);
|
|
82
97
|
}
|
|
83
98
|
set.add(handler);
|
|
84
|
-
return () => this
|
|
99
|
+
return () => this.#removeHandler(channel, handler);
|
|
85
100
|
}
|
|
86
|
-
/**
|
|
87
|
-
|
|
101
|
+
/** Porte non typée de {@link off}. */
|
|
102
|
+
#removeHandler(channel, handler) {
|
|
88
103
|
this.#handlers?.get(channel)?.delete(handler);
|
|
89
104
|
}
|
|
90
105
|
/**
|
|
91
|
-
* **Non support
|
|
92
|
-
* (multi-clients)
|
|
93
|
-
* `
|
|
106
|
+
* **Non supporté** : rejette toujours. Un handle au-dessus du hub n'a pas de pair
|
|
107
|
+
* unique (multi-clients), et le contrat commun `IRealtimeSocket` ne promet plus
|
|
108
|
+
* `request`. Comportement inchangé pour toute la série 10.
|
|
109
|
+
*
|
|
110
|
+
* @deprecated Retrait en 11.0. Pour un RPC serveur→client 1-1, utiliser
|
|
111
|
+
* `RealtimeController.requestClient` (par connexion, L1).
|
|
94
112
|
*/
|
|
95
113
|
request(_method, _params, _timeoutMs) {
|
|
96
114
|
return Promise.reject(/* @__PURE__ */ new Error("ServerRealtimeSocket.request: pas de pair unique côté hub (multi-clients). Pour un RPC serveur→client 1-1, utiliser RealtimeController.requestClient."));
|
|
@@ -102,7 +120,7 @@ var ServerRealtimeSocket = class {
|
|
|
102
120
|
return {
|
|
103
121
|
name,
|
|
104
122
|
on(handler) {
|
|
105
|
-
const dispose = hub
|
|
123
|
+
const dispose = hub.#addHandler(name, handler);
|
|
106
124
|
disposers.add(dispose);
|
|
107
125
|
return () => {
|
|
108
126
|
dispose();
|
|
@@ -110,7 +128,7 @@ var ServerRealtimeSocket = class {
|
|
|
110
128
|
};
|
|
111
129
|
},
|
|
112
130
|
send(payload) {
|
|
113
|
-
hub.publish(name, payload);
|
|
131
|
+
hub.hub.publish(name, payload);
|
|
114
132
|
},
|
|
115
133
|
open() {
|
|
116
134
|
hub.subscribe(name);
|
|
@@ -166,5 +184,4 @@ var ServerRealtimeSocket = class {
|
|
|
166
184
|
function serverSocket() {
|
|
167
185
|
return new ServerRealtimeSocket(getRealtimeHub());
|
|
168
186
|
}
|
|
169
|
-
//#endregion
|
|
170
187
|
export { ServerRealtimeSocket, ServerRealtimeSocket as default, serverSocket };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
// nodefony/src/server/deniedDetail.ts
|
|
2
2
|
/**
|
|
3
3
|
* Le détail d'un `realtime:denied` — dit au développeur, tu à l'utilisateur.
|
|
4
4
|
*
|
|
@@ -25,5 +25,4 @@ function deniedDetail(env, detail) {
|
|
|
25
25
|
if (!env || env === "production") return {};
|
|
26
26
|
return { detail };
|
|
27
27
|
}
|
|
28
|
-
//#endregion
|
|
29
28
|
export { deniedDetail };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// nodefony/src/server/subscribedAck.ts
|
|
2
|
+
/**
|
|
3
|
+
* Trame d'acquittement d'abonnement — `realtime:subscribed`, écrite d'un bloc.
|
|
4
|
+
*
|
|
5
|
+
* Tout ce qui précède le nom du canal ne dépend que du protocole : il est
|
|
6
|
+
* calculé une fois au chargement du module. Seul le canal se sérialise par
|
|
7
|
+
* trame. On évite ainsi l'enveloppe allouée et le `JSON.stringify` générique
|
|
8
|
+
* de `JsonRpcPeer.notify` — mesuré à ~0,5 µs par acquittement, soit l'essentiel
|
|
9
|
+
* du surcoût d'un `subscribe` acquitté.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ C'est une SECONDE écriture du format de notification : son égalité avec
|
|
12
|
+
* `JsonRpcPeer.buildNotification` est verrouillée par `subscribedAck.test.ts`,
|
|
13
|
+
* sur des noms de canal hostiles (guillemets, barres obliques inverses,
|
|
14
|
+
* caractères de contrôle, Unicode).
|
|
15
|
+
*
|
|
16
|
+
* @internal
|
|
17
|
+
*/
|
|
18
|
+
/** Le préfixe invariant de la trame, jusqu'à la valeur `channel` exclue. */
|
|
19
|
+
const SUBSCRIBED_FRAME_PREFIX = "{\"jsonrpc\":\"2.0\",\"method\":\"realtime:subscribed\",\"params\":{\"channel\":";
|
|
20
|
+
/**
|
|
21
|
+
* Sérialise la notification `realtime:subscribed { channel }`.
|
|
22
|
+
*
|
|
23
|
+
* @param channel - le canal acquitté (entrée réseau : échappé par `JSON.stringify`).
|
|
24
|
+
* @returns la trame JSON-RPC 2.0 prête à écrire sur le transport.
|
|
25
|
+
*/
|
|
26
|
+
function subscribedAckFrame(channel) {
|
|
27
|
+
return SUBSCRIBED_FRAME_PREFIX + JSON.stringify(channel) + "}}";
|
|
28
|
+
}
|
|
29
|
+
export { subscribedAckFrame };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { isRecord } from "../guards.js";
|
|
1
2
|
import { BROWSER_ORIGIN, Pdu } from "nodefony";
|
|
2
|
-
|
|
3
|
+
// nodefony/src/server/syslogUplink.ts
|
|
3
4
|
/**
|
|
4
5
|
* Réception des journaux **remontés par un navigateur** : le pendant serveur du
|
|
5
6
|
* transport client (`nodefony/client` → `installSyslogUplink`).
|
|
@@ -89,8 +90,9 @@ function createSyslogUplinkHandler(opts) {
|
|
|
89
90
|
for (let i = 0; i < limit; i += 1) {
|
|
90
91
|
if (windowCount >= maxEntriesPerWindow) return;
|
|
91
92
|
windowCount += 1;
|
|
92
|
-
const
|
|
93
|
-
if (
|
|
93
|
+
const entry = entries[i];
|
|
94
|
+
if (!isRecord(entry)) continue;
|
|
95
|
+
const raw = entry;
|
|
94
96
|
const severity = clampSeverity(raw.severity);
|
|
95
97
|
const msgid = str(raw.msgid, 64) ?? "";
|
|
96
98
|
const msg = str(raw.msg, maxStringLength) ?? "";
|
|
@@ -101,5 +103,4 @@ function createSyslogUplinkHandler(opts) {
|
|
|
101
103
|
}
|
|
102
104
|
};
|
|
103
105
|
}
|
|
104
|
-
//#endregion
|
|
105
106
|
export { BROWSER_ORIGIN, MAX_CLIENT_SEVERITY, createSyslogUplinkHandler };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
// nodefony/src/server/welcomeEnv.ts
|
|
2
2
|
/**
|
|
3
3
|
* Le mode d'exécution annoncé — ou non — dans `realtime:welcome`.
|
|
4
4
|
*
|
|
@@ -21,5 +21,4 @@ function welcomeEnv(env) {
|
|
|
21
21
|
if (!env || env === "production") return {};
|
|
22
22
|
return { env };
|
|
23
23
|
}
|
|
24
|
-
//#endregion
|
|
25
24
|
export { welcomeEnv };
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { isRealtimeConfig } from "../../config/defineModuleConfig.js";
|
|
1
2
|
import { RESERVED_SYSTEM_PREFIXES, getRealtimeHub } from "../server/RealtimeHub.js";
|
|
2
3
|
import { applyDeclaredBroadcastPrefixes } from "../../decorators/realtimeDecorators.js";
|
|
3
4
|
import { Service } from "nodefony";
|
|
4
|
-
|
|
5
|
+
// nodefony/src/service/RealtimeService.ts
|
|
5
6
|
const serviceName = "realtimeService";
|
|
6
7
|
/**
|
|
7
8
|
* Façade DI publique de la **socket Nodefony** côté serveur.
|
|
@@ -35,7 +36,7 @@ var RealtimeService = class extends Service {
|
|
|
35
36
|
#config = null;
|
|
36
37
|
#hub = null;
|
|
37
38
|
constructor(module) {
|
|
38
|
-
super(serviceName, module.container, module.notificationsCenter, module.options);
|
|
39
|
+
super(serviceName, module.container ?? void 0, module.notificationsCenter, module.options);
|
|
39
40
|
this.module = module;
|
|
40
41
|
}
|
|
41
42
|
/**
|
|
@@ -55,7 +56,7 @@ var RealtimeService = class extends Service {
|
|
|
55
56
|
*/
|
|
56
57
|
async init(_module) {
|
|
57
58
|
const config = this.module.config;
|
|
58
|
-
if (!config) throw new Error(`${serviceName}: realtimeConfig absente (this.module.config vide) — la Module class doit la valider à onKernelRegister`);
|
|
59
|
+
if (!isRealtimeConfig(config)) throw new Error(`${serviceName}: realtimeConfig absente (this.module.config vide) — la Module class doit la valider à onKernelRegister`);
|
|
59
60
|
this.#config = config;
|
|
60
61
|
if (!this.#config.enabled) {
|
|
61
62
|
this.log("realtime enabled:false → service inerte (hub non câblé)", "INFO");
|
|
@@ -237,5 +238,4 @@ function buildOriginGuard(config) {
|
|
|
237
238
|
return allowSet.has(origin);
|
|
238
239
|
};
|
|
239
240
|
}
|
|
240
|
-
//#endregion
|
|
241
241
|
export { RealtimeService, RealtimeService as default };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { TransportState } from "nodefony";
|
|
2
2
|
import { decideSend } from "@nodefony/http";
|
|
3
|
-
|
|
3
|
+
// nodefony/src/transport/WsConnectionTransport.ts
|
|
4
4
|
/**
|
|
5
5
|
* Contre-pression WS — **la règle vit dans `@nodefony/http`**, ce transport ne
|
|
6
6
|
* fait que l'appliquer (`decideSend`). Deux implémentations de la même
|
|
@@ -31,6 +31,8 @@ var WsConnectionTransport = class {
|
|
|
31
31
|
_bytesSent = 0;
|
|
32
32
|
_messagesSent = 0;
|
|
33
33
|
_dropped = 0;
|
|
34
|
+
_binaryDiscarded = 0;
|
|
35
|
+
_malformedDiscarded = 0;
|
|
34
36
|
_max;
|
|
35
37
|
_policy;
|
|
36
38
|
_closeAfterDrops;
|
|
@@ -51,6 +53,45 @@ var WsConnectionTransport = class {
|
|
|
51
53
|
this._messagesSent += 1;
|
|
52
54
|
this.conn.send(raw, () => {});
|
|
53
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Émet des octets tels quels — trame binaire RFC 6455, hors JSON-RPC (#617).
|
|
58
|
+
* Même contre-pression (`decideSend`, la règle unique) et mêmes compteurs que
|
|
59
|
+
* {@link send}. Écrite à part, et non en factorisant `send` : `send` est le
|
|
60
|
+
* chemin de CHAQUE frame JSON-RPC, et n'y paie pas un appel de plus.
|
|
61
|
+
*
|
|
62
|
+
* @param data - les octets à émettre.
|
|
63
|
+
* @returns `true` si la trame est partie, `false` si la connexion était
|
|
64
|
+
* fermée ou la contre-pression l'a jetée.
|
|
65
|
+
*/
|
|
66
|
+
sendBinary(data) {
|
|
67
|
+
if (this.conn.readyState !== TransportState.OPEN) return false;
|
|
68
|
+
if (decideSend(this.conn, this._max, this._policy, this._closeAfterDrops) !== "send") {
|
|
69
|
+
this._dropped += 1;
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
this._bytesSent += data.byteLength;
|
|
73
|
+
this._messagesSent += 1;
|
|
74
|
+
this.conn.send(data, () => {});
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Compte une trame ENTRANTE écartée et rend le cumul de sa nature — `1` dit
|
|
79
|
+
* « première », le seul instant où l'appelant journalise.
|
|
80
|
+
*
|
|
81
|
+
* @param kind - `binary` (sans lecteur) ou `malformed` (texte non JSON).
|
|
82
|
+
* @returns le cumul de cette nature, après incrément.
|
|
83
|
+
*/
|
|
84
|
+
discard(kind) {
|
|
85
|
+
return kind === "binary" ? ++this._binaryDiscarded : ++this._malformedDiscarded;
|
|
86
|
+
}
|
|
87
|
+
/** Cumul de trames BINAIRES reçues et écartées faute de lecteur — monotone. */
|
|
88
|
+
get binaryDiscarded() {
|
|
89
|
+
return this._binaryDiscarded;
|
|
90
|
+
}
|
|
91
|
+
/** Cumul de trames TEXTE reçues et écartées parce que non JSON — monotone. */
|
|
92
|
+
get malformedDiscarded() {
|
|
93
|
+
return this._malformedDiscarded;
|
|
94
|
+
}
|
|
54
95
|
close(code, reason) {
|
|
55
96
|
this.conn.close(code, reason);
|
|
56
97
|
}
|
|
@@ -90,5 +131,4 @@ var WsConnectionTransport = class {
|
|
|
90
131
|
this._onClose?.(code, reason);
|
|
91
132
|
}
|
|
92
133
|
};
|
|
93
|
-
//#endregion
|
|
94
134
|
export { WsConnectionTransport, WsConnectionTransport as default };
|
|
@@ -1,5 +1,38 @@
|
|
|
1
|
+
import { isRecord } from "../src/guards.js";
|
|
1
2
|
import { getRealtimeHub } from "../src/server/RealtimeHub.js";
|
|
2
|
-
|
|
3
|
+
import { RequestContext } from "nodefony";
|
|
4
|
+
// nodefony/testing/index.ts
|
|
5
|
+
/**
|
|
6
|
+
* `@nodefony/realtime/testing` — **harnais de test d'un controller temps réel**.
|
|
7
|
+
*
|
|
8
|
+
* Écrire un test de socket demandait jusqu'ici une centaine de lignes de
|
|
9
|
+
* plomberie FRAMEWORK avant la première assertion : un faux `ContextType` qui ne
|
|
10
|
+
* fournit que ce que la base touche, un pont vers `handleRealtime` (qui est
|
|
11
|
+
* `protected`), la remise à zéro du hub — singleton par process — entre deux
|
|
12
|
+
* cas, et la pose d'une identité au handshake. Cette plomberie se périme au
|
|
13
|
+
* premier changement de signature de la base ; recopiée dans chaque application,
|
|
14
|
+
* elle y aurait vieilli sans que personne ne le sache. Elle vit donc ICI, avec
|
|
15
|
+
* le code dont elle dépend.
|
|
16
|
+
*
|
|
17
|
+
* Le harnais pilote le **côté serveur** : il injecte des frames JSON-RPC 2.0
|
|
18
|
+
* telles qu'un client les enverrait, et lit celles qui sortent. Il n'embarque
|
|
19
|
+
* PAS `NodefonySocket` — le client est un artefact navigateur du cœur, et le
|
|
20
|
+
* tirer ici le ferait entrer dans le paquet serveur publié. Ce qu'on veut
|
|
21
|
+
* prouver d'un controller (ses canaux, ses actions, son plein-duplex, son
|
|
22
|
+
* nettoyage) s'observe entièrement aux frames.
|
|
23
|
+
*
|
|
24
|
+
* ```ts
|
|
25
|
+
* import { createRealtimeHarness } from "@nodefony/realtime/testing";
|
|
26
|
+
*
|
|
27
|
+
* const h = createRealtimeHarness((ctx) => new ChatController(ctx));
|
|
28
|
+
* await h.connect();
|
|
29
|
+
* await h.subscribe("chat:ticker");
|
|
30
|
+
* expect(h.messages("chat:ticker")).to.have.length.greaterThan(0);
|
|
31
|
+
* h.dispose();
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* @packageDocumentation
|
|
35
|
+
*/
|
|
3
36
|
/** `readyState` d'une connexion WebSocket ouverte (`ws` / DOM). */
|
|
4
37
|
const OPEN = 1;
|
|
5
38
|
/** Laisse tourner les microtâches et les timers immédiats. */
|
|
@@ -21,6 +54,67 @@ async function waitFor(predicate, ticks) {
|
|
|
21
54
|
return predicate();
|
|
22
55
|
}
|
|
23
56
|
/**
|
|
57
|
+
* Motifs de refus du protocole, rendus exhaustifs par le compilateur : un motif
|
|
58
|
+
* ajouté à `IRealtimeDenied.reason` casse la compilation ICI, au lieu de faire
|
|
59
|
+
* lever le harnais sur un refus légitime.
|
|
60
|
+
*/
|
|
61
|
+
const DENIED_REASONS = {
|
|
62
|
+
forbidden: true,
|
|
63
|
+
limit: true,
|
|
64
|
+
unknown: true
|
|
65
|
+
};
|
|
66
|
+
function isDeniedReason(reason) {
|
|
67
|
+
return Object.hasOwn(DENIED_REASONS, reason);
|
|
68
|
+
}
|
|
69
|
+
/** Erreur JSON-RPC 2.0 (§5.1) d'une trame, ou `null` si sa forme est fausse. */
|
|
70
|
+
function readRpcError(error) {
|
|
71
|
+
if (!isRecord(error) || typeof error.code !== "number" || typeof error.message !== "string") return null;
|
|
72
|
+
return Object.hasOwn(error, "data") ? {
|
|
73
|
+
code: error.code,
|
|
74
|
+
message: error.message,
|
|
75
|
+
data: error.data
|
|
76
|
+
} : {
|
|
77
|
+
code: error.code,
|
|
78
|
+
message: error.message
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Lit une trame émise par le contrôleur sous test. Une trame dont un champ a
|
|
83
|
+
* la mauvaise forme LÈVE : c'est un défaut du contrôleur, que le test doit
|
|
84
|
+
* voir, pas une donnée à écarter.
|
|
85
|
+
*/
|
|
86
|
+
function readFrame(data) {
|
|
87
|
+
const value = JSON.parse(data);
|
|
88
|
+
const fault = () => /* @__PURE__ */ new Error(`createRealtimeHarness: trame JSON-RPC malformée émise — ${data}`);
|
|
89
|
+
if (!isRecord(value)) throw fault();
|
|
90
|
+
const { jsonrpc, id, method } = value;
|
|
91
|
+
if (jsonrpc !== void 0 && typeof jsonrpc !== "string") throw fault();
|
|
92
|
+
if (id !== void 0 && typeof id !== "number" && typeof id !== "string") throw fault();
|
|
93
|
+
if (method !== void 0 && typeof method !== "string") throw fault();
|
|
94
|
+
const error = Object.hasOwn(value, "error") ? readRpcError(value.error) : void 0;
|
|
95
|
+
if (error === null) throw fault();
|
|
96
|
+
return {
|
|
97
|
+
...jsonrpc === void 0 ? {} : { jsonrpc },
|
|
98
|
+
...id === void 0 ? {} : { id },
|
|
99
|
+
...method === void 0 ? {} : { method },
|
|
100
|
+
...Object.hasOwn(value, "params") ? { params: value.params } : {},
|
|
101
|
+
...Object.hasOwn(value, "result") ? { result: value.result } : {},
|
|
102
|
+
...error === void 0 ? {} : { error }
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/** Lit le `params` d'un `realtime:denied` — lève si le contrôleur l'a mal formé. */
|
|
106
|
+
function readDenied(params) {
|
|
107
|
+
if (isRecord(params) && typeof params.channel === "string" && typeof params.reason === "string" && isDeniedReason(params.reason) && (params.detail === void 0 || typeof params.detail === "string")) return params.detail === void 0 ? {
|
|
108
|
+
channel: params.channel,
|
|
109
|
+
reason: params.reason
|
|
110
|
+
} : {
|
|
111
|
+
channel: params.channel,
|
|
112
|
+
reason: params.reason,
|
|
113
|
+
detail: params.detail
|
|
114
|
+
};
|
|
115
|
+
throw new Error(`createRealtimeHarness: realtime:denied malformé — ${JSON.stringify(params)}`);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
24
118
|
* Monte un controller temps réel sur une fausse connexion et rend de quoi le
|
|
25
119
|
* piloter.
|
|
26
120
|
*
|
|
@@ -53,7 +147,7 @@ function createRealtimeHarness(create, options = {}) {
|
|
|
53
147
|
const connection = {
|
|
54
148
|
readyState: OPEN,
|
|
55
149
|
send: (data, cb) => {
|
|
56
|
-
received.push(
|
|
150
|
+
received.push(readFrame(data));
|
|
57
151
|
cb?.();
|
|
58
152
|
},
|
|
59
153
|
close: (code, reason) => {
|
|
@@ -64,6 +158,7 @@ function createRealtimeHarness(create, options = {}) {
|
|
|
64
158
|
}
|
|
65
159
|
};
|
|
66
160
|
const controller = create({
|
|
161
|
+
type: "websocket",
|
|
67
162
|
connection,
|
|
68
163
|
once: (event, fn) => {
|
|
69
164
|
if (event === "onFinish") onFinish = fn;
|
|
@@ -79,8 +174,16 @@ function createRealtimeHarness(create, options = {}) {
|
|
|
79
174
|
});
|
|
80
175
|
const bridge = controller;
|
|
81
176
|
let nextId = 1;
|
|
177
|
+
const identity = options.identity;
|
|
178
|
+
const handshakeStore = identity === void 0 ? { requestId: "realtime-harness" } : {
|
|
179
|
+
requestId: "realtime-harness",
|
|
180
|
+
token: identity,
|
|
181
|
+
user: identity.getAttribute("user")
|
|
182
|
+
};
|
|
82
183
|
const feed = async (frame) => {
|
|
83
|
-
|
|
184
|
+
RequestContext.run(handshakeStore, () => {
|
|
185
|
+
bridge.handleRealtime(frame === null ? null : JSON.stringify(frame));
|
|
186
|
+
});
|
|
84
187
|
await tick();
|
|
85
188
|
};
|
|
86
189
|
const rpc = (payload) => ({
|
|
@@ -107,10 +210,21 @@ function createRealtimeHarness(create, options = {}) {
|
|
|
107
210
|
return welcome;
|
|
108
211
|
},
|
|
109
212
|
async subscribe(channel) {
|
|
213
|
+
const from = received.length;
|
|
110
214
|
await feed(rpc({
|
|
111
215
|
method: "subscribe",
|
|
112
216
|
params: { channel }
|
|
113
217
|
}));
|
|
218
|
+
await waitFor(() => {
|
|
219
|
+
for (let i = from; i < received.length; i++) {
|
|
220
|
+
const f = received[i];
|
|
221
|
+
if ((f?.method === "realtime:subscribed" || f?.method === "realtime:denied") && isRecord(f.params) && f.params["channel"] === channel) return true;
|
|
222
|
+
}
|
|
223
|
+
return false;
|
|
224
|
+
}, 20);
|
|
225
|
+
},
|
|
226
|
+
subscriptions() {
|
|
227
|
+
return received.filter((f) => f.method === "realtime:subscribed").map((f) => isRecord(f.params) ? String(f.params["channel"]) : "");
|
|
114
228
|
},
|
|
115
229
|
async unsubscribe(channel) {
|
|
116
230
|
await feed(rpc({
|
|
@@ -147,7 +261,7 @@ function createRealtimeHarness(create, options = {}) {
|
|
|
147
261
|
return received.filter((f) => f.method === channel && f.id === void 0).map((f) => f.params);
|
|
148
262
|
},
|
|
149
263
|
denials() {
|
|
150
|
-
return received.filter((f) => f.method === "realtime:denied").map((f) => f.params);
|
|
264
|
+
return received.filter((f) => f.method === "realtime:denied").map((f) => readDenied(f.params));
|
|
151
265
|
},
|
|
152
266
|
close() {
|
|
153
267
|
if (closed) return;
|
|
@@ -161,5 +275,4 @@ function createRealtimeHarness(create, options = {}) {
|
|
|
161
275
|
}
|
|
162
276
|
};
|
|
163
277
|
}
|
|
164
|
-
//#endregion
|
|
165
278
|
export { createRealtimeHarness };
|
package/dist/types/index.d.ts
CHANGED
|
@@ -81,6 +81,7 @@ export { RealtimeAction, RealtimeBroadcast, RealtimeChannel, RealtimeInbound, ge
|
|
|
81
81
|
export type { RealtimeChannelFactory } from "./nodefony/decorators/realtimeDecorators.js";
|
|
82
82
|
export type { IRealtimeController, RealtimePublish, RealtimeInboundHandler, } from "./nodefony/interfaces/IRealtimeController.js";
|
|
83
83
|
export type { IChannelPolicy } from "./nodefony/interfaces/IChannelPolicy.js";
|
|
84
|
+
export type { IRealtimeConnectionState } from "./nodefony/interfaces/IRealtimeConnectionState.js";
|
|
84
85
|
export type { IClientLogsLimits } from "./nodefony/interfaces/IClientLogsLimits.js";
|
|
85
86
|
export { createSyslogUplinkHandler, BROWSER_ORIGIN, MAX_CLIENT_SEVERITY, } from "./nodefony/src/server/syslogUplink.js";
|
|
86
87
|
export type { SyslogUplinkHandlerOptions, ClientSeverity, } from "./nodefony/src/server/syslogUplink.js";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Schema de la configuration de `@nodefony/realtime` — lu SANS démarrer
|
|
3
|
+
* l'application, par `nodefony env` et la notice `.env.example`, qui y
|
|
4
|
+
* trouvent les noms courts (`.meta({ env })`).
|
|
5
|
+
*
|
|
6
|
+
* Déclaré dans `package.json` → `nodefony.configSchema` ; le nom de l'export
|
|
7
|
+
* est FIXE. Fichier FEUILLE : il n'importe que zod et le schéma, et n'ouvre
|
|
8
|
+
* rien à l'import — une commande qui ne démarre pas l'application l'exécute.
|
|
9
|
+
*
|
|
10
|
+
* @returns le JSON Schema, métadonnées Nodefony comprises.
|
|
11
|
+
*/
|
|
12
|
+
export declare function configJsonSchema(): unknown;
|