@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,5 +1,15 @@
|
|
|
1
|
+
import { isRecord } from "../guards.js";
|
|
1
2
|
import { CLUSTER_PROBE_CTL_KIND, CLUSTER_PROBE_KIND, CLUSTER_PROBE_SNAPSHOT_KIND, RichProcessProbe, isClusterMessage, isClusterProbeEnrich, readOrmRich } from "nodefony";
|
|
2
|
-
|
|
3
|
+
// nodefony/src/cluster/ClusterProbeClient.ts
|
|
4
|
+
/**
|
|
5
|
+
* Vrai si la valeur est une liste de santés d'instance. Le snapshot vient du
|
|
6
|
+
* master du cluster par IPC — un process de la MÊME application, pas le
|
|
7
|
+
* réseau : on vérifie la forme qui sert de clé (`instanceId`), le reste est
|
|
8
|
+
* la sonde du même code.
|
|
9
|
+
*/
|
|
10
|
+
function isRealtimeHealthList(value) {
|
|
11
|
+
return Array.isArray(value) && value.every((h) => isRecord(h) && typeof h.instanceId === "string");
|
|
12
|
+
}
|
|
3
13
|
/** Transport de production : `process.send` + `process.on("message")`. */
|
|
4
14
|
const processProbeTransport = {
|
|
5
15
|
send(report) {
|
|
@@ -213,10 +223,11 @@ var ClusterProbeClient = class {
|
|
|
213
223
|
return;
|
|
214
224
|
}
|
|
215
225
|
if (!isClusterMessage(msg) || msg.kind !== CLUSTER_PROBE_SNAPSHOT_KIND) return;
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
this.#
|
|
226
|
+
if (!isRecord(msg)) return;
|
|
227
|
+
const { ts, instances } = msg;
|
|
228
|
+
if (isRealtimeHealthList(instances)) {
|
|
229
|
+
this.#lastInstances = instances;
|
|
230
|
+
this.#snapTs = typeof ts === "number" ? ts : Date.now();
|
|
220
231
|
}
|
|
221
232
|
}
|
|
222
233
|
/** Vue POD agrégée, ou `null` tant qu'aucun snapshot n'est reçu (cold start). */
|
|
@@ -280,5 +291,4 @@ function clusterProbeInstance(pid) {
|
|
|
280
291
|
if (!health) return null;
|
|
281
292
|
return health.instances.find((i) => i.process?.pid === pid || i.instanceId === String(pid)) ?? null;
|
|
282
293
|
}
|
|
283
|
-
//#endregion
|
|
284
294
|
export { ClusterProbeClient, ClusterProbeClient as default, clusterProbeHealth, clusterProbeInstance, clusterProbeRequestEnrich, mergeClusterHealth, processProbeTransport, setClusterProbeClient };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// nodefony/src/guards.ts
|
|
2
|
+
/**
|
|
3
|
+
* Gardes de type internes au paquet — elles remplacent une conversion (`as`)
|
|
4
|
+
* par une vérification faite à l'exécution, pour qu'une valeur d'une autre
|
|
5
|
+
* forme soit écartée au lieu de traverser le code sous un type qui ment.
|
|
6
|
+
*
|
|
7
|
+
* Non exportées par le paquet : ce sont des détails d'implémentation.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Vrai si la valeur est un objet non nul : ses propriétés se lisent alors,
|
|
11
|
+
* typées `unknown`, et chacune se vérifie avant usage.
|
|
12
|
+
*
|
|
13
|
+
* @param value - valeur venue du réseau, d'une métadonnée ou du conteneur
|
|
14
|
+
* @returns `true` pour tout objet (tableau compris), `false` pour `null` et les primitives
|
|
15
|
+
*/
|
|
16
|
+
function isRecord(value) {
|
|
17
|
+
return typeof value === "object" && value !== null;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Vrai si la valeur est une fonction. Toute fonction JavaScript s'appelle avec
|
|
21
|
+
* n'importe quels arguments et rend une valeur inconnue : la signature annoncée
|
|
22
|
+
* ne promet rien de plus que ce que `typeof` a vérifié.
|
|
23
|
+
*
|
|
24
|
+
* @param value - valeur lue par son nom (métadonnée, propriété dynamique)
|
|
25
|
+
* @returns `true` si la valeur est appelable
|
|
26
|
+
*/
|
|
27
|
+
function isCallable(value) {
|
|
28
|
+
return typeof value === "function";
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Vrai si la valeur se comporte comme une promesse (transport asynchrone) —
|
|
32
|
+
* un client Redis rend une promesse, un faux transport de test rend `void`.
|
|
33
|
+
*
|
|
34
|
+
* @param value - retour d'un transport
|
|
35
|
+
* @returns `true` si la valeur porte une méthode `then`
|
|
36
|
+
*/
|
|
37
|
+
function isThenable(value) {
|
|
38
|
+
return isRecord(value) && typeof value.then === "function";
|
|
39
|
+
}
|
|
40
|
+
export { isCallable, isRecord, isThenable };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
// nodefony/src/server/AnonymousRealtimeToken.ts
|
|
2
2
|
/**
|
|
3
3
|
* Token realtime ANONYME — fallback Zero Trust quand aucun matcher
|
|
4
4
|
* `IRealtimeAuthenticatorMatcher` ne capture le handshake.
|
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
* token avec `isAuthenticated() === false` et appliqueront leur politique
|
|
13
13
|
* (Zero Trust : zone protégée → frame refusée par `beforeDispatch`).
|
|
14
14
|
*/
|
|
15
|
-
const anonymousAttributes = Object.freeze({});
|
|
16
15
|
const ANONYMOUS_REALTIME_TOKEN = Object.freeze({
|
|
17
16
|
type: "anonymous",
|
|
18
17
|
getUserIdentifier() {
|
|
@@ -27,9 +26,6 @@ const ANONYMOUS_REALTIME_TOKEN = Object.freeze({
|
|
|
27
26
|
getScopes() {
|
|
28
27
|
return [];
|
|
29
28
|
},
|
|
30
|
-
getAttribute(
|
|
31
|
-
return anonymousAttributes[key];
|
|
32
|
-
}
|
|
29
|
+
getAttribute(_key) {}
|
|
33
30
|
});
|
|
34
|
-
//#endregion
|
|
35
31
|
export { ANONYMOUS_REALTIME_TOKEN, ANONYMOUS_REALTIME_TOKEN as default };
|
|
@@ -2,7 +2,7 @@ import { getRealtimeHub } from "./RealtimeHub.js";
|
|
|
2
2
|
import { listBackplaneDrivers } from "../backplane/backplaneRegistry.js";
|
|
3
3
|
import { clusterProbeHealth } from "../cluster/ClusterProbeClient.js";
|
|
4
4
|
import { Nodefony, ProcessProbe, readOrmHealth } from "nodefony";
|
|
5
|
-
|
|
5
|
+
// nodefony/src/server/RealtimeAdminApi.ts
|
|
6
6
|
const processProbe = new ProcessProbe();
|
|
7
7
|
/**
|
|
8
8
|
* Producteur `IAdminApi` de la **socket Nodefony** — exposé sous
|
|
@@ -83,5 +83,4 @@ function createRealtimeAdminApi() {
|
|
|
83
83
|
adminEndpoints: () => endpoints
|
|
84
84
|
};
|
|
85
85
|
}
|
|
86
|
-
//#endregion
|
|
87
86
|
export { buildOwnHealth, buildRealtimeHealth, createRealtimeAdminApi };
|
|
@@ -1,14 +1,21 @@
|
|
|
1
|
+
import { isRecord } from "../guards.js";
|
|
1
2
|
import { ANONYMOUS_REALTIME_TOKEN } from "./AnonymousRealtimeToken.js";
|
|
2
3
|
import { getRealtimeHub } from "./RealtimeHub.js";
|
|
3
4
|
import { DEFAULT_ACTION_POLICY, getRealtimeActions, getRealtimeChannelPolicies, getRealtimeChannels, getRealtimeInbound } from "../../decorators/realtimeDecorators.js";
|
|
4
5
|
import { createSyslogUplinkHandler } from "./syslogUplink.js";
|
|
5
6
|
import { WsConnectionTransport } from "../transport/WsConnectionTransport.js";
|
|
6
7
|
import { welcomeEnv } from "./welcomeEnv.js";
|
|
8
|
+
import { subscribedAckFrame } from "./subscribedAck.js";
|
|
7
9
|
import { deniedDetail } from "./deniedDetail.js";
|
|
8
|
-
import { BootConfigurationError, JsonRpcPeer, PLATFORM_INBOUND, RequestContext, RpcEnvelope, RpcError, Scope, identityHint } from "nodefony";
|
|
9
|
-
import { readBackpressureOptions } from "@nodefony/http";
|
|
10
|
-
import { Controller } from "@nodefony/framework";
|
|
11
|
-
|
|
10
|
+
import { BootConfigurationError, JsonRpcError, JsonRpcPeer, JsonRpcServerError, PLATFORM_INBOUND, RequestContext, RpcEnvelope, RpcError, Scope, identityHint, isJsonRpcId, isPromise, jsonRpcFailure } from "nodefony";
|
|
11
|
+
import { isWebsocketContext, readBackpressureOptions, toValidationFields } from "@nodefony/http";
|
|
12
|
+
import { Controller, buildContextParamArgs, getParamArgsMeta, isFrameworkRouter } from "@nodefony/framework";
|
|
13
|
+
// nodefony/src/server/RealtimeController.ts
|
|
14
|
+
/**
|
|
15
|
+
* Une action RPC n'a ni variables de route ni URL : `@Param` et `@Query` y
|
|
16
|
+
* lisent cet objet vide.
|
|
17
|
+
*/
|
|
18
|
+
const NO_ROUTE_PARAMS = Object.freeze({});
|
|
12
19
|
/**
|
|
13
20
|
* Statut HTTP-équivalent d'une erreur survenue pendant une invocation du pont.
|
|
14
21
|
*
|
|
@@ -22,11 +29,12 @@ import { Controller } from "@nodefony/framework";
|
|
|
22
29
|
*/
|
|
23
30
|
function httpStatusOfFrameError(e) {
|
|
24
31
|
if (e instanceof RpcError) {
|
|
25
|
-
const status = e.data
|
|
32
|
+
const status = isRecord(e.data) ? e.data.status : void 0;
|
|
26
33
|
if (typeof status === "number") return status;
|
|
27
|
-
return e.code ===
|
|
34
|
+
return e.code === JsonRpcError.INVALID_PARAMS ? 400 : null;
|
|
28
35
|
}
|
|
29
|
-
|
|
36
|
+
if (e instanceof Error && toValidationFields(e) !== null) return 422;
|
|
37
|
+
const code = isRecord(e) ? e.code : void 0;
|
|
30
38
|
return typeof code === "number" && code >= 400 && code <= 599 ? code : null;
|
|
31
39
|
}
|
|
32
40
|
/**
|
|
@@ -40,7 +48,7 @@ function httpStatusOfFrameError(e) {
|
|
|
40
48
|
function toFrameRpcError(e, status, requestId) {
|
|
41
49
|
if (e instanceof RpcError) {
|
|
42
50
|
if (!requestId) return e;
|
|
43
|
-
const data =
|
|
51
|
+
const data = isRecord(e.data) ? {
|
|
44
52
|
...e.data,
|
|
45
53
|
requestId
|
|
46
54
|
} : { requestId };
|
|
@@ -49,10 +57,27 @@ function toFrameRpcError(e, status, requestId) {
|
|
|
49
57
|
if (status !== null) {
|
|
50
58
|
const data = { status };
|
|
51
59
|
if (requestId) data.requestId = requestId;
|
|
52
|
-
|
|
60
|
+
if (status === 422 && e instanceof Error) {
|
|
61
|
+
const fields = toValidationFields(e);
|
|
62
|
+
if (fields !== null) data.fields = fields;
|
|
63
|
+
}
|
|
64
|
+
return new RpcError(e instanceof Error ? e.message : String(e), JsonRpcServerError.DEFAULT, data);
|
|
53
65
|
}
|
|
54
66
|
return e;
|
|
55
67
|
}
|
|
68
|
+
/** Le canal nommé par les params d'un `subscribe`/`unsubscribe` — entrée réseau. */
|
|
69
|
+
function channelParam(params) {
|
|
70
|
+
return isRecord(params) && typeof params.channel === "string" ? params.channel : void 0;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Le serveur WebSocket qui sert la connexion, ou `null` — lu une fois par
|
|
74
|
+
* poignée de main pour ses réglages de contre-pression. Un `WebSocketServer`
|
|
75
|
+
* porte toujours ses `options` ; aucun serveur HTTP n'en déclare.
|
|
76
|
+
*/
|
|
77
|
+
function webSocketServerOf(ctx) {
|
|
78
|
+
const server = ctx.server;
|
|
79
|
+
return server && "options" in server ? server : null;
|
|
80
|
+
}
|
|
56
81
|
let warnedUnenforcedPolicies = false;
|
|
57
82
|
/**
|
|
58
83
|
* RealtimeController — base d'un endpoint WebSocket temps réel SERVEUR (JSON-RPC 2.0).
|
|
@@ -62,7 +87,7 @@ let warnedUnenforcedPolicies = false;
|
|
|
62
87
|
* — écrit UNE fois, partagé par tous les modules. Un contrôleur concret ne déclare que
|
|
63
88
|
* son métier : {@link createRealtimeChannel} (providers de canaux) + {@link realtimeActions}.
|
|
64
89
|
*
|
|
65
|
-
* Chaque connexion compose un `JsonRpcPeer` (le MÊME que `
|
|
90
|
+
* Chaque connexion compose un `JsonRpcPeer` (le MÊME que `NodefonySocket` côté navigateur
|
|
66
91
|
* — symétrie isomorphe) branché sur un {@link WsConnectionTransport}.
|
|
67
92
|
*
|
|
68
93
|
* Usage : le sous-classe garde sa route WS et délègue —
|
|
@@ -106,8 +131,20 @@ var RealtimeController = class extends Controller {
|
|
|
106
131
|
constructor(name, context) {
|
|
107
132
|
new.target.assertScope();
|
|
108
133
|
super(name, context);
|
|
134
|
+
this.#connection = isWebsocketContext(context) ? context : null;
|
|
109
135
|
}
|
|
110
136
|
/**
|
|
137
|
+
* La connexion WebSocket que sert CETTE instance — rétrécie UNE fois, ici,
|
|
138
|
+
* plutôt qu'à chaque frame.
|
|
139
|
+
*
|
|
140
|
+
* Sûr parce que l'instance est liée à sa connexion pour toute sa vie : la
|
|
141
|
+
* portée `singleton` est refusée au constructeur, et le contexte n'est posé
|
|
142
|
+
* qu'à la construction. `null` si la classe sert aussi une route HTTP et que
|
|
143
|
+
* cette instance en est issue. Le chemin de chaque frame lit ce champ au lieu
|
|
144
|
+
* de l'accesseur `context` : une lecture de champ, sans appel.
|
|
145
|
+
*/
|
|
146
|
+
#connection;
|
|
147
|
+
/**
|
|
111
148
|
* Map des factories de canaux EXACT, lue depuis les décorateurs `@RealtimeChannel`
|
|
112
149
|
* au handshake (cold-path) et mémoïsée par instance. `null` = pas de décorateur
|
|
113
150
|
* sur cette classe → la base court-circuite la lecture metadata par frame.
|
|
@@ -179,17 +216,17 @@ var RealtimeController = class extends Controller {
|
|
|
179
216
|
* est structurel, pas un choix : c'est au client d'attendre `realtime:welcome`
|
|
180
217
|
* avant de pousser.
|
|
181
218
|
*
|
|
182
|
-
* ⚠️ Cette phrase a longtemps ajouté « ce que
|
|
219
|
+
* ⚠️ Cette phrase a longtemps ajouté « ce que le client fait nativement ».
|
|
183
220
|
* C'était FAUX : le client rejouait ses abonnements dès l'ouverture de la socket,
|
|
184
221
|
* si bien qu'un `subscribe` posé avant le démarrage et TOUS ceux d'après une
|
|
185
222
|
* reconnexion étaient perdus ici même, sans un mot des deux côtés. Un contrat écrit
|
|
186
223
|
* d'un seul côté du fil n'est pas tenu ; il l'est désormais par
|
|
187
|
-
* `
|
|
188
|
-
* écoute (welcome) » de `
|
|
224
|
+
* `NodefonySocket.replaySubscriptions` et les deux cas « la fenêtre où le serveur
|
|
225
|
+
* écoute (welcome) » de `NodefonySocketCoverage.test.ts`.
|
|
189
226
|
*/
|
|
190
227
|
handleRealtime(message) {
|
|
191
|
-
const ctx = this
|
|
192
|
-
if (
|
|
228
|
+
const ctx = this.#connection;
|
|
229
|
+
if (ctx === null) throw this.outsideWebsocketError();
|
|
193
230
|
if (message == null) {
|
|
194
231
|
this.onHandshake(ctx).catch((e) => {
|
|
195
232
|
this.log(`WS realtime handshake failed: ${e instanceof Error ? e.message : String(e)}`, "ERROR");
|
|
@@ -197,7 +234,28 @@ var RealtimeController = class extends Controller {
|
|
|
197
234
|
});
|
|
198
235
|
return;
|
|
199
236
|
}
|
|
200
|
-
ctx.__nfRealtime
|
|
237
|
+
const state = ctx.__nfRealtime;
|
|
238
|
+
if (typeof message === "string") {
|
|
239
|
+
state?.transport.feed(message);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (state !== void 0) this.receiveBinary(state.transport, message);
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Faute de câblage : `handleRealtime` reçu par une instance qui ne sert pas
|
|
246
|
+
* une connexion WebSocket — route sans `methods: ["WEBSOCKET"]`, ou faux
|
|
247
|
+
* contexte de test sans `type`. Levée plutôt que passée sous silence : le
|
|
248
|
+
* client attendrait un welcome qui ne vient jamais. Chemin froid.
|
|
249
|
+
*/
|
|
250
|
+
outsideWebsocketError() {
|
|
251
|
+
return /* @__PURE__ */ new TypeError(`${this.constructor.name}.handleRealtime : cette instance ne sert pas une connexion WebSocket (contexte de type « ${String(this.context?.type)} »). La route doit déclarer requirements: { methods: ["WEBSOCKET"] } ; un faux contexte de test déclare type: "websocket".`);
|
|
252
|
+
}
|
|
253
|
+
receiveBinary(transport, data) {
|
|
254
|
+
if (this.onRealtimeBinary !== void 0) {
|
|
255
|
+
this.onRealtimeBinary(data);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (transport.discard("binary") === 1) this.log("WS realtime : trame binaire reçue et écartée — le protocole est du JSON-RPC texte ; pour la lire, surcharger onRealtimeBinary()", "WARNING");
|
|
201
259
|
}
|
|
202
260
|
/**
|
|
203
261
|
* **L1 — duplex serveur→client.** Émet une REQUÊTE vers le client de CETTE
|
|
@@ -215,9 +273,9 @@ var RealtimeController = class extends Controller {
|
|
|
215
273
|
* @throws (rejet) si le handshake n'est pas terminé (aucun peer encore).
|
|
216
274
|
*/
|
|
217
275
|
requestClient(method, params, timeoutMs) {
|
|
218
|
-
const state = this.
|
|
276
|
+
const state = this.coldConnState();
|
|
219
277
|
if (!state) return Promise.reject(/* @__PURE__ */ new Error("requestClient: connexion realtime non établie (welcome non émis)"));
|
|
220
|
-
return state.peer.
|
|
278
|
+
return state.peer.requestAs(method, params, timeoutMs);
|
|
221
279
|
}
|
|
222
280
|
/**
|
|
223
281
|
* Notification CIBLÉE serveur→client (sans réponse) vers CETTE connexion, hors
|
|
@@ -226,7 +284,27 @@ var RealtimeController = class extends Controller {
|
|
|
226
284
|
* handshake n'est pas terminé.
|
|
227
285
|
*/
|
|
228
286
|
notifyClient(method, params) {
|
|
229
|
-
this
|
|
287
|
+
this.#connection?.__nfRealtime?.peer.notify(method, params);
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Émet une trame BINAIRE vers CETTE connexion, sans la sérialiser — pendant
|
|
291
|
+
* sortant de {@link onRealtimeBinary}, et de `NodefonySocket.sendBinary` côté
|
|
292
|
+
* client. Même contre-pression que les frames JSON-RPC.
|
|
293
|
+
*
|
|
294
|
+
* @param data - les octets à émettre (`Uint8Array`, donc un `Buffer` aussi).
|
|
295
|
+
* @returns `false` si le handshake n'est pas terminé, la connexion fermée ou
|
|
296
|
+
* la trame jetée par la contre-pression.
|
|
297
|
+
*/
|
|
298
|
+
sendBinaryToClient(data) {
|
|
299
|
+
return this.coldConnState()?.transport.sendBinary(data) ?? false;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* État realtime de CETTE connexion, `undefined` avant le welcome — pour les
|
|
303
|
+
* chemins FROIDS (requête sortante, binaire). `notifyClient` et la réception
|
|
304
|
+
* gardent leur lecture en ligne : ils sont sur le chemin de chaque frame.
|
|
305
|
+
*/
|
|
306
|
+
coldConnState() {
|
|
307
|
+
return this.#connection?.__nfRealtime;
|
|
230
308
|
}
|
|
231
309
|
/**
|
|
232
310
|
* Handshake : seams sécurité #4 (Origin RFC 6455 §10.2) + #2 (authenticator
|
|
@@ -244,8 +322,7 @@ var RealtimeController = class extends Controller {
|
|
|
244
322
|
* - `authenticate` throw → code 4001 (`unauthorized`).
|
|
245
323
|
*/
|
|
246
324
|
async onHandshake(ctx) {
|
|
247
|
-
|
|
248
|
-
if (holder.__nfRealtime?.welcomed) return;
|
|
325
|
+
if (ctx.__nfRealtime?.welcomed) return;
|
|
249
326
|
const conn = ctx.connection;
|
|
250
327
|
if (!conn) return;
|
|
251
328
|
const hub = getRealtimeHub();
|
|
@@ -270,7 +347,7 @@ var RealtimeController = class extends Controller {
|
|
|
270
347
|
return;
|
|
271
348
|
}
|
|
272
349
|
else token = ANONYMOUS_REALTIME_TOKEN;
|
|
273
|
-
const transport = new WsConnectionTransport(conn, readBackpressureOptions(ctx
|
|
350
|
+
const transport = new WsConnectionTransport(conn, readBackpressureOptions(webSocketServerOf(ctx)));
|
|
274
351
|
const peerOptions = {
|
|
275
352
|
send: (frame) => {
|
|
276
353
|
let json;
|
|
@@ -278,16 +355,9 @@ var RealtimeController = class extends Controller {
|
|
|
278
355
|
json = JSON.stringify(frame);
|
|
279
356
|
} catch (e) {
|
|
280
357
|
this.log(`WS realtime send: payload non sérialisable — ${e instanceof Error ? e.message : String(e)}`, "ERROR");
|
|
281
|
-
const id = frame.id;
|
|
282
|
-
if (id
|
|
283
|
-
json = JSON.stringify(
|
|
284
|
-
jsonrpc: "2.0",
|
|
285
|
-
id,
|
|
286
|
-
error: {
|
|
287
|
-
code: -32603,
|
|
288
|
-
message: "internal error: non-serializable payload"
|
|
289
|
-
}
|
|
290
|
-
});
|
|
358
|
+
const id = isRecord(frame) ? frame.id : void 0;
|
|
359
|
+
if (!isJsonRpcId(id)) return;
|
|
360
|
+
json = JSON.stringify(jsonRpcFailure(id, JsonRpcError.INTERNAL_ERROR, "internal error: non-serializable payload"));
|
|
291
361
|
}
|
|
292
362
|
transport.send(json);
|
|
293
363
|
},
|
|
@@ -298,9 +368,10 @@ var RealtimeController = class extends Controller {
|
|
|
298
368
|
peerOptions.onFrameAudit = (reason, frame, auditedPeer) => {
|
|
299
369
|
if (reason !== "denied") return;
|
|
300
370
|
this.log("WS realtime frame refused by authorizer", "WARNING");
|
|
371
|
+
if (!isRecord(frame)) return;
|
|
301
372
|
const f = frame;
|
|
302
373
|
if (f.id !== void 0) return;
|
|
303
|
-
const channel = f.method === "subscribe" ?
|
|
374
|
+
const channel = f.method === "subscribe" ? channelParam(f.params) : typeof f.method === "string" ? f.method : void 0;
|
|
304
375
|
if (channel !== void 0) {
|
|
305
376
|
const hint = identityHint(this.kernel);
|
|
306
377
|
const denied = {
|
|
@@ -318,12 +389,13 @@ var RealtimeController = class extends Controller {
|
|
|
318
389
|
try {
|
|
319
390
|
parsed = JSON.parse(raw);
|
|
320
391
|
} catch {
|
|
392
|
+
if (transport.discard("malformed") === 1) this.log("WS realtime : trame texte illisible (pas du JSON) reçue et écartée", "WARNING");
|
|
321
393
|
return;
|
|
322
394
|
}
|
|
323
395
|
peer.receive(parsed);
|
|
324
396
|
});
|
|
325
397
|
const allActions = {
|
|
326
|
-
...getRealtimeActions(this),
|
|
398
|
+
...getRealtimeActions(this, this.actionParamsWrapper(ctx)),
|
|
327
399
|
...this.realtimeActions()
|
|
328
400
|
};
|
|
329
401
|
for (const [name, handler] of Object.entries(allActions)) {
|
|
@@ -378,7 +450,7 @@ var RealtimeController = class extends Controller {
|
|
|
378
450
|
channels: /* @__PURE__ */ new Map(),
|
|
379
451
|
inbound
|
|
380
452
|
};
|
|
381
|
-
|
|
453
|
+
ctx.__nfRealtime = state;
|
|
382
454
|
ctx.once("onFinish", () => {
|
|
383
455
|
const liveHub = getRealtimeHub();
|
|
384
456
|
for (const [channel, sink] of state.channels) liveHub.unsubscribe(channel, sink);
|
|
@@ -396,6 +468,7 @@ var RealtimeController = class extends Controller {
|
|
|
396
468
|
protocol: "jsonrpc-2.0",
|
|
397
469
|
channels: announcedChannels,
|
|
398
470
|
methods: peer.methods,
|
|
471
|
+
subscriptionAcks: true,
|
|
399
472
|
...welcomeEnv(env),
|
|
400
473
|
identity: {
|
|
401
474
|
type: token.type,
|
|
@@ -414,11 +487,11 @@ var RealtimeController = class extends Controller {
|
|
|
414
487
|
*/
|
|
415
488
|
onRealtimeNotification(ctx, method, params) {
|
|
416
489
|
if (method === "subscribe") {
|
|
417
|
-
this.startChannel(ctx, params
|
|
490
|
+
this.startChannel(ctx, channelParam(params));
|
|
418
491
|
return;
|
|
419
492
|
}
|
|
420
493
|
if (method === "unsubscribe") {
|
|
421
|
-
this.stopChannel(ctx, params
|
|
494
|
+
this.stopChannel(ctx, channelParam(params));
|
|
422
495
|
return;
|
|
423
496
|
}
|
|
424
497
|
const state = ctx.__nfRealtime;
|
|
@@ -428,11 +501,25 @@ var RealtimeController = class extends Controller {
|
|
|
428
501
|
handler(params, (payload) => state.peer.notify(method, payload));
|
|
429
502
|
}
|
|
430
503
|
}
|
|
504
|
+
/**
|
|
505
|
+
* Acquitte un abonnement ACCEPTÉ (`realtime:subscribed`) — le seul signal
|
|
506
|
+
* qui dise au client à partir de quand le flux du canal lui est livré.
|
|
507
|
+
*
|
|
508
|
+
* Un refus n'y passe jamais : il a `realtime:denied`. Cold path (1×/canal et
|
|
509
|
+
* par connexion).
|
|
510
|
+
*/
|
|
511
|
+
acknowledgeSubscription(state, channel) {
|
|
512
|
+
state.transport.send(subscribedAckFrame(channel));
|
|
513
|
+
}
|
|
431
514
|
/** Abonne la connexion à un canal via le hub partagé (idempotent par connexion). */
|
|
432
515
|
startChannel(ctx, channel) {
|
|
433
516
|
if (!channel) return;
|
|
434
517
|
const state = ctx.__nfRealtime;
|
|
435
|
-
if (!state
|
|
518
|
+
if (!state) return;
|
|
519
|
+
if (state.channels.has(channel)) {
|
|
520
|
+
this.acknowledgeSubscription(state, channel);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
436
523
|
const cap = getRealtimeHub().maxChannelsPerConnection;
|
|
437
524
|
if (cap !== null && state.channels.size >= cap) {
|
|
438
525
|
const denied = {
|
|
@@ -459,6 +546,7 @@ var RealtimeController = class extends Controller {
|
|
|
459
546
|
}, (payload) => JSON.stringify(JsonRpcPeer.buildNotification(channel, payload)))) {
|
|
460
547
|
state.channels.set(channel, sink);
|
|
461
548
|
if (isServedByPattern()) getRealtimeHub().noticeUnguardedDynamicChannel(channel);
|
|
549
|
+
this.acknowledgeSubscription(state, channel);
|
|
462
550
|
this.log(`WS subscribe → ${channel}`, "DEBUG");
|
|
463
551
|
return;
|
|
464
552
|
}
|
|
@@ -472,6 +560,48 @@ var RealtimeController = class extends Controller {
|
|
|
472
560
|
this.log(floor ? `WS subscribe refusé (canal de plateforme, aucun module de sécurité) → ${channel}` : `WS subscribe refusé (aucun producteur pour ce canal) → ${channel}`, "DEBUG");
|
|
473
561
|
}
|
|
474
562
|
/**
|
|
563
|
+
* Enveloppe des actions `@RealtimeAction` dont un paramètre est décoré
|
|
564
|
+
* (`@Body`, `@CurrentUser`…) : l'action reçoit ses arguments comme une route,
|
|
565
|
+
* résolus par la fonction du framework et non par une copie.
|
|
566
|
+
*
|
|
567
|
+
* L'identité ne change PAS : un message est déjà traité dans la bulle
|
|
568
|
+
* `RequestContext` du handshake (`WebsocketContext` lie son écouteur par
|
|
569
|
+
* `AsyncResource.bind`), où le pare-feu a posé `user` et `token`. La bulle
|
|
570
|
+
* ouverte ici HÉRITE de ce store et n'y ajoute que la charge (`body`, que
|
|
571
|
+
* `@Body` lit dans l'ALS comme pour `api.request`) — une écriture
|
|
572
|
+
* `RequestContext.set()` dans l'action reste ainsi propre à l'appel.
|
|
573
|
+
*
|
|
574
|
+
* Une action sans paramètre décoré est rendue telle quelle : rien ne change
|
|
575
|
+
* pour elle, ni comportement ni coût. Les métadonnées se lisent ICI, une fois
|
|
576
|
+
* par connexion.
|
|
577
|
+
*
|
|
578
|
+
* @param ctx - le contexte de la connexion
|
|
579
|
+
* @returns l'enveloppe passée à {@link getRealtimeActions}
|
|
580
|
+
*/
|
|
581
|
+
actionParamsWrapper(ctx) {
|
|
582
|
+
const proto = Reflect.getPrototypeOf(this);
|
|
583
|
+
return (method, propertyKey) => {
|
|
584
|
+
if (proto === null) return method;
|
|
585
|
+
const metas = getParamArgsMeta(proto, propertyKey);
|
|
586
|
+
if (metas === null) return method;
|
|
587
|
+
return (params) => {
|
|
588
|
+
const store = {
|
|
589
|
+
requestId: ctx.requestId,
|
|
590
|
+
...RequestContext.get(),
|
|
591
|
+
body: params
|
|
592
|
+
};
|
|
593
|
+
const result = RequestContext.run(store, () => method(...buildContextParamArgs(metas, ctx, NO_ROUTE_PARAMS, NO_ROUTE_PARAMS)));
|
|
594
|
+
if (isPromise(result)) {
|
|
595
|
+
const release = () => RequestContext.release(store);
|
|
596
|
+
result.then(release, release);
|
|
597
|
+
return result;
|
|
598
|
+
}
|
|
599
|
+
RequestContext.release(store);
|
|
600
|
+
return result;
|
|
601
|
+
};
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
475
605
|
* Handler de la méthode RPC `api.request {path}` — le **pont API souverain**
|
|
476
606
|
* (POC Ph.3) : résout le path porté par le message via le Router
|
|
477
607
|
* (`cleanPathOverride` — l'URL de la connexion n'est jamais mutée) puis
|
|
@@ -491,77 +621,91 @@ var RealtimeController = class extends Controller {
|
|
|
491
621
|
* Tout autre throw de l'action reste opaque (`-32603`, Zero Trust).
|
|
492
622
|
*/
|
|
493
623
|
async invokeApiRequest(ctx, params, peer) {
|
|
494
|
-
const p = params;
|
|
624
|
+
const p = isRecord(params) ? params : void 0;
|
|
495
625
|
const path = p?.path;
|
|
496
|
-
if (typeof path !== "string" || path.charCodeAt(0) !== 47) throw new RpcError("api.request: params.path invalide",
|
|
626
|
+
if (typeof path !== "string" || path.charCodeAt(0) !== 47) throw new RpcError("api.request: params.path invalide", JsonRpcError.INVALID_PARAMS);
|
|
497
627
|
const method = typeof p?.method === "string" ? p.method.toUpperCase() : "GET";
|
|
498
|
-
if (method !== "GET" && method !== "POST" && method !== "PUT" && method !== "PATCH" && method !== "DELETE") throw new RpcError(`api.request: méthode ${method} non supportée`,
|
|
628
|
+
if (method !== "GET" && method !== "POST" && method !== "PUT" && method !== "PATCH" && method !== "DELETE") throw new RpcError(`api.request: méthode ${method} non supportée`, JsonRpcError.INVALID_PARAMS);
|
|
499
629
|
const router = ctx.router;
|
|
500
|
-
if (!router) throw new RpcError("api.request: router indisponible",
|
|
630
|
+
if (!isFrameworkRouter(router)) throw new RpcError("api.request: router indisponible", JsonRpcServerError.DEFAULT, { status: 500 });
|
|
501
631
|
const qIdx = path.indexOf("?");
|
|
502
632
|
const pathname = qIdx === -1 ? path : path.slice(0, qIdx);
|
|
503
633
|
const frame = ctx.beginFrame(method, path);
|
|
634
|
+
let store = null;
|
|
504
635
|
try {
|
|
505
|
-
frame?.phaseStart("resolve");
|
|
506
|
-
let resolver;
|
|
507
|
-
try {
|
|
508
|
-
resolver = router.resolve(ctx, pathname, method === "GET" ? void 0 : method);
|
|
509
|
-
} finally {
|
|
510
|
-
frame?.phaseEnd("resolve");
|
|
511
|
-
}
|
|
512
|
-
if (!resolver.resolve) throw new RpcError(`api.request: not found ${pathname}`, -32e3, { status: 404 });
|
|
513
|
-
if (frame) frame.resolver = resolver;
|
|
514
|
-
if (qIdx !== -1 && qIdx < path.length - 1) {
|
|
515
|
-
const sp = new URLSearchParams(path.slice(qIdx + 1));
|
|
516
|
-
const query = {};
|
|
517
|
-
for (const [k, v] of sp) {
|
|
518
|
-
const prev = query[k];
|
|
519
|
-
if (prev === void 0) query[k] = v;
|
|
520
|
-
else if (Array.isArray(prev)) prev.push(v);
|
|
521
|
-
else query[k] = [prev, v];
|
|
522
|
-
}
|
|
523
|
-
resolver.queryOverride = query;
|
|
524
|
-
}
|
|
525
636
|
const token = getRealtimeHub().getTokenForPeer(peer);
|
|
526
|
-
if (token.isValid) {
|
|
527
|
-
let valid;
|
|
528
|
-
frame?.phaseStart("identity");
|
|
529
|
-
try {
|
|
530
|
-
valid = await token.isValid();
|
|
531
|
-
} catch {
|
|
532
|
-
valid = false;
|
|
533
|
-
} finally {
|
|
534
|
-
frame?.phaseEnd("identity");
|
|
535
|
-
}
|
|
536
|
-
if (!valid) throw new RpcError("api.request: identité de session expirée ou invalide", -32e3, { status: 401 });
|
|
537
|
-
}
|
|
538
637
|
const renderSink = {};
|
|
539
|
-
const
|
|
540
|
-
requestId: ctx.requestId,
|
|
638
|
+
const frameStore = {
|
|
639
|
+
requestId: frame?.requestId ?? ctx.requestId,
|
|
541
640
|
scheme: ctx.scheme,
|
|
542
641
|
token,
|
|
543
642
|
user: token.getAttribute("user"),
|
|
544
|
-
userId: token.getUserIdentifier(),
|
|
545
643
|
context: ctx,
|
|
546
|
-
resolver,
|
|
644
|
+
resolver: void 0,
|
|
547
645
|
scope: ctx.container instanceof Scope ? ctx.container : void 0,
|
|
548
646
|
body: p?.body,
|
|
549
647
|
idempotencyKey: typeof p?.idempotencyKey === "string" ? p.idempotencyKey : void 0,
|
|
550
648
|
renderSink,
|
|
551
649
|
invocation: frame ?? void 0,
|
|
552
650
|
queries: frame?.profilerQueries ?? void 0
|
|
553
|
-
}
|
|
651
|
+
};
|
|
652
|
+
store = frameStore;
|
|
653
|
+
const result = await RequestContext.run(frameStore, async () => {
|
|
654
|
+
frame?.phaseStart("resolve");
|
|
655
|
+
let resolver;
|
|
656
|
+
try {
|
|
657
|
+
resolver = router.resolve(ctx, pathname, method === "GET" ? void 0 : method);
|
|
658
|
+
} finally {
|
|
659
|
+
frame?.phaseEnd("resolve");
|
|
660
|
+
}
|
|
661
|
+
if (!resolver.resolve) throw new RpcError(`api.request: not found ${pathname}`, JsonRpcServerError.DEFAULT, { status: 404 });
|
|
662
|
+
if (frame) frame.resolver = resolver;
|
|
663
|
+
frameStore.resolver = resolver;
|
|
664
|
+
if (qIdx !== -1 && qIdx < path.length - 1) {
|
|
665
|
+
const sp = new URLSearchParams(path.slice(qIdx + 1));
|
|
666
|
+
const query = {};
|
|
667
|
+
for (const [k, v] of sp) {
|
|
668
|
+
const prev = query[k];
|
|
669
|
+
if (prev === void 0) query[k] = v;
|
|
670
|
+
else if (Array.isArray(prev)) prev.push(v);
|
|
671
|
+
else query[k] = [prev, v];
|
|
672
|
+
}
|
|
673
|
+
resolver.queryOverride = query;
|
|
674
|
+
}
|
|
675
|
+
if (token.isValid) {
|
|
676
|
+
let valid;
|
|
677
|
+
frame?.phaseStart("identity");
|
|
678
|
+
try {
|
|
679
|
+
valid = await token.isValid();
|
|
680
|
+
if (valid && token.confirmAccount) valid = await token.confirmAccount();
|
|
681
|
+
} catch {
|
|
682
|
+
valid = false;
|
|
683
|
+
} finally {
|
|
684
|
+
frame?.phaseEnd("identity");
|
|
685
|
+
}
|
|
686
|
+
if (!valid) throw new RpcError("api.request: identité de session expirée ou invalide", JsonRpcServerError.DEFAULT, { status: 401 });
|
|
687
|
+
}
|
|
554
688
|
frame?.phaseStart("action");
|
|
555
689
|
try {
|
|
556
690
|
const { result: actionResult } = await resolver.executeActionGuarded(void 0, true);
|
|
557
691
|
const raw = await Promise.resolve(actionResult);
|
|
558
692
|
if (renderSink.body !== void 0) {
|
|
559
693
|
const text = Buffer.isBuffer(renderSink.body) ? renderSink.body.toString("utf8") : renderSink.body;
|
|
694
|
+
let body;
|
|
560
695
|
try {
|
|
561
|
-
|
|
696
|
+
body = JSON.parse(text);
|
|
562
697
|
} catch {
|
|
563
|
-
|
|
698
|
+
body = text;
|
|
699
|
+
}
|
|
700
|
+
const status = renderSink.status;
|
|
701
|
+
if (status !== void 0 && status >= 400) {
|
|
702
|
+
const error = isRecord(body) ? body.error : void 0;
|
|
703
|
+
throw new RpcError(typeof error === "string" ? error : `api.request: HTTP ${status}`, JsonRpcServerError.DEFAULT, {
|
|
704
|
+
status,
|
|
705
|
+
body
|
|
706
|
+
});
|
|
564
707
|
}
|
|
708
|
+
return body;
|
|
565
709
|
}
|
|
566
710
|
return raw;
|
|
567
711
|
} finally {
|
|
@@ -575,6 +719,7 @@ var RealtimeController = class extends Controller {
|
|
|
575
719
|
frame?.finish(status ?? 500, e);
|
|
576
720
|
throw toFrameRpcError(e, status, frame?.requestId);
|
|
577
721
|
} finally {
|
|
722
|
+
if (store !== null) RequestContext.release(store);
|
|
578
723
|
ctx.collectFrame(frame);
|
|
579
724
|
}
|
|
580
725
|
}
|
|
@@ -639,5 +784,4 @@ function handshakePath(ctx) {
|
|
|
639
784
|
return raw.charCodeAt(0) === 47 ? raw : "/";
|
|
640
785
|
}
|
|
641
786
|
}
|
|
642
|
-
//#endregion
|
|
643
787
|
export { RealtimeController, RealtimeController as default };
|