@nodefony/realtime 10.0.0-beta.2 → 10.0.0-beta.3
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.153.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +17 -10
- package/dist/nodefony/config/config.js +6 -3
- package/dist/nodefony/config/configSchema.js +19 -0
- package/dist/nodefony/config/defineModuleConfig.js +42 -15
- package/dist/nodefony/decorators/realtimeDecorators.js +68 -17
- package/dist/nodefony/src/backplane/RedisBackplane.js +21 -4
- package/dist/nodefony/src/backplane/bootNotices.js +43 -0
- package/dist/nodefony/src/backplane/publishQueue.js +1 -4
- package/dist/nodefony/src/cluster/ClusterProbeClient.js +15 -4
- package/dist/nodefony/src/guards.js +41 -0
- package/dist/nodefony/src/server/AnonymousRealtimeToken.js +1 -4
- package/dist/nodefony/src/server/RealtimeController.js +120 -50
- package/dist/nodefony/src/server/RealtimeHub.js +29 -51
- package/dist/nodefony/src/server/ServerRealtimeSocket.js +13 -5
- package/dist/nodefony/src/server/syslogUplink.js +4 -2
- package/dist/nodefony/src/service/RealtimeService.js +3 -2
- package/dist/nodefony/testing/index.js +41 -1
- 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/IRealtimeController.d.ts +1 -1
- package/dist/types/nodefony/interfaces/IRealtimeHandshake.d.ts +1 -1
- 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 +24 -4
- package/dist/types/nodefony/src/server/RealtimeHub.d.ts +22 -16
- package/dist/types/nodefony/src/service/RealtimeService.d.ts +1 -1
- package/dist/types/nodefony/testing/index.d.ts +2 -2
- package/docs/actions.md +111 -28
- package/docs/architecture.md +77 -75
- package/docs/cadence-et-contre-pression.md +634 -0
- package/docs/configuration.md +39 -34
- package/docs/cookbook-chat.md +31 -27
- package/docs/index.md +35 -21
- package/docs/observabilite.md +17 -15
- package/docs/protocole.md +74 -63
- package/docs/securite.md +49 -49
- package/docs/vocabulaire.md +76 -75
- package/package.json +16 -12
- package/docs/couverture-bancs.md +0 -139
|
@@ -1,3 +1,4 @@
|
|
|
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";
|
|
@@ -5,11 +6,16 @@ import { createSyslogUplinkHandler } from "./syslogUplink.js";
|
|
|
5
6
|
import { WsConnectionTransport } from "../transport/WsConnectionTransport.js";
|
|
6
7
|
import { welcomeEnv } from "./welcomeEnv.js";
|
|
7
8
|
import { deniedDetail } from "./deniedDetail.js";
|
|
8
|
-
import { BootConfigurationError, JsonRpcPeer, PLATFORM_INBOUND, RequestContext, RpcEnvelope, RpcError, Scope, identityHint } from "nodefony";
|
|
9
|
+
import { BootConfigurationError, JsonRpcError, JsonRpcPeer, JsonRpcServerError, PLATFORM_INBOUND, RequestContext, RpcEnvelope, RpcError, Scope, identityHint, isJsonRpcId, isPromise, jsonRpcFailure } from "nodefony";
|
|
9
10
|
import { readBackpressureOptions } from "@nodefony/http";
|
|
10
|
-
import { Controller } from "@nodefony/framework";
|
|
11
|
+
import { Controller, buildContextParamArgs, getParamArgsMeta } from "@nodefony/framework";
|
|
11
12
|
//#region nodefony/src/server/RealtimeController.ts
|
|
12
13
|
/**
|
|
14
|
+
* Une action RPC n'a ni variables de route ni URL : `@Param` et `@Query` y
|
|
15
|
+
* lisent cet objet vide.
|
|
16
|
+
*/
|
|
17
|
+
const NO_ROUTE_PARAMS = Object.freeze({});
|
|
18
|
+
/**
|
|
13
19
|
* Statut HTTP-équivalent d'une erreur survenue pendant une invocation du pont.
|
|
14
20
|
*
|
|
15
21
|
* UNE seule lecture pour les trois sources possibles — la `RpcError` déjà mappée
|
|
@@ -22,11 +28,11 @@ import { Controller } from "@nodefony/framework";
|
|
|
22
28
|
*/
|
|
23
29
|
function httpStatusOfFrameError(e) {
|
|
24
30
|
if (e instanceof RpcError) {
|
|
25
|
-
const status = e.data
|
|
31
|
+
const status = isRecord(e.data) ? e.data.status : void 0;
|
|
26
32
|
if (typeof status === "number") return status;
|
|
27
|
-
return e.code ===
|
|
33
|
+
return e.code === JsonRpcError.INVALID_PARAMS ? 400 : null;
|
|
28
34
|
}
|
|
29
|
-
const code = e.code;
|
|
35
|
+
const code = isRecord(e) ? e.code : void 0;
|
|
30
36
|
return typeof code === "number" && code >= 400 && code <= 599 ? code : null;
|
|
31
37
|
}
|
|
32
38
|
/**
|
|
@@ -40,7 +46,7 @@ function httpStatusOfFrameError(e) {
|
|
|
40
46
|
function toFrameRpcError(e, status, requestId) {
|
|
41
47
|
if (e instanceof RpcError) {
|
|
42
48
|
if (!requestId) return e;
|
|
43
|
-
const data =
|
|
49
|
+
const data = isRecord(e.data) ? {
|
|
44
50
|
...e.data,
|
|
45
51
|
requestId
|
|
46
52
|
} : { requestId };
|
|
@@ -49,10 +55,23 @@ function toFrameRpcError(e, status, requestId) {
|
|
|
49
55
|
if (status !== null) {
|
|
50
56
|
const data = { status };
|
|
51
57
|
if (requestId) data.requestId = requestId;
|
|
52
|
-
return new RpcError(e instanceof Error ? e.message : String(e),
|
|
58
|
+
return new RpcError(e instanceof Error ? e.message : String(e), JsonRpcServerError.DEFAULT, data);
|
|
53
59
|
}
|
|
54
60
|
return e;
|
|
55
61
|
}
|
|
62
|
+
/** Le canal nommé par les params d'un `subscribe`/`unsubscribe` — entrée réseau. */
|
|
63
|
+
function channelParam(params) {
|
|
64
|
+
return isRecord(params) && typeof params.channel === "string" ? params.channel : void 0;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Le serveur WebSocket qui sert la connexion, ou `null` — lu une fois par
|
|
68
|
+
* poignée de main pour ses réglages de contre-pression. Un `WebSocketServer`
|
|
69
|
+
* porte toujours ses `options` ; aucun serveur HTTP n'en déclare.
|
|
70
|
+
*/
|
|
71
|
+
function webSocketServerOf(ctx) {
|
|
72
|
+
const server = ctx.server;
|
|
73
|
+
return server && "options" in server ? server : null;
|
|
74
|
+
}
|
|
56
75
|
let warnedUnenforcedPolicies = false;
|
|
57
76
|
/**
|
|
58
77
|
* RealtimeController — base d'un endpoint WebSocket temps réel SERVEUR (JSON-RPC 2.0).
|
|
@@ -62,7 +81,7 @@ let warnedUnenforcedPolicies = false;
|
|
|
62
81
|
* — écrit UNE fois, partagé par tous les modules. Un contrôleur concret ne déclare que
|
|
63
82
|
* son métier : {@link createRealtimeChannel} (providers de canaux) + {@link realtimeActions}.
|
|
64
83
|
*
|
|
65
|
-
* Chaque connexion compose un `JsonRpcPeer` (le MÊME que `
|
|
84
|
+
* Chaque connexion compose un `JsonRpcPeer` (le MÊME que `NodefonySocket` côté navigateur
|
|
66
85
|
* — symétrie isomorphe) branché sur un {@link WsConnectionTransport}.
|
|
67
86
|
*
|
|
68
87
|
* Usage : le sous-classe garde sa route WS et délègue —
|
|
@@ -179,13 +198,13 @@ var RealtimeController = class extends Controller {
|
|
|
179
198
|
* est structurel, pas un choix : c'est au client d'attendre `realtime:welcome`
|
|
180
199
|
* avant de pousser.
|
|
181
200
|
*
|
|
182
|
-
* ⚠️ Cette phrase a longtemps ajouté « ce que
|
|
201
|
+
* ⚠️ Cette phrase a longtemps ajouté « ce que le client fait nativement ».
|
|
183
202
|
* C'était FAUX : le client rejouait ses abonnements dès l'ouverture de la socket,
|
|
184
203
|
* si bien qu'un `subscribe` posé avant le démarrage et TOUS ceux d'après une
|
|
185
204
|
* reconnexion étaient perdus ici même, sans un mot des deux côtés. Un contrat écrit
|
|
186
205
|
* d'un seul côté du fil n'est pas tenu ; il l'est désormais par
|
|
187
|
-
* `
|
|
188
|
-
* écoute (welcome) » de `
|
|
206
|
+
* `NodefonySocket.replaySubscriptions` et les deux cas « la fenêtre où le serveur
|
|
207
|
+
* écoute (welcome) » de `NodefonySocketCoverage.test.ts`.
|
|
189
208
|
*/
|
|
190
209
|
handleRealtime(message) {
|
|
191
210
|
const ctx = this.context;
|
|
@@ -270,7 +289,7 @@ var RealtimeController = class extends Controller {
|
|
|
270
289
|
return;
|
|
271
290
|
}
|
|
272
291
|
else token = ANONYMOUS_REALTIME_TOKEN;
|
|
273
|
-
const transport = new WsConnectionTransport(conn, readBackpressureOptions(ctx
|
|
292
|
+
const transport = new WsConnectionTransport(conn, readBackpressureOptions(webSocketServerOf(ctx)));
|
|
274
293
|
const peerOptions = {
|
|
275
294
|
send: (frame) => {
|
|
276
295
|
let json;
|
|
@@ -278,16 +297,9 @@ var RealtimeController = class extends Controller {
|
|
|
278
297
|
json = JSON.stringify(frame);
|
|
279
298
|
} catch (e) {
|
|
280
299
|
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
|
-
});
|
|
300
|
+
const id = isRecord(frame) ? frame.id : void 0;
|
|
301
|
+
if (!isJsonRpcId(id)) return;
|
|
302
|
+
json = JSON.stringify(jsonRpcFailure(id, JsonRpcError.INTERNAL_ERROR, "internal error: non-serializable payload"));
|
|
291
303
|
}
|
|
292
304
|
transport.send(json);
|
|
293
305
|
},
|
|
@@ -298,9 +310,10 @@ var RealtimeController = class extends Controller {
|
|
|
298
310
|
peerOptions.onFrameAudit = (reason, frame, auditedPeer) => {
|
|
299
311
|
if (reason !== "denied") return;
|
|
300
312
|
this.log("WS realtime frame refused by authorizer", "WARNING");
|
|
313
|
+
if (!isRecord(frame)) return;
|
|
301
314
|
const f = frame;
|
|
302
315
|
if (f.id !== void 0) return;
|
|
303
|
-
const channel = f.method === "subscribe" ?
|
|
316
|
+
const channel = f.method === "subscribe" ? channelParam(f.params) : typeof f.method === "string" ? f.method : void 0;
|
|
304
317
|
if (channel !== void 0) {
|
|
305
318
|
const hint = identityHint(this.kernel);
|
|
306
319
|
const denied = {
|
|
@@ -323,7 +336,7 @@ var RealtimeController = class extends Controller {
|
|
|
323
336
|
peer.receive(parsed);
|
|
324
337
|
});
|
|
325
338
|
const allActions = {
|
|
326
|
-
...getRealtimeActions(this),
|
|
339
|
+
...getRealtimeActions(this, this.actionParamsWrapper(ctx)),
|
|
327
340
|
...this.realtimeActions()
|
|
328
341
|
};
|
|
329
342
|
for (const [name, handler] of Object.entries(allActions)) {
|
|
@@ -414,11 +427,11 @@ var RealtimeController = class extends Controller {
|
|
|
414
427
|
*/
|
|
415
428
|
onRealtimeNotification(ctx, method, params) {
|
|
416
429
|
if (method === "subscribe") {
|
|
417
|
-
this.startChannel(ctx, params
|
|
430
|
+
this.startChannel(ctx, channelParam(params));
|
|
418
431
|
return;
|
|
419
432
|
}
|
|
420
433
|
if (method === "unsubscribe") {
|
|
421
|
-
this.stopChannel(ctx, params
|
|
434
|
+
this.stopChannel(ctx, channelParam(params));
|
|
422
435
|
return;
|
|
423
436
|
}
|
|
424
437
|
const state = ctx.__nfRealtime;
|
|
@@ -472,6 +485,48 @@ var RealtimeController = class extends Controller {
|
|
|
472
485
|
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
486
|
}
|
|
474
487
|
/**
|
|
488
|
+
* Enveloppe des actions `@RealtimeAction` dont un paramètre est décoré
|
|
489
|
+
* (`@Body`, `@CurrentUser`…) : l'action reçoit ses arguments comme une route,
|
|
490
|
+
* résolus par la fonction du framework et non par une copie.
|
|
491
|
+
*
|
|
492
|
+
* L'identité ne change PAS : un message est déjà traité dans la bulle
|
|
493
|
+
* `RequestContext` du handshake (`WebsocketContext` lie son écouteur par
|
|
494
|
+
* `AsyncResource.bind`), où le pare-feu a posé `user` et `token`. La bulle
|
|
495
|
+
* ouverte ici HÉRITE de ce store et n'y ajoute que la charge (`body`, que
|
|
496
|
+
* `@Body` lit dans l'ALS comme pour `api.request`) — une écriture
|
|
497
|
+
* `RequestContext.set()` dans l'action reste ainsi propre à l'appel.
|
|
498
|
+
*
|
|
499
|
+
* Une action sans paramètre décoré est rendue telle quelle : rien ne change
|
|
500
|
+
* pour elle, ni comportement ni coût. Les métadonnées se lisent ICI, une fois
|
|
501
|
+
* par connexion.
|
|
502
|
+
*
|
|
503
|
+
* @param ctx - le contexte de la connexion
|
|
504
|
+
* @returns l'enveloppe passée à {@link getRealtimeActions}
|
|
505
|
+
*/
|
|
506
|
+
actionParamsWrapper(ctx) {
|
|
507
|
+
const proto = Reflect.getPrototypeOf(this);
|
|
508
|
+
return (method, propertyKey) => {
|
|
509
|
+
if (proto === null) return method;
|
|
510
|
+
const metas = getParamArgsMeta(proto, propertyKey);
|
|
511
|
+
if (metas === null) return method;
|
|
512
|
+
return (params) => {
|
|
513
|
+
const store = {
|
|
514
|
+
requestId: ctx.requestId,
|
|
515
|
+
...RequestContext.get(),
|
|
516
|
+
body: params
|
|
517
|
+
};
|
|
518
|
+
const result = RequestContext.run(store, () => method(...buildContextParamArgs(metas, ctx, NO_ROUTE_PARAMS, NO_ROUTE_PARAMS)));
|
|
519
|
+
if (isPromise(result)) {
|
|
520
|
+
const release = () => RequestContext.release(store);
|
|
521
|
+
result.then(release, release);
|
|
522
|
+
return result;
|
|
523
|
+
}
|
|
524
|
+
RequestContext.release(store);
|
|
525
|
+
return result;
|
|
526
|
+
};
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
475
530
|
* Handler de la méthode RPC `api.request {path}` — le **pont API souverain**
|
|
476
531
|
* (POC Ph.3) : résout le path porté par le message via le Router
|
|
477
532
|
* (`cleanPathOverride` — l'URL de la connexion n'est jamais mutée) puis
|
|
@@ -491,13 +546,13 @@ var RealtimeController = class extends Controller {
|
|
|
491
546
|
* Tout autre throw de l'action reste opaque (`-32603`, Zero Trust).
|
|
492
547
|
*/
|
|
493
548
|
async invokeApiRequest(ctx, params, peer) {
|
|
494
|
-
const p = params;
|
|
549
|
+
const p = isRecord(params) ? params : void 0;
|
|
495
550
|
const path = p?.path;
|
|
496
|
-
if (typeof path !== "string" || path.charCodeAt(0) !== 47) throw new RpcError("api.request: params.path invalide",
|
|
551
|
+
if (typeof path !== "string" || path.charCodeAt(0) !== 47) throw new RpcError("api.request: params.path invalide", JsonRpcError.INVALID_PARAMS);
|
|
497
552
|
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`,
|
|
553
|
+
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
554
|
const router = ctx.router;
|
|
500
|
-
if (!router) throw new RpcError("api.request: router indisponible",
|
|
555
|
+
if (!router) throw new RpcError("api.request: router indisponible", JsonRpcServerError.DEFAULT, { status: 500 });
|
|
501
556
|
const qIdx = path.indexOf("?");
|
|
502
557
|
const pathname = qIdx === -1 ? path : path.slice(0, qIdx);
|
|
503
558
|
const frame = ctx.beginFrame(method, path);
|
|
@@ -509,7 +564,7 @@ var RealtimeController = class extends Controller {
|
|
|
509
564
|
} finally {
|
|
510
565
|
frame?.phaseEnd("resolve");
|
|
511
566
|
}
|
|
512
|
-
if (!resolver.resolve) throw new RpcError(`api.request: not found ${pathname}`,
|
|
567
|
+
if (!resolver.resolve) throw new RpcError(`api.request: not found ${pathname}`, JsonRpcServerError.DEFAULT, { status: 404 });
|
|
513
568
|
if (frame) frame.resolver = resolver;
|
|
514
569
|
if (qIdx !== -1 && qIdx < path.length - 1) {
|
|
515
570
|
const sp = new URLSearchParams(path.slice(qIdx + 1));
|
|
@@ -533,15 +588,14 @@ var RealtimeController = class extends Controller {
|
|
|
533
588
|
} finally {
|
|
534
589
|
frame?.phaseEnd("identity");
|
|
535
590
|
}
|
|
536
|
-
if (!valid) throw new RpcError("api.request: identité de session expirée ou invalide",
|
|
591
|
+
if (!valid) throw new RpcError("api.request: identité de session expirée ou invalide", JsonRpcServerError.DEFAULT, { status: 401 });
|
|
537
592
|
}
|
|
538
593
|
const renderSink = {};
|
|
539
|
-
const
|
|
594
|
+
const store = {
|
|
540
595
|
requestId: ctx.requestId,
|
|
541
596
|
scheme: ctx.scheme,
|
|
542
597
|
token,
|
|
543
598
|
user: token.getAttribute("user"),
|
|
544
|
-
userId: token.getUserIdentifier(),
|
|
545
599
|
context: ctx,
|
|
546
600
|
resolver,
|
|
547
601
|
scope: ctx.container instanceof Scope ? ctx.container : void 0,
|
|
@@ -550,24 +604,40 @@ var RealtimeController = class extends Controller {
|
|
|
550
604
|
renderSink,
|
|
551
605
|
invocation: frame ?? void 0,
|
|
552
606
|
queries: frame?.profilerQueries ?? void 0
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
const
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
607
|
+
};
|
|
608
|
+
let result;
|
|
609
|
+
try {
|
|
610
|
+
result = await RequestContext.run(store, async () => {
|
|
611
|
+
frame?.phaseStart("action");
|
|
612
|
+
try {
|
|
613
|
+
const { result: actionResult } = await resolver.executeActionGuarded(void 0, true);
|
|
614
|
+
const raw = await Promise.resolve(actionResult);
|
|
615
|
+
if (renderSink.body !== void 0) {
|
|
616
|
+
const text = Buffer.isBuffer(renderSink.body) ? renderSink.body.toString("utf8") : renderSink.body;
|
|
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;
|
|
564
632
|
}
|
|
633
|
+
return raw;
|
|
634
|
+
} finally {
|
|
635
|
+
frame?.phaseEnd("action");
|
|
565
636
|
}
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
});
|
|
637
|
+
});
|
|
638
|
+
} finally {
|
|
639
|
+
RequestContext.release(store);
|
|
640
|
+
}
|
|
571
641
|
frame?.finish(200);
|
|
572
642
|
return frame ? new RpcEnvelope(result, { requestId: frame.requestId }) : result;
|
|
573
643
|
} catch (e) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { LoopbackBackplane } from "../backplane/LoopbackBackplane.js";
|
|
2
2
|
import { ANONYMOUS_REALTIME_TOKEN } from "./AnonymousRealtimeToken.js";
|
|
3
|
-
import { NODEFONY_CHANNEL_NAMESPACE, isPlatformChannel } from "nodefony";
|
|
3
|
+
import { IDENTITY_REFRESH_CLOSE_CODE, NODEFONY_CHANNEL_NAMESPACE, REVOCATION_REVALIDATE_MS, RevocationWatch, isPlatformChannel } from "nodefony";
|
|
4
4
|
//#region nodefony/src/server/RealtimeHub.ts
|
|
5
5
|
/**
|
|
6
6
|
* Seuil d'alerte slow-consumer (octets de `bufferedAmount`). Au-delà, la connexion
|
|
@@ -45,17 +45,6 @@ const RESERVED_SYSTEM_PREFIXES = [NODEFONY_CHANNEL_NAMESPACE];
|
|
|
45
45
|
*/
|
|
46
46
|
const isReservedSystemChannel = isPlatformChannel;
|
|
47
47
|
/**
|
|
48
|
-
* Période de re-validation des identités RÉVOCABLES — F4 (revue 0.6).
|
|
49
|
-
* Le verrou de frame est SYNC par doctrine (identité figée au handshake, cf
|
|
50
|
-
* {@link FrameAuthorizer}) → il ne peut pas re-lire la session par frame. Un socket
|
|
51
|
-
* survivrait donc à sa session (`subscribe` garderait ses flux après un logout HTTP),
|
|
52
|
-
* là où `api.request` re-valide déjà par requête (`isValid()`). Ce tick ferme
|
|
53
|
-
* l'écart : toutes les `REVOCATION_REVALIDATE_MS`, les tokens révocables sont
|
|
54
|
-
* re-validés et la connexion fermée si l'identité n'est plus valide (≤ 1 fenêtre de
|
|
55
|
-
* délai). Ordre de grandeur aligné sur le heartbeat WS (`keepaliveInterval` 20 s).
|
|
56
|
-
*/
|
|
57
|
-
const REVOCATION_REVALIDATE_MS = 3e4;
|
|
58
|
-
/**
|
|
59
48
|
* Le MOTIF de famille d'un canal — son dernier segment remplacé par `*`.
|
|
60
49
|
*
|
|
61
50
|
* `"studio:metrics:1s"` → `"studio:metrics:*"`, `"alerts"` → `"*"`. Sert à
|
|
@@ -99,7 +88,7 @@ function channelFamily(channel) {
|
|
|
99
88
|
* multiplexe des canaux ; le hub aiguille entre les sockets. La façade *consommateur*
|
|
100
89
|
* côté serveur EXISTE — {@link ServerRealtimeSocket} (`subscribe/on/publish`), exportée
|
|
101
90
|
* par le barrel du module : c'est elle qu'un service back tient, jamais ce hub
|
|
102
|
-
* directement. Même rôle que `
|
|
91
|
+
* directement. Même rôle que `NodefonySocket` côté navigateur.
|
|
103
92
|
*/
|
|
104
93
|
var RealtimeHub = class RealtimeHub {
|
|
105
94
|
#channels = null;
|
|
@@ -111,8 +100,12 @@ var RealtimeHub = class RealtimeHub {
|
|
|
111
100
|
#notice = null;
|
|
112
101
|
#noticed = null;
|
|
113
102
|
#connections = null;
|
|
114
|
-
#
|
|
115
|
-
|
|
103
|
+
#revocation = new RevocationWatch({
|
|
104
|
+
isValid: (entry) => entry.token.isValid?.(),
|
|
105
|
+
revoke: (entry) => entry.close(4001, "session revoked"),
|
|
106
|
+
identityOf: (entry) => entry.token.getUserIdentifier(),
|
|
107
|
+
refresh: (entry) => entry.close(IDENTITY_REFRESH_CLOSE_CODE, "identity changed")
|
|
108
|
+
});
|
|
116
109
|
#backplane = null;
|
|
117
110
|
#authenticators = null;
|
|
118
111
|
#peerTokens = null;
|
|
@@ -433,51 +426,36 @@ var RealtimeHub = class RealtimeHub {
|
|
|
433
426
|
* non révocable (anonyme/JWT sans revalidation) ne doit pas y entrer.
|
|
434
427
|
*/
|
|
435
428
|
registerRevocable(entry) {
|
|
436
|
-
|
|
437
|
-
if (this.#revalidateTimer === null) {
|
|
438
|
-
this.#revalidateTimer = setInterval(() => {
|
|
439
|
-
this.revalidateRevocable();
|
|
440
|
-
}, REVOCATION_REVALIDATE_MS);
|
|
441
|
-
this.#revalidateTimer.unref();
|
|
442
|
-
}
|
|
429
|
+
this.#revocation.register(entry);
|
|
443
430
|
}
|
|
444
431
|
/**
|
|
445
432
|
* Retire une connexion du registre de révocation (au close). Arrête le tick dès que
|
|
446
433
|
* le registre est vide (0 timer au repos). No-op si absente.
|
|
447
434
|
*/
|
|
448
435
|
unregisterRevocable(entry) {
|
|
449
|
-
|
|
450
|
-
this.#revocable.delete(entry);
|
|
451
|
-
if (this.#revocable.size === 0 && this.#revalidateTimer !== null) {
|
|
452
|
-
clearInterval(this.#revalidateTimer);
|
|
453
|
-
this.#revalidateTimer = null;
|
|
454
|
-
}
|
|
436
|
+
this.#revocation.unregister(entry);
|
|
455
437
|
}
|
|
456
438
|
/**
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
* `onFinish`). Exposé (public) pour un test déterministe sans fake timers.
|
|
439
|
+
* Re-valide toutes les connexions révocables et ferme (`4001`) celles dont
|
|
440
|
+
* l'identité est morte : session détruite ou passée à un autre compte, jeton
|
|
441
|
+
* expiré ou révoqué. Fail-closed : une re-validation qui lève ferme aussi. Le
|
|
442
|
+
* registre est celui des flux SSE ({@link RevocationWatch}) — une garde, deux
|
|
443
|
+
* transports. Exposé (public) pour un test déterministe sans faux minuteur.
|
|
463
444
|
*/
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
} catch {}
|
|
479
|
-
}
|
|
480
|
-
}
|
|
445
|
+
revalidateRevocable() {
|
|
446
|
+
return this.#revocation.revalidate();
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Ferme tout de suite les connexions qui ont figé cette identité — ses droits
|
|
450
|
+
* ont changé (`IDENTITY_CHANGED_EVENT`). Code {@link IDENTITY_REFRESH_CLOSE_CODE},
|
|
451
|
+
* reconnectable : le client revient avec ses rôles frais. Le registre est
|
|
452
|
+
* celui des flux SSE — une règle, deux transports.
|
|
453
|
+
*
|
|
454
|
+
* @param identifier - identifiant de connexion de l'identité changée.
|
|
455
|
+
* @returns le nombre de connexions fermées.
|
|
456
|
+
*/
|
|
457
|
+
refreshIdentity(identifier) {
|
|
458
|
+
return this.#revocation.refreshIdentity(identifier);
|
|
481
459
|
}
|
|
482
460
|
/**
|
|
483
461
|
* Compte une frame entrante full-duplex (canaux gated SIP/bridge). Appelé par le
|
|
@@ -74,6 +74,14 @@ var ServerRealtimeSocket = class {
|
|
|
74
74
|
}
|
|
75
75
|
/** Branche un handler de réception sur un canal. Renvoie un `dispose`. */
|
|
76
76
|
on(channel, handler) {
|
|
77
|
+
return this.#addHandler(channel, handler);
|
|
78
|
+
}
|
|
79
|
+
/** Retire un handler d'un canal. */
|
|
80
|
+
off(channel, handler) {
|
|
81
|
+
this.#removeHandler(channel, handler);
|
|
82
|
+
}
|
|
83
|
+
/** Porte non typée de {@link on} : un nom de canal quelconque, un handler `unknown`. */
|
|
84
|
+
#addHandler(channel, handler) {
|
|
77
85
|
const handlers = this.#handlers ??= /* @__PURE__ */ new Map();
|
|
78
86
|
let set = handlers.get(channel);
|
|
79
87
|
if (!set) {
|
|
@@ -81,10 +89,10 @@ var ServerRealtimeSocket = class {
|
|
|
81
89
|
handlers.set(channel, set);
|
|
82
90
|
}
|
|
83
91
|
set.add(handler);
|
|
84
|
-
return () => this
|
|
92
|
+
return () => this.#removeHandler(channel, handler);
|
|
85
93
|
}
|
|
86
|
-
/**
|
|
87
|
-
|
|
94
|
+
/** Porte non typée de {@link off}. */
|
|
95
|
+
#removeHandler(channel, handler) {
|
|
88
96
|
this.#handlers?.get(channel)?.delete(handler);
|
|
89
97
|
}
|
|
90
98
|
/**
|
|
@@ -102,7 +110,7 @@ var ServerRealtimeSocket = class {
|
|
|
102
110
|
return {
|
|
103
111
|
name,
|
|
104
112
|
on(handler) {
|
|
105
|
-
const dispose = hub
|
|
113
|
+
const dispose = hub.#addHandler(name, handler);
|
|
106
114
|
disposers.add(dispose);
|
|
107
115
|
return () => {
|
|
108
116
|
dispose();
|
|
@@ -110,7 +118,7 @@ var ServerRealtimeSocket = class {
|
|
|
110
118
|
};
|
|
111
119
|
},
|
|
112
120
|
send(payload) {
|
|
113
|
-
hub.publish(name, payload);
|
|
121
|
+
hub.hub.publish(name, payload);
|
|
114
122
|
},
|
|
115
123
|
open() {
|
|
116
124
|
hub.subscribe(name);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isRecord } from "../guards.js";
|
|
1
2
|
import { BROWSER_ORIGIN, Pdu } from "nodefony";
|
|
2
3
|
//#region nodefony/src/server/syslogUplink.ts
|
|
3
4
|
/**
|
|
@@ -89,8 +90,9 @@ function createSyslogUplinkHandler(opts) {
|
|
|
89
90
|
for (let i = 0; i < limit; i += 1) {
|
|
90
91
|
if (windowCount >= maxEntriesPerWindow) return;
|
|
91
92
|
windowCount += 1;
|
|
92
|
-
const
|
|
93
|
-
if (
|
|
93
|
+
const entry = entries[i];
|
|
94
|
+
if (!isRecord(entry)) continue;
|
|
95
|
+
const raw = entry;
|
|
94
96
|
const severity = clampSeverity(raw.severity);
|
|
95
97
|
const msgid = str(raw.msgid, 64) ?? "";
|
|
96
98
|
const msg = str(raw.msg, maxStringLength) ?? "";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isRealtimeConfig } from "../../config/defineModuleConfig.js";
|
|
1
2
|
import { RESERVED_SYSTEM_PREFIXES, getRealtimeHub } from "../server/RealtimeHub.js";
|
|
2
3
|
import { applyDeclaredBroadcastPrefixes } from "../../decorators/realtimeDecorators.js";
|
|
3
4
|
import { Service } from "nodefony";
|
|
@@ -35,7 +36,7 @@ var RealtimeService = class extends Service {
|
|
|
35
36
|
#config = null;
|
|
36
37
|
#hub = null;
|
|
37
38
|
constructor(module) {
|
|
38
|
-
super(serviceName, module.container, module.notificationsCenter, module.options);
|
|
39
|
+
super(serviceName, module.container ?? void 0, module.notificationsCenter, module.options);
|
|
39
40
|
this.module = module;
|
|
40
41
|
}
|
|
41
42
|
/**
|
|
@@ -55,7 +56,7 @@ var RealtimeService = class extends Service {
|
|
|
55
56
|
*/
|
|
56
57
|
async init(_module) {
|
|
57
58
|
const config = this.module.config;
|
|
58
|
-
if (!config) throw new Error(`${serviceName}: realtimeConfig absente (this.module.config vide) — la Module class doit la valider à onKernelRegister`);
|
|
59
|
+
if (!isRealtimeConfig(config)) throw new Error(`${serviceName}: realtimeConfig absente (this.module.config vide) — la Module class doit la valider à onKernelRegister`);
|
|
59
60
|
this.#config = config;
|
|
60
61
|
if (!this.#config.enabled) {
|
|
61
62
|
this.log("realtime enabled:false → service inerte (hub non câblé)", "INFO");
|
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
import { getRealtimeHub } from "../src/server/RealtimeHub.js";
|
|
2
|
+
import { RequestContext } from "nodefony";
|
|
2
3
|
//#region nodefony/testing/index.ts
|
|
4
|
+
/**
|
|
5
|
+
* `@nodefony/realtime/testing` — **harnais de test d'un controller temps réel**.
|
|
6
|
+
*
|
|
7
|
+
* Écrire un test de socket demandait jusqu'ici une centaine de lignes de
|
|
8
|
+
* plomberie FRAMEWORK avant la première assertion : un faux `ContextType` qui ne
|
|
9
|
+
* fournit que ce que la base touche, un pont vers `handleRealtime` (qui est
|
|
10
|
+
* `protected`), la remise à zéro du hub — singleton par process — entre deux
|
|
11
|
+
* cas, et la pose d'une identité au handshake. Cette plomberie se périme au
|
|
12
|
+
* premier changement de signature de la base ; recopiée dans chaque application,
|
|
13
|
+
* elle y aurait vieilli sans que personne ne le sache. Elle vit donc ICI, avec
|
|
14
|
+
* le code dont elle dépend.
|
|
15
|
+
*
|
|
16
|
+
* Le harnais pilote le **côté serveur** : il injecte des frames JSON-RPC 2.0
|
|
17
|
+
* telles qu'un client les enverrait, et lit celles qui sortent. Il n'embarque
|
|
18
|
+
* PAS `NodefonySocket` — le client est un artefact navigateur du cœur, et le
|
|
19
|
+
* tirer ici le ferait entrer dans le paquet serveur publié. Ce qu'on veut
|
|
20
|
+
* prouver d'un controller (ses canaux, ses actions, son plein-duplex, son
|
|
21
|
+
* nettoyage) s'observe entièrement aux frames.
|
|
22
|
+
*
|
|
23
|
+
* ```ts
|
|
24
|
+
* import { createRealtimeHarness } from "@nodefony/realtime/testing";
|
|
25
|
+
*
|
|
26
|
+
* const h = createRealtimeHarness((ctx) => new ChatController(ctx));
|
|
27
|
+
* await h.connect();
|
|
28
|
+
* await h.subscribe("chat:ticker");
|
|
29
|
+
* expect(h.messages("chat:ticker")).to.have.length.greaterThan(0);
|
|
30
|
+
* h.dispose();
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* @packageDocumentation
|
|
34
|
+
*/
|
|
3
35
|
/** `readyState` d'une connexion WebSocket ouverte (`ws` / DOM). */
|
|
4
36
|
const OPEN = 1;
|
|
5
37
|
/** Laisse tourner les microtâches et les timers immédiats. */
|
|
@@ -79,8 +111,16 @@ function createRealtimeHarness(create, options = {}) {
|
|
|
79
111
|
});
|
|
80
112
|
const bridge = controller;
|
|
81
113
|
let nextId = 1;
|
|
114
|
+
const identity = options.identity;
|
|
115
|
+
const handshakeStore = identity === void 0 ? { requestId: "realtime-harness" } : {
|
|
116
|
+
requestId: "realtime-harness",
|
|
117
|
+
token: identity,
|
|
118
|
+
user: identity.getAttribute("user")
|
|
119
|
+
};
|
|
82
120
|
const feed = async (frame) => {
|
|
83
|
-
|
|
121
|
+
RequestContext.run(handshakeStore, () => {
|
|
122
|
+
bridge.handleRealtime(frame === null ? null : JSON.stringify(frame));
|
|
123
|
+
});
|
|
84
124
|
await tick();
|
|
85
125
|
};
|
|
86
126
|
const rpc = (payload) => ({
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Schema de la configuration de `@nodefony/realtime` — lu SANS démarrer
|
|
3
|
+
* l'application, par `nodefony env` et la notice `.env.example`, qui y
|
|
4
|
+
* trouvent les noms courts (`.meta({ env })`).
|
|
5
|
+
*
|
|
6
|
+
* Déclaré dans `package.json` → `nodefony.configSchema` ; le nom de l'export
|
|
7
|
+
* est FIXE. Fichier FEUILLE : il n'importe que zod et le schéma, et n'ouvre
|
|
8
|
+
* rien à l'import — une commande qui ne démarre pas l'application l'exécute.
|
|
9
|
+
*
|
|
10
|
+
* @returns le JSON Schema, métadonnées Nodefony comprises.
|
|
11
|
+
*/
|
|
12
|
+
export declare function configJsonSchema(): unknown;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
1
|
+
import type { z } from "zod";
|
|
2
2
|
import { realtimeConfigSchema, type IRealtimeSchemaConfig } from "./config.js";
|
|
3
3
|
import type { IBackplane } from "../interfaces/IBackplane.js";
|
|
4
4
|
/**
|
|
@@ -19,11 +19,13 @@ import type { IBackplane } from "../interfaces/IBackplane.js";
|
|
|
19
19
|
* faire dans un JSON Schema Studio). L'instance, si fournie, prend précédence
|
|
20
20
|
* sur `driver` côté service ; ce dernier reste source d'introspection/UI.
|
|
21
21
|
*
|
|
22
|
-
* **
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* (
|
|
26
|
-
*
|
|
22
|
+
* **Variables d'environnement** : `NF_REALTIME_DRIVER`,
|
|
23
|
+
* `NF_REALTIME_BACKPLANE_SECRET` et `NF_REALTIME_BACKPLANE_NAMESPACE` ne se
|
|
24
|
+
* lisent PAS ici. Chacune est déclarée sur son champ dans `config.ts`
|
|
25
|
+
* (`.meta({ env })`) et posée par le Kernel AVANT la validation : le schéma
|
|
26
|
+
* les valide comme une valeur de l'application (un secret de moins de 32
|
|
27
|
+
* caractères arrête le démarrage). Un appel direct de ce builder, hors Kernel,
|
|
28
|
+
* ne les voit donc pas.
|
|
27
29
|
*
|
|
28
30
|
* @param config - configuration brute (sections omises = défauts sûrs).
|
|
29
31
|
* @param options.backplane - instance `IBackplane` custom (override le driver à
|
|
@@ -34,6 +36,27 @@ import type { IBackplane } from "../interfaces/IBackplane.js";
|
|
|
34
36
|
export declare function defineRealtimeConfig(config?: IRealtimeConfigInput, options?: {
|
|
35
37
|
backplane?: IBackplane;
|
|
36
38
|
}): IRealtimeConfig;
|
|
39
|
+
/**
|
|
40
|
+
* Le builder pour une entrée NON typée — la config reçue par le module au
|
|
41
|
+
* démarrage, dont le schéma décide seul de la forme. Interne au paquet : une
|
|
42
|
+
* application passe par {@link defineRealtimeConfig}, qui type ce qu'elle écrit.
|
|
43
|
+
*
|
|
44
|
+
* @param config - configuration brute, validée par le schéma
|
|
45
|
+
* @param options.backplane - instance `IBackplane` custom
|
|
46
|
+
* @returns config gelée prête pour `RealtimeService`.
|
|
47
|
+
* @throws BootConfigurationError si invalide.
|
|
48
|
+
*/
|
|
49
|
+
export declare function buildRealtimeConfig(config?: unknown, options?: {
|
|
50
|
+
backplane?: IBackplane;
|
|
51
|
+
}): IRealtimeConfig;
|
|
52
|
+
/**
|
|
53
|
+
* Vrai si la valeur est une config produite par {@link buildRealtimeConfig}
|
|
54
|
+
* (donc validée par le schéma). Lu une fois, à l'init du service.
|
|
55
|
+
*
|
|
56
|
+
* @param value - la config lue sur le module
|
|
57
|
+
* @returns `true` si elle sort du builder
|
|
58
|
+
*/
|
|
59
|
+
export declare function isRealtimeConfig(value: unknown): value is IRealtimeConfig;
|
|
37
60
|
/**
|
|
38
61
|
* JSON Schema introspectable de la config realtime — destiné au formulaire
|
|
39
62
|
* d'édition Studio (futur). N'inclut PAS `backplane.instance` (non sérialisable).
|