@nodefony/realtime 10.0.0-beta.3 → 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/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/index.js +4 -6
- package/dist/nodefony/config/config.js +1 -2
- package/dist/nodefony/config/configSchema.js +1 -2
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/decorators/realtimeDecorators.js +1 -2
- 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 +1 -2
- package/dist/nodefony/src/backplane/backplaneRegistry.js +1 -2
- package/dist/nodefony/src/backplane/bootNotices.js +1 -2
- 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 +0 -1
- package/dist/nodefony/src/cluster/ClusterProbeClient.js +1 -2
- package/dist/nodefony/src/guards.js +1 -2
- package/dist/nodefony/src/server/AnonymousRealtimeToken.js +1 -2
- package/dist/nodefony/src/server/RealtimeAdminApi.js +1 -2
- package/dist/nodefony/src/server/RealtimeController.js +154 -80
- package/dist/nodefony/src/server/RealtimeHub.js +3 -3
- package/dist/nodefony/src/server/ServerRealtimeSocket.js +18 -9
- 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 +1 -2
- package/dist/nodefony/src/server/welcomeEnv.js +1 -2
- package/dist/nodefony/src/service/RealtimeService.js +1 -2
- package/dist/nodefony/src/transport/WsConnectionTransport.js +42 -2
- package/dist/nodefony/testing/index.js +77 -4
- package/dist/types/index.d.ts +1 -0
- package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +40 -0
- 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/server/RealtimeController.d.ts +58 -0
- 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/transport/WsConnectionTransport.d.ts +26 -1
- package/dist/types/nodefony/testing/index.d.ts +6 -1
- package/docs/actions.md +44 -43
- package/docs/architecture.md +125 -119
- package/docs/cadence-et-contre-pression.md +31 -22
- package/docs/configuration.md +106 -82
- package/docs/cookbook-chat.md +37 -34
- package/docs/index.md +35 -24
- package/docs/observabilite.md +49 -49
- package/docs/protocole.md +91 -68
- package/docs/securite.md +125 -114
- package/docs/vocabulaire.md +117 -114
- package/package.json +10 -10
- package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { LoopbackBackplane } from "../backplane/LoopbackBackplane.js";
|
|
2
2
|
import { ANONYMOUS_REALTIME_TOKEN } from "./AnonymousRealtimeToken.js";
|
|
3
3
|
import { IDENTITY_REFRESH_CLOSE_CODE, NODEFONY_CHANNEL_NAMESPACE, REVOCATION_REVALIDATE_MS, RevocationWatch, isPlatformChannel } from "nodefony";
|
|
4
|
-
|
|
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
|
|
@@ -789,9 +789,10 @@ var RealtimeHub = class RealtimeHub {
|
|
|
789
789
|
* @param channel - le canal servi dynamiquement, cité en exemple dans l'alerte.
|
|
790
790
|
*/
|
|
791
791
|
noticeUnguardedDynamicChannel(channel) {
|
|
792
|
+
if (isReservedSystemChannel(channel)) return;
|
|
792
793
|
if (!this.hasChannelPolicies()) return;
|
|
793
794
|
if (this.resolveChannelPolicy(channel) !== null) return;
|
|
794
|
-
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().`);
|
|
795
796
|
}
|
|
796
797
|
/**
|
|
797
798
|
* Enregistre la **factory d'un canal système** (plateforme) — un module bas
|
|
@@ -921,5 +922,4 @@ let _hub = null;
|
|
|
921
922
|
function getRealtimeHub() {
|
|
922
923
|
return _hub ??= new RealtimeHub();
|
|
923
924
|
}
|
|
924
|
-
//#endregion
|
|
925
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,11 +73,17 @@ 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) {
|
|
77
84
|
return this.#addHandler(channel, handler);
|
|
78
85
|
}
|
|
79
|
-
/** Retire un handler d'un canal. */
|
|
86
|
+
/** Retire un handler d'un canal — même frontière que {@link on}. */
|
|
80
87
|
off(channel, handler) {
|
|
81
88
|
this.#removeHandler(channel, handler);
|
|
82
89
|
}
|
|
@@ -96,9 +103,12 @@ var ServerRealtimeSocket = class {
|
|
|
96
103
|
this.#handlers?.get(channel)?.delete(handler);
|
|
97
104
|
}
|
|
98
105
|
/**
|
|
99
|
-
* **Non support
|
|
100
|
-
* (multi-clients)
|
|
101
|
-
* `
|
|
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).
|
|
102
112
|
*/
|
|
103
113
|
request(_method, _params, _timeoutMs) {
|
|
104
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."));
|
|
@@ -174,5 +184,4 @@ var ServerRealtimeSocket = class {
|
|
|
174
184
|
function serverSocket() {
|
|
175
185
|
return new ServerRealtimeSocket(getRealtimeHub());
|
|
176
186
|
}
|
|
177
|
-
//#endregion
|
|
178
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,6 +1,6 @@
|
|
|
1
1
|
import { isRecord } from "../guards.js";
|
|
2
2
|
import { BROWSER_ORIGIN, Pdu } from "nodefony";
|
|
3
|
-
|
|
3
|
+
// nodefony/src/server/syslogUplink.ts
|
|
4
4
|
/**
|
|
5
5
|
* Réception des journaux **remontés par un navigateur** : le pendant serveur du
|
|
6
6
|
* transport client (`nodefony/client` → `installSyslogUplink`).
|
|
@@ -103,5 +103,4 @@ function createSyslogUplinkHandler(opts) {
|
|
|
103
103
|
}
|
|
104
104
|
};
|
|
105
105
|
}
|
|
106
|
-
//#endregion
|
|
107
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 };
|
|
@@ -2,7 +2,7 @@ import { isRealtimeConfig } from "../../config/defineModuleConfig.js";
|
|
|
2
2
|
import { RESERVED_SYSTEM_PREFIXES, getRealtimeHub } from "../server/RealtimeHub.js";
|
|
3
3
|
import { applyDeclaredBroadcastPrefixes } from "../../decorators/realtimeDecorators.js";
|
|
4
4
|
import { Service } from "nodefony";
|
|
5
|
-
|
|
5
|
+
// nodefony/src/service/RealtimeService.ts
|
|
6
6
|
const serviceName = "realtimeService";
|
|
7
7
|
/**
|
|
8
8
|
* Façade DI publique de la **socket Nodefony** côté serveur.
|
|
@@ -238,5 +238,4 @@ function buildOriginGuard(config) {
|
|
|
238
238
|
return allowSet.has(origin);
|
|
239
239
|
};
|
|
240
240
|
}
|
|
241
|
-
//#endregion
|
|
242
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,6 +1,7 @@
|
|
|
1
|
+
import { isRecord } from "../src/guards.js";
|
|
1
2
|
import { getRealtimeHub } from "../src/server/RealtimeHub.js";
|
|
2
3
|
import { RequestContext } from "nodefony";
|
|
3
|
-
|
|
4
|
+
// nodefony/testing/index.ts
|
|
4
5
|
/**
|
|
5
6
|
* `@nodefony/realtime/testing` — **harnais de test d'un controller temps réel**.
|
|
6
7
|
*
|
|
@@ -53,6 +54,67 @@ async function waitFor(predicate, ticks) {
|
|
|
53
54
|
return predicate();
|
|
54
55
|
}
|
|
55
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
|
+
/**
|
|
56
118
|
* Monte un controller temps réel sur une fausse connexion et rend de quoi le
|
|
57
119
|
* piloter.
|
|
58
120
|
*
|
|
@@ -85,7 +147,7 @@ function createRealtimeHarness(create, options = {}) {
|
|
|
85
147
|
const connection = {
|
|
86
148
|
readyState: OPEN,
|
|
87
149
|
send: (data, cb) => {
|
|
88
|
-
received.push(
|
|
150
|
+
received.push(readFrame(data));
|
|
89
151
|
cb?.();
|
|
90
152
|
},
|
|
91
153
|
close: (code, reason) => {
|
|
@@ -96,6 +158,7 @@ function createRealtimeHarness(create, options = {}) {
|
|
|
96
158
|
}
|
|
97
159
|
};
|
|
98
160
|
const controller = create({
|
|
161
|
+
type: "websocket",
|
|
99
162
|
connection,
|
|
100
163
|
once: (event, fn) => {
|
|
101
164
|
if (event === "onFinish") onFinish = fn;
|
|
@@ -147,10 +210,21 @@ function createRealtimeHarness(create, options = {}) {
|
|
|
147
210
|
return welcome;
|
|
148
211
|
},
|
|
149
212
|
async subscribe(channel) {
|
|
213
|
+
const from = received.length;
|
|
150
214
|
await feed(rpc({
|
|
151
215
|
method: "subscribe",
|
|
152
216
|
params: { channel }
|
|
153
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"]) : "");
|
|
154
228
|
},
|
|
155
229
|
async unsubscribe(channel) {
|
|
156
230
|
await feed(rpc({
|
|
@@ -187,7 +261,7 @@ function createRealtimeHarness(create, options = {}) {
|
|
|
187
261
|
return received.filter((f) => f.method === channel && f.id === void 0).map((f) => f.params);
|
|
188
262
|
},
|
|
189
263
|
denials() {
|
|
190
|
-
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));
|
|
191
265
|
},
|
|
192
266
|
close() {
|
|
193
267
|
if (closed) return;
|
|
@@ -201,5 +275,4 @@ function createRealtimeHarness(create, options = {}) {
|
|
|
201
275
|
}
|
|
202
276
|
};
|
|
203
277
|
}
|
|
204
|
-
//#endregion
|
|
205
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,40 @@
|
|
|
1
|
+
import type { JsonRpcPeer } from "nodefony";
|
|
2
|
+
import type { WsConnectionTransport } from "../src/transport/WsConnectionTransport.js";
|
|
3
|
+
import type { ChannelSink } from "../src/server/RealtimeHub.js";
|
|
4
|
+
import type { RealtimeInboundHandler } from "./IRealtimeController.js";
|
|
5
|
+
/**
|
|
6
|
+
* État du protocole temps réel d'UNE connexion WebSocket — posé au welcome, et
|
|
7
|
+
* relu à chaque frame jusqu'à la fermeture.
|
|
8
|
+
*
|
|
9
|
+
* Il vit sur la CONNEXION (`WebsocketContext`), pas sur le contrôleur : un
|
|
10
|
+
* `forward` fait construire une instance neuve qui sert la même socket, et
|
|
11
|
+
* l'état doit lui survivre.
|
|
12
|
+
*
|
|
13
|
+
* Le pair y est rangé sous son contrat EFFACÉ, ce qu'il est d'ailleurs à sa
|
|
14
|
+
* construction. Plusieurs contrôleurs peuvent servir la même connexion, et aucun
|
|
15
|
+
* type ne relie cet état à l'un d'eux : chacun le relit sous son propre contrat
|
|
16
|
+
* (`RealtimeController.requestClient` → `JsonRpcPeer.requestAs`).
|
|
17
|
+
*/
|
|
18
|
+
export interface IRealtimeConnectionState {
|
|
19
|
+
/** `realtime:welcome` émis : la poignée de main ne se rejoue pas. */
|
|
20
|
+
welcomed: boolean;
|
|
21
|
+
/** Le pair JSON-RPC de la connexion, sous son contrat effacé. */
|
|
22
|
+
peer: JsonRpcPeer;
|
|
23
|
+
/** Le transport qui relie le pair à la socket (contre-pression, sonde). */
|
|
24
|
+
transport: WsConnectionTransport;
|
|
25
|
+
/** Canal → sink de CETTE connexion auprès du hub partagé, pour s'en désabonner. */
|
|
26
|
+
channels: Map<string, ChannelSink>;
|
|
27
|
+
/** Canaux full-duplex qui acceptent une entrée client ; `null` s'il n'y en a aucun. */
|
|
28
|
+
inbound: Record<string, RealtimeInboundHandler> | null;
|
|
29
|
+
}
|
|
30
|
+
declare module "@nodefony/http" {
|
|
31
|
+
interface IWebsocketConnectionState {
|
|
32
|
+
/**
|
|
33
|
+
* État du protocole temps réel de la connexion, posé par
|
|
34
|
+
* `@nodefony/realtime` au welcome ; `undefined` avant, et sur toute socket
|
|
35
|
+
* qu'aucun `RealtimeController` ne sert. Réservé au module : le lire,
|
|
36
|
+
* jamais l'écrire.
|
|
37
|
+
*/
|
|
38
|
+
__nfRealtime?: IRealtimeConnectionState;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -45,4 +45,17 @@ export interface IRealtimeToken {
|
|
|
45
45
|
* (publish/subscribe), payé seulement sur `api.request`.
|
|
46
46
|
*/
|
|
47
47
|
isValid?(nowMs?: number): boolean | Promise<boolean>;
|
|
48
|
+
/**
|
|
49
|
+
* Le COMPTE qui a ouvert la socket existe-t-il toujours, actif, et est-ce
|
|
50
|
+
* bien le même ? `false` si le compte a disparu, a été désactivé ou
|
|
51
|
+
* verrouillé, ou si un AUTRE compte a repris son identifiant (suppression
|
|
52
|
+
* hors framework puis recréation). Optionnel — absent = considéré confirmé.
|
|
53
|
+
*
|
|
54
|
+
* Distinct d'{@link isValid} parce que son coût l'est : il relit l'annuaire
|
|
55
|
+
* des comptes (une lecture, comme une requête HTTP en zone), là où
|
|
56
|
+
* `isValid` ne lit qu'un store de jetons ou de sessions. Appelé par le pont
|
|
57
|
+
* `api.request` SEULEMENT, jamais par la re-validation périodique du hub —
|
|
58
|
+
* qui le paierait pour chaque socket ouverte, à chaque tour.
|
|
59
|
+
*/
|
|
60
|
+
confirmAccount?(): Promise<boolean>;
|
|
48
61
|
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
export type { IBackplane, IBackplaneMessage, BackplaneHandler, } from "./IBackplane.js";
|
|
12
12
|
export type { IRealtimeController, RealtimePublish, RealtimeInboundHandler, } from "./IRealtimeController.js";
|
|
13
13
|
export type { IChannelPolicy } from "./IChannelPolicy.js";
|
|
14
|
+
export type { IRealtimeConnectionState } from "./IRealtimeConnectionState.js";
|
|
14
15
|
export type { IRealtimeProbe, IRealtimeHealth, IRealtimeClusterHealth, IRealtimeChannelStat, IRealtimeConnProbe, } from "./IRealtimeProbe.js";
|
|
15
16
|
export type { IRealtimeToken } from "./IRealtimeToken.js";
|
|
16
17
|
export type { IRealtimeHandshake } from "./IRealtimeHandshake.js";
|
|
@@ -26,6 +26,7 @@ import type { IRealtimeController, RealtimePublish, RealtimeInboundHandler } fro
|
|
|
26
26
|
* d'action n'est payé que sur une requête `id`. C'est aussi le seam du backplane Redis.
|
|
27
27
|
*/
|
|
28
28
|
export declare abstract class RealtimeController<Emit extends EventsMap = DefaultEventsMap, Actions extends ActionsMap = DefaultActionsMap> extends Controller implements IRealtimeController {
|
|
29
|
+
#private;
|
|
29
30
|
/**
|
|
30
31
|
* Une instance PAR CONNEXION, par construction — le défaut `singleton` des
|
|
31
32
|
* contrôleurs ne s'applique pas ici. Le pair JSON-RPC de la connexion capture
|
|
@@ -120,6 +121,39 @@ export declare abstract class RealtimeController<Emit extends EventsMap = Defaul
|
|
|
120
121
|
* écoute (welcome) » de `NodefonySocketCoverage.test.ts`.
|
|
121
122
|
*/
|
|
122
123
|
protected handleRealtime(message: string | Buffer | null): void;
|
|
124
|
+
/**
|
|
125
|
+
* Lit les trames BINAIRES de cette connexion — le point d'extension d'un
|
|
126
|
+
* protocole binaire écrit par l'application, à côté du JSON-RPC ; pendant
|
|
127
|
+
* serveur de `NodefonySocket.onBinary`.
|
|
128
|
+
*
|
|
129
|
+
* Le protocole temps réel est du JSON-RPC texte : il ne sait pas à quel canal
|
|
130
|
+
* une trame binaire appartient. Sans cette méthode, une trame binaire est
|
|
131
|
+
* écartée, comptée (`binaryDiscarded` de la sonde de connexion) et journalisée
|
|
132
|
+
* une fois par connexion. La surcharger la reçoit telle quelle, octets exacts,
|
|
133
|
+
* en `Uint8Array` — le type que livre aussi `NodefonySocket.onBinary` dans le
|
|
134
|
+
* navigateur : un décodeur s'écrit une fois pour les deux côtés. À l'exécution
|
|
135
|
+
* c'est un `Buffer` de `ws`, qui en est un.
|
|
136
|
+
*
|
|
137
|
+
* ⚠️ Hors du verrou de trame, et pour toute la série 10 : la trame brute ne
|
|
138
|
+
* passe ni par l'autorisation `beforeDispatch` ni par les gardes d'action et de
|
|
139
|
+
* canal du JSON-RPC. La connexion, elle, a déjà franchi Origin, l'authentificateur
|
|
140
|
+
* et le pare-feu. L'identité se lit par `RequestContext.getAuthenticatedUser()`,
|
|
141
|
+
* et toute décision d'accès sur une trame binaire appartient à l'application.
|
|
142
|
+
*
|
|
143
|
+
* ⚠️ Un `DataView` se construit sur `data.buffer` AVEC `data.byteOffset` et
|
|
144
|
+
* `data.byteLength` : un `Buffer` issu du pool de Node ne commence pas à 0.
|
|
145
|
+
*
|
|
146
|
+
* @param data - les octets reçus.
|
|
147
|
+
*/
|
|
148
|
+
protected onRealtimeBinary?(data: Uint8Array): void;
|
|
149
|
+
/**
|
|
150
|
+
* Faute de câblage : `handleRealtime` reçu par une instance qui ne sert pas
|
|
151
|
+
* une connexion WebSocket — route sans `methods: ["WEBSOCKET"]`, ou faux
|
|
152
|
+
* contexte de test sans `type`. Levée plutôt que passée sous silence : le
|
|
153
|
+
* client attendrait un welcome qui ne vient jamais. Chemin froid.
|
|
154
|
+
*/
|
|
155
|
+
private outsideWebsocketError;
|
|
156
|
+
private receiveBinary;
|
|
123
157
|
/**
|
|
124
158
|
* **L1 — duplex serveur→client.** Émet une REQUÊTE vers le client de CETTE
|
|
125
159
|
* connexion (sur une action qu'il a exposée via `client.register(...)`) et attend
|
|
@@ -143,6 +177,22 @@ export declare abstract class RealtimeController<Emit extends EventsMap = Defaul
|
|
|
143
177
|
* handshake n'est pas terminé.
|
|
144
178
|
*/
|
|
145
179
|
protected notifyClient<K extends EventNames<Emit>>(method: K, params?: EventPayload<Emit, K>): void;
|
|
180
|
+
/**
|
|
181
|
+
* Émet une trame BINAIRE vers CETTE connexion, sans la sérialiser — pendant
|
|
182
|
+
* sortant de {@link onRealtimeBinary}, et de `NodefonySocket.sendBinary` côté
|
|
183
|
+
* client. Même contre-pression que les frames JSON-RPC.
|
|
184
|
+
*
|
|
185
|
+
* @param data - les octets à émettre (`Uint8Array`, donc un `Buffer` aussi).
|
|
186
|
+
* @returns `false` si le handshake n'est pas terminé, la connexion fermée ou
|
|
187
|
+
* la trame jetée par la contre-pression.
|
|
188
|
+
*/
|
|
189
|
+
protected sendBinaryToClient(data: Uint8Array): boolean;
|
|
190
|
+
/**
|
|
191
|
+
* État realtime de CETTE connexion, `undefined` avant le welcome — pour les
|
|
192
|
+
* chemins FROIDS (requête sortante, binaire). `notifyClient` et la réception
|
|
193
|
+
* gardent leur lecture en ligne : ils sont sur le chemin de chaque frame.
|
|
194
|
+
*/
|
|
195
|
+
private coldConnState;
|
|
146
196
|
/**
|
|
147
197
|
* Handshake : seams sécurité #4 (Origin RFC 6455 §10.2) + #2 (authenticator
|
|
148
198
|
* réseau) PUIS crée peer+transport, enregistre les actions, welcome + cleanup.
|
|
@@ -164,6 +214,14 @@ export declare abstract class RealtimeController<Emit extends EventsMap = Defaul
|
|
|
164
214
|
* canaux FULL-DUPLEX déclarés (entrée client → handler `realtimeInbound`).
|
|
165
215
|
*/
|
|
166
216
|
private onRealtimeNotification;
|
|
217
|
+
/**
|
|
218
|
+
* Acquitte un abonnement ACCEPTÉ (`realtime:subscribed`) — le seul signal
|
|
219
|
+
* qui dise au client à partir de quand le flux du canal lui est livré.
|
|
220
|
+
*
|
|
221
|
+
* Un refus n'y passe jamais : il a `realtime:denied`. Cold path (1×/canal et
|
|
222
|
+
* par connexion).
|
|
223
|
+
*/
|
|
224
|
+
private acknowledgeSubscription;
|
|
167
225
|
/** Abonne la connexion à un canal via le hub partagé (idempotent par connexion). */
|
|
168
226
|
private startChannel;
|
|
169
227
|
/**
|
|
@@ -16,8 +16,9 @@ import { type IRealtimeSocket, type IRealtimeChannel, type IChannelStats, type R
|
|
|
16
16
|
* l'état créé est passif, et c'est la fabrique du controller qui tranchera quand
|
|
17
17
|
* une connexion cliente le demandera. Un service n'invente donc jamais un canal
|
|
18
18
|
* pour les autres.
|
|
19
|
-
* - `request` : **non supporté** — un handle au-dessus du hub n'a PAS de
|
|
20
|
-
* (le hub est multi-clients)
|
|
19
|
+
* - `request` : **non supporté, et déprécié** — un handle au-dessus du hub n'a PAS de
|
|
20
|
+
* pair unique (le hub est multi-clients), et le contrat commun `IRealtimeSocket` ne
|
|
21
|
+
* le promet plus. Pour un RPC serveur→client 1-1, utiliser
|
|
21
22
|
* `RealtimeController.requestClient` (par connexion, L1).
|
|
22
23
|
*
|
|
23
24
|
* Lazy : aucune structure allouée tant que le service n'`on`/`subscribe`/`publish` pas.
|
|
@@ -25,7 +26,7 @@ import { type IRealtimeSocket, type IRealtimeChannel, type IChannelStats, type R
|
|
|
25
26
|
* @typeParam Emit — canaux pub/sub SORTANTS (`publish`) = ServerToClient.
|
|
26
27
|
* @typeParam Listen — canaux pub/sub RÉCEPTIONNÉS (`subscribe`/`on`) = ClientToServer.
|
|
27
28
|
*/
|
|
28
|
-
export declare class ServerRealtimeSocket<Emit extends EventsMap = DefaultEventsMap, Listen extends EventsMap = DefaultEventsMap, Actions extends ActionsMap = DefaultActionsMap> implements IRealtimeSocket<Emit, Listen
|
|
29
|
+
export declare class ServerRealtimeSocket<Emit extends EventsMap = DefaultEventsMap, Listen extends EventsMap = DefaultEventsMap, Actions extends ActionsMap = DefaultActionsMap> implements IRealtimeSocket<Emit, Listen> {
|
|
29
30
|
#private;
|
|
30
31
|
private readonly hub;
|
|
31
32
|
constructor(hub?: import("./RealtimeHub.js").RealtimeHub);
|
|
@@ -39,14 +40,23 @@ export declare class ServerRealtimeSocket<Emit extends EventsMap = DefaultEvents
|
|
|
39
40
|
subscribe(channel: EventNames<Listen> | (string & {})): void;
|
|
40
41
|
/** Désabonne (ref-compté) : coupe le sink du hub au DERNIER consommateur. */
|
|
41
42
|
unsubscribe(channel: EventNames<Listen> | (string & {})): void;
|
|
42
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Branche un handler de réception sur un canal. Renvoie un `dispose`.
|
|
45
|
+
*
|
|
46
|
+
* Le handler typé par `Listen` se range sans conversion sous
|
|
47
|
+
* `RealtimeHandler`, qui porte la frontière du contrat typé : il reçoit ce
|
|
48
|
+
* que le hub publie, dont `Listen` décrit la forme sans la valider.
|
|
49
|
+
*/
|
|
43
50
|
on<K extends string>(channel: K, handler: K extends EventNames<Listen> ? (payload: EventPayload<Listen, K>) => void : RealtimeHandler): () => void;
|
|
44
|
-
/** Retire un handler d'un canal. */
|
|
51
|
+
/** Retire un handler d'un canal — même frontière que {@link on}. */
|
|
45
52
|
off<K extends string>(channel: K, handler: K extends EventNames<Listen> ? (payload: EventPayload<Listen, K>) => void : RealtimeHandler): void;
|
|
46
53
|
/**
|
|
47
|
-
* **Non support
|
|
48
|
-
* (multi-clients)
|
|
49
|
-
* `
|
|
54
|
+
* **Non supporté** : rejette toujours. Un handle au-dessus du hub n'a pas de pair
|
|
55
|
+
* unique (multi-clients), et le contrat commun `IRealtimeSocket` ne promet plus
|
|
56
|
+
* `request`. Comportement inchangé pour toute la série 10.
|
|
57
|
+
*
|
|
58
|
+
* @deprecated Retrait en 11.0. Pour un RPC serveur→client 1-1, utiliser
|
|
59
|
+
* `RealtimeController.requestClient` (par connexion, L1).
|
|
50
60
|
*/
|
|
51
61
|
request<K extends string, T = unknown>(_method: K, _params?: ContractParams<Actions, K>, _timeoutMs?: number): Promise<ContractResult<Actions, K, T>>;
|
|
52
62
|
/** Vue par-canal ({@link IRealtimeChannel}) — fine liaison sur les primitives. */
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trame d'acquittement d'abonnement — `realtime:subscribed`, écrite d'un bloc.
|
|
3
|
+
*
|
|
4
|
+
* Tout ce qui précède le nom du canal ne dépend que du protocole : il est
|
|
5
|
+
* calculé une fois au chargement du module. Seul le canal se sérialise par
|
|
6
|
+
* trame. On évite ainsi l'enveloppe allouée et le `JSON.stringify` générique
|
|
7
|
+
* de `JsonRpcPeer.notify` — mesuré à ~0,5 µs par acquittement, soit l'essentiel
|
|
8
|
+
* du surcoût d'un `subscribe` acquitté.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ C'est une SECONDE écriture du format de notification : son égalité avec
|
|
11
|
+
* `JsonRpcPeer.buildNotification` est verrouillée par `subscribedAck.test.ts`,
|
|
12
|
+
* sur des noms de canal hostiles (guillemets, barres obliques inverses,
|
|
13
|
+
* caractères de contrôle, Unicode).
|
|
14
|
+
*
|
|
15
|
+
* @internal
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Sérialise la notification `realtime:subscribed { channel }`.
|
|
19
|
+
*
|
|
20
|
+
* @param channel - le canal acquitté (entrée réseau : échappé par `JSON.stringify`).
|
|
21
|
+
* @returns la trame JSON-RPC 2.0 prête à écrire sur le transport.
|
|
22
|
+
*/
|
|
23
|
+
export declare function subscribedAckFrame(channel: string): string;
|