@nodefony/realtime 10.0.0-beta.3 → 10.0.0-beta.5
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 +29 -10
- 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 +158 -84
- 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 +60 -2
- 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 +11 -4
- package/docs/actions.md +67 -44
- 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 +56 -34
- package/docs/index.md +35 -24
- package/docs/observabilite.md +49 -49
- package/docs/protocole.md +91 -68
- package/docs/securite.md +135 -115
- 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
|
@@ -5,11 +5,12 @@ import { DEFAULT_ACTION_POLICY, getRealtimeActions, getRealtimeChannelPolicies,
|
|
|
5
5
|
import { createSyslogUplinkHandler } from "./syslogUplink.js";
|
|
6
6
|
import { WsConnectionTransport } from "../transport/WsConnectionTransport.js";
|
|
7
7
|
import { welcomeEnv } from "./welcomeEnv.js";
|
|
8
|
+
import { subscribedAckFrame } from "./subscribedAck.js";
|
|
8
9
|
import { deniedDetail } from "./deniedDetail.js";
|
|
9
10
|
import { BootConfigurationError, JsonRpcError, JsonRpcPeer, JsonRpcServerError, PLATFORM_INBOUND, RequestContext, RpcEnvelope, RpcError, Scope, identityHint, isJsonRpcId, isPromise, jsonRpcFailure } from "nodefony";
|
|
10
|
-
import { readBackpressureOptions } from "@nodefony/http";
|
|
11
|
-
import { Controller, buildContextParamArgs, getParamArgsMeta } from "@nodefony/framework";
|
|
12
|
-
|
|
11
|
+
import { isWebsocketContext, readBackpressureOptions, toValidationFields } from "@nodefony/http";
|
|
12
|
+
import { Controller, buildContextParamArgs, getParamArgsMeta, isFrameworkRouter } from "@nodefony/framework";
|
|
13
|
+
// nodefony/src/server/RealtimeController.ts
|
|
13
14
|
/**
|
|
14
15
|
* Une action RPC n'a ni variables de route ni URL : `@Param` et `@Query` y
|
|
15
16
|
* lisent cet objet vide.
|
|
@@ -32,6 +33,7 @@ function httpStatusOfFrameError(e) {
|
|
|
32
33
|
if (typeof status === "number") return status;
|
|
33
34
|
return e.code === JsonRpcError.INVALID_PARAMS ? 400 : null;
|
|
34
35
|
}
|
|
36
|
+
if (e instanceof Error && toValidationFields(e) !== null) return 422;
|
|
35
37
|
const code = isRecord(e) ? e.code : void 0;
|
|
36
38
|
return typeof code === "number" && code >= 400 && code <= 599 ? code : null;
|
|
37
39
|
}
|
|
@@ -55,6 +57,10 @@ function toFrameRpcError(e, status, requestId) {
|
|
|
55
57
|
if (status !== null) {
|
|
56
58
|
const data = { status };
|
|
57
59
|
if (requestId) data.requestId = requestId;
|
|
60
|
+
if (status === 422 && e instanceof Error) {
|
|
61
|
+
const fields = toValidationFields(e);
|
|
62
|
+
if (fields !== null) data.fields = fields;
|
|
63
|
+
}
|
|
58
64
|
return new RpcError(e instanceof Error ? e.message : String(e), JsonRpcServerError.DEFAULT, data);
|
|
59
65
|
}
|
|
60
66
|
return e;
|
|
@@ -125,8 +131,20 @@ var RealtimeController = class extends Controller {
|
|
|
125
131
|
constructor(name, context) {
|
|
126
132
|
new.target.assertScope();
|
|
127
133
|
super(name, context);
|
|
134
|
+
this.#connection = isWebsocketContext(context) ? context : null;
|
|
128
135
|
}
|
|
129
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
|
+
/**
|
|
130
148
|
* Map des factories de canaux EXACT, lue depuis les décorateurs `@RealtimeChannel`
|
|
131
149
|
* au handshake (cold-path) et mémoïsée par instance. `null` = pas de décorateur
|
|
132
150
|
* sur cette classe → la base court-circuite la lecture metadata par frame.
|
|
@@ -175,7 +193,7 @@ var RealtimeController = class extends Controller {
|
|
|
175
193
|
return {};
|
|
176
194
|
}
|
|
177
195
|
/**
|
|
178
|
-
* Opt-in du **pont API** (
|
|
196
|
+
* Opt-in du **pont API** (une action, deux transports) : `true` → la
|
|
179
197
|
* connexion expose la méthode RPC `api.request {path}` qui re-route le path
|
|
180
198
|
* porté par le message vers l'action controller correspondante — la MÊME
|
|
181
199
|
* action que le GET REST (`socket.request("/nodefony/kernel/api/modules")`
|
|
@@ -207,8 +225,8 @@ var RealtimeController = class extends Controller {
|
|
|
207
225
|
* écoute (welcome) » de `NodefonySocketCoverage.test.ts`.
|
|
208
226
|
*/
|
|
209
227
|
handleRealtime(message) {
|
|
210
|
-
const ctx = this
|
|
211
|
-
if (
|
|
228
|
+
const ctx = this.#connection;
|
|
229
|
+
if (ctx === null) throw this.outsideWebsocketError();
|
|
212
230
|
if (message == null) {
|
|
213
231
|
this.onHandshake(ctx).catch((e) => {
|
|
214
232
|
this.log(`WS realtime handshake failed: ${e instanceof Error ? e.message : String(e)}`, "ERROR");
|
|
@@ -216,7 +234,28 @@ var RealtimeController = class extends Controller {
|
|
|
216
234
|
});
|
|
217
235
|
return;
|
|
218
236
|
}
|
|
219
|
-
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");
|
|
220
259
|
}
|
|
221
260
|
/**
|
|
222
261
|
* **L1 — duplex serveur→client.** Émet une REQUÊTE vers le client de CETTE
|
|
@@ -234,9 +273,9 @@ var RealtimeController = class extends Controller {
|
|
|
234
273
|
* @throws (rejet) si le handshake n'est pas terminé (aucun peer encore).
|
|
235
274
|
*/
|
|
236
275
|
requestClient(method, params, timeoutMs) {
|
|
237
|
-
const state = this.
|
|
276
|
+
const state = this.coldConnState();
|
|
238
277
|
if (!state) return Promise.reject(/* @__PURE__ */ new Error("requestClient: connexion realtime non établie (welcome non émis)"));
|
|
239
|
-
return state.peer.
|
|
278
|
+
return state.peer.requestAs(method, params, timeoutMs);
|
|
240
279
|
}
|
|
241
280
|
/**
|
|
242
281
|
* Notification CIBLÉE serveur→client (sans réponse) vers CETTE connexion, hors
|
|
@@ -245,7 +284,27 @@ var RealtimeController = class extends Controller {
|
|
|
245
284
|
* handshake n'est pas terminé.
|
|
246
285
|
*/
|
|
247
286
|
notifyClient(method, params) {
|
|
248
|
-
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;
|
|
249
308
|
}
|
|
250
309
|
/**
|
|
251
310
|
* Handshake : seams sécurité #4 (Origin RFC 6455 §10.2) + #2 (authenticator
|
|
@@ -263,8 +322,7 @@ var RealtimeController = class extends Controller {
|
|
|
263
322
|
* - `authenticate` throw → code 4001 (`unauthorized`).
|
|
264
323
|
*/
|
|
265
324
|
async onHandshake(ctx) {
|
|
266
|
-
|
|
267
|
-
if (holder.__nfRealtime?.welcomed) return;
|
|
325
|
+
if (ctx.__nfRealtime?.welcomed) return;
|
|
268
326
|
const conn = ctx.connection;
|
|
269
327
|
if (!conn) return;
|
|
270
328
|
const hub = getRealtimeHub();
|
|
@@ -331,6 +389,7 @@ var RealtimeController = class extends Controller {
|
|
|
331
389
|
try {
|
|
332
390
|
parsed = JSON.parse(raw);
|
|
333
391
|
} catch {
|
|
392
|
+
if (transport.discard("malformed") === 1) this.log("WS realtime : trame texte illisible (pas du JSON) reçue et écartée", "WARNING");
|
|
334
393
|
return;
|
|
335
394
|
}
|
|
336
395
|
peer.receive(parsed);
|
|
@@ -391,7 +450,7 @@ var RealtimeController = class extends Controller {
|
|
|
391
450
|
channels: /* @__PURE__ */ new Map(),
|
|
392
451
|
inbound
|
|
393
452
|
};
|
|
394
|
-
|
|
453
|
+
ctx.__nfRealtime = state;
|
|
395
454
|
ctx.once("onFinish", () => {
|
|
396
455
|
const liveHub = getRealtimeHub();
|
|
397
456
|
for (const [channel, sink] of state.channels) liveHub.unsubscribe(channel, sink);
|
|
@@ -409,6 +468,7 @@ var RealtimeController = class extends Controller {
|
|
|
409
468
|
protocol: "jsonrpc-2.0",
|
|
410
469
|
channels: announcedChannels,
|
|
411
470
|
methods: peer.methods,
|
|
471
|
+
subscriptionAcks: true,
|
|
412
472
|
...welcomeEnv(env),
|
|
413
473
|
identity: {
|
|
414
474
|
type: token.type,
|
|
@@ -441,11 +501,25 @@ var RealtimeController = class extends Controller {
|
|
|
441
501
|
handler(params, (payload) => state.peer.notify(method, payload));
|
|
442
502
|
}
|
|
443
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
|
+
}
|
|
444
514
|
/** Abonne la connexion à un canal via le hub partagé (idempotent par connexion). */
|
|
445
515
|
startChannel(ctx, channel) {
|
|
446
516
|
if (!channel) return;
|
|
447
517
|
const state = ctx.__nfRealtime;
|
|
448
|
-
if (!state
|
|
518
|
+
if (!state) return;
|
|
519
|
+
if (state.channels.has(channel)) {
|
|
520
|
+
this.acknowledgeSubscription(state, channel);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
449
523
|
const cap = getRealtimeHub().maxChannelsPerConnection;
|
|
450
524
|
if (cap !== null && state.channels.size >= cap) {
|
|
451
525
|
const denied = {
|
|
@@ -472,6 +546,7 @@ var RealtimeController = class extends Controller {
|
|
|
472
546
|
}, (payload) => JSON.stringify(JsonRpcPeer.buildNotification(channel, payload)))) {
|
|
473
547
|
state.channels.set(channel, sink);
|
|
474
548
|
if (isServedByPattern()) getRealtimeHub().noticeUnguardedDynamicChannel(channel);
|
|
549
|
+
this.acknowledgeSubscription(state, channel);
|
|
475
550
|
this.log(`WS subscribe → ${channel}`, "DEBUG");
|
|
476
551
|
return;
|
|
477
552
|
}
|
|
@@ -527,7 +602,7 @@ var RealtimeController = class extends Controller {
|
|
|
527
602
|
};
|
|
528
603
|
}
|
|
529
604
|
/**
|
|
530
|
-
* Handler de la méthode RPC `api.request {path}` — le **pont
|
|
605
|
+
* Handler de la méthode RPC `api.request {path}` — le **pont socket**
|
|
531
606
|
* (POC Ph.3) : résout le path porté par le message via le Router
|
|
532
607
|
* (`cleanPathOverride` — l'URL de la connexion n'est jamais mutée) puis
|
|
533
608
|
* exécute l'action SANS la rendre (`executeAction`) : la valeur nue est
|
|
@@ -552,52 +627,21 @@ var RealtimeController = class extends Controller {
|
|
|
552
627
|
const method = typeof p?.method === "string" ? p.method.toUpperCase() : "GET";
|
|
553
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);
|
|
554
629
|
const router = ctx.router;
|
|
555
|
-
if (!router) throw new RpcError("api.request: router indisponible", JsonRpcServerError.DEFAULT, { status: 500 });
|
|
630
|
+
if (!isFrameworkRouter(router)) throw new RpcError("api.request: router indisponible", JsonRpcServerError.DEFAULT, { status: 500 });
|
|
556
631
|
const qIdx = path.indexOf("?");
|
|
557
632
|
const pathname = qIdx === -1 ? path : path.slice(0, qIdx);
|
|
558
633
|
const frame = ctx.beginFrame(method, path);
|
|
634
|
+
let store = null;
|
|
559
635
|
try {
|
|
560
|
-
frame?.phaseStart("resolve");
|
|
561
|
-
let resolver;
|
|
562
|
-
try {
|
|
563
|
-
resolver = router.resolve(ctx, pathname, method === "GET" ? void 0 : method);
|
|
564
|
-
} finally {
|
|
565
|
-
frame?.phaseEnd("resolve");
|
|
566
|
-
}
|
|
567
|
-
if (!resolver.resolve) throw new RpcError(`api.request: not found ${pathname}`, JsonRpcServerError.DEFAULT, { status: 404 });
|
|
568
|
-
if (frame) frame.resolver = resolver;
|
|
569
|
-
if (qIdx !== -1 && qIdx < path.length - 1) {
|
|
570
|
-
const sp = new URLSearchParams(path.slice(qIdx + 1));
|
|
571
|
-
const query = {};
|
|
572
|
-
for (const [k, v] of sp) {
|
|
573
|
-
const prev = query[k];
|
|
574
|
-
if (prev === void 0) query[k] = v;
|
|
575
|
-
else if (Array.isArray(prev)) prev.push(v);
|
|
576
|
-
else query[k] = [prev, v];
|
|
577
|
-
}
|
|
578
|
-
resolver.queryOverride = query;
|
|
579
|
-
}
|
|
580
636
|
const token = getRealtimeHub().getTokenForPeer(peer);
|
|
581
|
-
if (token.isValid) {
|
|
582
|
-
let valid;
|
|
583
|
-
frame?.phaseStart("identity");
|
|
584
|
-
try {
|
|
585
|
-
valid = await token.isValid();
|
|
586
|
-
} catch {
|
|
587
|
-
valid = false;
|
|
588
|
-
} finally {
|
|
589
|
-
frame?.phaseEnd("identity");
|
|
590
|
-
}
|
|
591
|
-
if (!valid) throw new RpcError("api.request: identité de session expirée ou invalide", JsonRpcServerError.DEFAULT, { status: 401 });
|
|
592
|
-
}
|
|
593
637
|
const renderSink = {};
|
|
594
|
-
const
|
|
595
|
-
requestId: ctx.requestId,
|
|
638
|
+
const frameStore = {
|
|
639
|
+
requestId: frame?.requestId ?? ctx.requestId,
|
|
596
640
|
scheme: ctx.scheme,
|
|
597
641
|
token,
|
|
598
642
|
user: token.getAttribute("user"),
|
|
599
643
|
context: ctx,
|
|
600
|
-
resolver,
|
|
644
|
+
resolver: void 0,
|
|
601
645
|
scope: ctx.container instanceof Scope ? ctx.container : void 0,
|
|
602
646
|
body: p?.body,
|
|
603
647
|
idempotencyKey: typeof p?.idempotencyKey === "string" ? p.idempotencyKey : void 0,
|
|
@@ -605,46 +649,77 @@ var RealtimeController = class extends Controller {
|
|
|
605
649
|
invocation: frame ?? void 0,
|
|
606
650
|
queries: frame?.profilerQueries ?? void 0
|
|
607
651
|
};
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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");
|
|
612
678
|
try {
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
let body;
|
|
618
|
-
try {
|
|
619
|
-
body = JSON.parse(text);
|
|
620
|
-
} catch {
|
|
621
|
-
body = text;
|
|
622
|
-
}
|
|
623
|
-
const status = renderSink.status;
|
|
624
|
-
if (status !== void 0 && status >= 400) {
|
|
625
|
-
const error = isRecord(body) ? body.error : void 0;
|
|
626
|
-
throw new RpcError(typeof error === "string" ? error : `api.request: HTTP ${status}`, JsonRpcServerError.DEFAULT, {
|
|
627
|
-
status,
|
|
628
|
-
body
|
|
629
|
-
});
|
|
630
|
-
}
|
|
631
|
-
return body;
|
|
632
|
-
}
|
|
633
|
-
return raw;
|
|
679
|
+
valid = await token.isValid();
|
|
680
|
+
if (valid && token.confirmAccount) valid = await token.confirmAccount();
|
|
681
|
+
} catch {
|
|
682
|
+
valid = false;
|
|
634
683
|
} finally {
|
|
635
|
-
frame?.phaseEnd("
|
|
684
|
+
frame?.phaseEnd("identity");
|
|
636
685
|
}
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
686
|
+
if (!valid) throw new RpcError("api.request: identité de session expirée ou invalide", JsonRpcServerError.DEFAULT, { status: 401 });
|
|
687
|
+
}
|
|
688
|
+
frame?.phaseStart("action");
|
|
689
|
+
try {
|
|
690
|
+
const { result: actionResult } = await resolver.executeActionGuarded(void 0, true);
|
|
691
|
+
const raw = await Promise.resolve(actionResult);
|
|
692
|
+
if (renderSink.body !== void 0) {
|
|
693
|
+
const text = Buffer.isBuffer(renderSink.body) ? renderSink.body.toString("utf8") : renderSink.body;
|
|
694
|
+
let body;
|
|
695
|
+
try {
|
|
696
|
+
body = JSON.parse(text);
|
|
697
|
+
} catch {
|
|
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
|
+
});
|
|
707
|
+
}
|
|
708
|
+
return body;
|
|
709
|
+
}
|
|
710
|
+
return raw;
|
|
711
|
+
} finally {
|
|
712
|
+
frame?.phaseEnd("action");
|
|
713
|
+
}
|
|
714
|
+
});
|
|
641
715
|
frame?.finish(200);
|
|
642
|
-
return frame ? new RpcEnvelope(result, { requestId: frame.requestId }) : result;
|
|
716
|
+
return frame?.profiled ? new RpcEnvelope(result, { requestId: frame.requestId }) : result;
|
|
643
717
|
} catch (e) {
|
|
644
718
|
const status = httpStatusOfFrameError(e);
|
|
645
719
|
frame?.finish(status ?? 500, e);
|
|
646
|
-
throw toFrameRpcError(e, status, frame?.requestId);
|
|
720
|
+
throw toFrameRpcError(e, status, frame?.profiled ? frame.requestId : void 0);
|
|
647
721
|
} finally {
|
|
722
|
+
if (store !== null) RequestContext.release(store);
|
|
648
723
|
ctx.collectFrame(frame);
|
|
649
724
|
}
|
|
650
725
|
}
|
|
@@ -709,5 +784,4 @@ function handshakePath(ctx) {
|
|
|
709
784
|
return raw.charCodeAt(0) === 47 ? raw : "/";
|
|
710
785
|
}
|
|
711
786
|
}
|
|
712
|
-
//#endregion
|
|
713
787
|
export { RealtimeController, RealtimeController as default };
|
|
@@ -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 };
|