@nodefony/realtime 10.0.0-alpha.9 → 10.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/nodefony/src/backplane/envelope.js +2 -1
- package/dist/nodefony/src/cluster/ClusterProbeClient.js +1 -1
- package/dist/nodefony/src/server/RealtimeController.js +52 -11
- package/dist/nodefony/src/server/RealtimeHub.js +14 -12
- package/dist/nodefony/src/server/ServerRealtimeSocket.js +9 -7
- package/dist/nodefony/src/service/RealtimeService.js +7 -6
- package/dist/nodefony/testing/index.js +4 -2
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +1 -1
- package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +3 -3
- package/dist/types/nodefony/interfaces/IRealtimeAuthenticatorMatcher.d.ts +1 -1
- package/dist/types/nodefony/interfaces/IRealtimeHandshake.d.ts +1 -1
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +27 -0
- package/dist/types/nodefony/testing/index.d.ts +2 -2
- package/docs/actions.md +6 -6
- package/docs/architecture.md +18 -18
- package/docs/configuration.md +6 -6
- package/docs/cookbook-chat.md +11 -11
- package/docs/index.md +1 -4
- package/docs/observabilite.md +2 -5
- package/docs/protocole.md +19 -17
- package/docs/securite.md +23 -23
- package/docs/vocabulaire.md +18 -18
- package/package.json +10 -10
|
@@ -99,7 +99,8 @@ function openBackplaneEnvelope(raw, secret) {
|
|
|
99
99
|
if (!secret) return msg;
|
|
100
100
|
const provided = parsed[SEAL_FIELD];
|
|
101
101
|
if (typeof provided !== "string") return null;
|
|
102
|
-
const
|
|
102
|
+
const payloadText = JSON.stringify(msg.payload);
|
|
103
|
+
const expected = seal(JSON.stringify(msg.originId), JSON.stringify(msg.channel), payloadText ?? "null", secret);
|
|
103
104
|
const a = Buffer.from(provided, "base64url");
|
|
104
105
|
const b = Buffer.from(expected, "base64url");
|
|
105
106
|
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
|
|
@@ -136,7 +136,7 @@ var ClusterProbeClient = class {
|
|
|
136
136
|
#report(buildOwn) {
|
|
137
137
|
const payload = buildOwn();
|
|
138
138
|
if (this.#richEnabled) {
|
|
139
|
-
|
|
139
|
+
this.#richProbe ??= new RichProcessProbe();
|
|
140
140
|
payload.rich = this.#richProbe.read();
|
|
141
141
|
}
|
|
142
142
|
if (this.#ormRich !== null) payload.ormRich = this.#ormRich;
|
|
@@ -5,7 +5,7 @@ import { createSyslogUplinkHandler } from "./syslogUplink.js";
|
|
|
5
5
|
import { WsConnectionTransport } from "../transport/WsConnectionTransport.js";
|
|
6
6
|
import { welcomeEnv } from "./welcomeEnv.js";
|
|
7
7
|
import { deniedDetail } from "./deniedDetail.js";
|
|
8
|
-
import { JsonRpcPeer, PLATFORM_INBOUND, RequestContext, RpcEnvelope, RpcError, identityHint } from "nodefony";
|
|
8
|
+
import { BootConfigurationError, JsonRpcPeer, PLATFORM_INBOUND, RequestContext, RpcEnvelope, RpcError, Scope, identityHint } from "nodefony";
|
|
9
9
|
import { readBackpressureOptions } from "@nodefony/http";
|
|
10
10
|
import { Controller } from "@nodefony/framework";
|
|
11
11
|
//#region nodefony/src/server/RealtimeController.ts
|
|
@@ -77,6 +77,36 @@ let warnedUnenforcedPolicies = false;
|
|
|
77
77
|
* d'action n'est payé que sur une requête `id`. C'est aussi le seam du backplane Redis.
|
|
78
78
|
*/
|
|
79
79
|
var RealtimeController = class extends Controller {
|
|
80
|
+
/**
|
|
81
|
+
* Une instance PAR CONNEXION, par construction — le défaut `singleton` des
|
|
82
|
+
* contrôleurs ne s'applique pas ici. Le pair JSON-RPC de la connexion capture
|
|
83
|
+
* l'instance du handshake, et {@link notifyClient} / {@link requestClient}
|
|
84
|
+
* désignent « CETTE connexion » par l'instance : partagée, ils liraient
|
|
85
|
+
* l'ALS de celui qui ÉMET (un écouteur du hub, un minuteur), donc une autre
|
|
86
|
+
* connexion, ou aucune. Cf {@link assertScope}.
|
|
87
|
+
*/
|
|
88
|
+
static scope = "request";
|
|
89
|
+
/**
|
|
90
|
+
* Refuse qu'une sous-classe se déclare singleton — au démarrage
|
|
91
|
+
* (`Router.setController`), et au constructeur pour qui construit par `new`
|
|
92
|
+
* hors du routeur (harnais de test). Sans ce refus, la socket s'ouvrirait
|
|
93
|
+
* sans jamais émettre `realtime:welcome` : une panne indiscernable d'un
|
|
94
|
+
* incident réseau.
|
|
95
|
+
*
|
|
96
|
+
* @throws BootConfigurationError si la classe se déclare `@Scope("singleton")`.
|
|
97
|
+
*/
|
|
98
|
+
static assertScope() {
|
|
99
|
+
if (this.scope === "singleton") throw new BootConfigurationError(`Contrôleur temps réel « ${this.name} » déclaré @Scope("singleton") : un RealtimeController vit PAR CONNEXION — son pair JSON-RPC et notifyClient/requestClient désignent la connexion par l'instance. Retirer @Scope("singleton") : la portée d'un RealtimeController est request (une instance par connexion).`);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* @param name - nom du service (celui de la classe, par convention).
|
|
103
|
+
* @param context - le contexte de la connexion.
|
|
104
|
+
* @throws BootConfigurationError si la classe se déclare singleton.
|
|
105
|
+
*/
|
|
106
|
+
constructor(name, context) {
|
|
107
|
+
new.target.assertScope();
|
|
108
|
+
super(name, context);
|
|
109
|
+
}
|
|
80
110
|
/**
|
|
81
111
|
* Map des factories de canaux EXACT, lue depuis les décorateurs `@RealtimeChannel`
|
|
82
112
|
* au handshake (cold-path) et mémoïsée par instance. `null` = pas de décorateur
|
|
@@ -161,7 +191,10 @@ var RealtimeController = class extends Controller {
|
|
|
161
191
|
const ctx = this.context;
|
|
162
192
|
if (!ctx) return;
|
|
163
193
|
if (message == null) {
|
|
164
|
-
this.onHandshake(ctx)
|
|
194
|
+
this.onHandshake(ctx).catch((e) => {
|
|
195
|
+
this.log(`WS realtime handshake failed: ${e instanceof Error ? e.message : String(e)}`, "ERROR");
|
|
196
|
+
ctx.connection?.close(1011, "internal error");
|
|
197
|
+
});
|
|
165
198
|
return;
|
|
166
199
|
}
|
|
167
200
|
ctx.__nfRealtime?.transport.feed(message.toString());
|
|
@@ -224,7 +257,7 @@ var RealtimeController = class extends Controller {
|
|
|
224
257
|
}
|
|
225
258
|
const authenticator = hub.resolveAuthenticator(handshake);
|
|
226
259
|
let token;
|
|
227
|
-
if (authenticator
|
|
260
|
+
if (authenticator?.supports(handshake)) try {
|
|
228
261
|
token = await authenticator.authenticate(handshake);
|
|
229
262
|
authenticator.onSuccess?.(handshake, token);
|
|
230
263
|
} catch (e) {
|
|
@@ -301,13 +334,17 @@ var RealtimeController = class extends Controller {
|
|
|
301
334
|
this._decoratedChannels = getRealtimeChannels(this);
|
|
302
335
|
const channelPolicies = getRealtimeChannelPolicies(this);
|
|
303
336
|
if (channelPolicies) {
|
|
304
|
-
for (const name
|
|
337
|
+
for (const [name, policy] of Object.entries(channelPolicies)) hub.registerChannelPolicy(name, policy);
|
|
305
338
|
if (hub.hasUnenforcedChannelPolicies() && !warnedUnenforcedPolicies) {
|
|
306
339
|
warnedUnenforcedPolicies = true;
|
|
307
340
|
this.log("Realtime channel policies declared but NO frame authorizer is wired — these policies are NOT enforced (a protected channel is currently open). Load @nodefony/security with a realtime zone to enforce them.", "WARNING");
|
|
308
341
|
}
|
|
309
342
|
}
|
|
310
|
-
|
|
343
|
+
const noticeSyslog = this.syslog;
|
|
344
|
+
const noticeName = this.name;
|
|
345
|
+
hub.onPlatformNotice((message, severity) => {
|
|
346
|
+
noticeSyslog?.log(message, severity, noticeName);
|
|
347
|
+
});
|
|
311
348
|
hub.registerConnection(transport);
|
|
312
349
|
let revocable = null;
|
|
313
350
|
if (typeof token.isValid === "function") {
|
|
@@ -318,7 +355,7 @@ var RealtimeController = class extends Controller {
|
|
|
318
355
|
hub.registerRevocable(revocable);
|
|
319
356
|
}
|
|
320
357
|
const broadcast = this.realtimeBroadcastChannels();
|
|
321
|
-
for (
|
|
358
|
+
for (const channel of broadcast) hub.markBroadcastChannel(channel);
|
|
322
359
|
const decoratedInbound = getRealtimeInbound(this);
|
|
323
360
|
const overrideInbound = this.realtimeInbound();
|
|
324
361
|
const inboundMap = {
|
|
@@ -326,7 +363,7 @@ var RealtimeController = class extends Controller {
|
|
|
326
363
|
...overrideInbound
|
|
327
364
|
};
|
|
328
365
|
const clientLogs = hub.clientLogsLimits;
|
|
329
|
-
if (clientLogs !== null && inboundMap
|
|
366
|
+
if (clientLogs !== null && !Object.hasOwn(inboundMap, PLATFORM_INBOUND.syslogUplink)) {
|
|
330
367
|
const syslog = this.syslog;
|
|
331
368
|
if (syslog) inboundMap[PLATFORM_INBOUND.syslogUplink] = createSyslogUplinkHandler({
|
|
332
369
|
syslog,
|
|
@@ -342,7 +379,7 @@ var RealtimeController = class extends Controller {
|
|
|
342
379
|
inbound
|
|
343
380
|
};
|
|
344
381
|
holder.__nfRealtime = state;
|
|
345
|
-
ctx.once
|
|
382
|
+
ctx.once("onFinish", () => {
|
|
346
383
|
const liveHub = getRealtimeHub();
|
|
347
384
|
for (const [channel, sink] of state.channels) liveHub.unsubscribe(channel, sink);
|
|
348
385
|
state.channels.clear();
|
|
@@ -412,6 +449,7 @@ var RealtimeController = class extends Controller {
|
|
|
412
449
|
else state.peer.notify(channel, payload);
|
|
413
450
|
};
|
|
414
451
|
let servedByPattern = false;
|
|
452
|
+
const isServedByPattern = () => servedByPattern;
|
|
415
453
|
if (getRealtimeHub().subscribeClient(channel, sink, (ch, publish) => {
|
|
416
454
|
const decFactory = this._decoratedChannels?.[ch];
|
|
417
455
|
if (decFactory) return decFactory(ch, publish);
|
|
@@ -420,7 +458,7 @@ var RealtimeController = class extends Controller {
|
|
|
420
458
|
return dispose;
|
|
421
459
|
}, (payload) => JSON.stringify(JsonRpcPeer.buildNotification(channel, payload)))) {
|
|
422
460
|
state.channels.set(channel, sink);
|
|
423
|
-
if (
|
|
461
|
+
if (isServedByPattern()) getRealtimeHub().noticeUnguardedDynamicChannel(channel);
|
|
424
462
|
this.log(`WS subscribe → ${channel}`, "DEBUG");
|
|
425
463
|
return;
|
|
426
464
|
}
|
|
@@ -505,6 +543,8 @@ var RealtimeController = class extends Controller {
|
|
|
505
543
|
user: token.getAttribute("user"),
|
|
506
544
|
userId: token.getUserIdentifier(),
|
|
507
545
|
context: ctx,
|
|
546
|
+
resolver,
|
|
547
|
+
scope: ctx.container instanceof Scope ? ctx.container : void 0,
|
|
508
548
|
body: p?.body,
|
|
509
549
|
idempotencyKey: typeof p?.idempotencyKey === "string" ? p.idempotencyKey : void 0,
|
|
510
550
|
renderSink,
|
|
@@ -542,7 +582,8 @@ var RealtimeController = class extends Controller {
|
|
|
542
582
|
stopChannel(ctx, channel) {
|
|
543
583
|
if (!channel) return;
|
|
544
584
|
const state = ctx.__nfRealtime;
|
|
545
|
-
|
|
585
|
+
if (!state) return;
|
|
586
|
+
const sink = state.channels.get(channel);
|
|
546
587
|
if (sink) {
|
|
547
588
|
getRealtimeHub().unsubscribe(channel, sink);
|
|
548
589
|
state.channels.delete(channel);
|
|
@@ -564,7 +605,7 @@ var RealtimeController = class extends Controller {
|
|
|
564
605
|
function buildHandshakeFromContext(ctx) {
|
|
565
606
|
const headers = ctx.request?.headers ?? {};
|
|
566
607
|
const cookies = /* @__PURE__ */ new Map();
|
|
567
|
-
const rawCookies = ctx.cookies
|
|
608
|
+
const rawCookies = ctx.cookies;
|
|
568
609
|
for (const name in rawCookies) {
|
|
569
610
|
const c = rawCookies[name];
|
|
570
611
|
if (c && typeof c.value === "string") cookies.set(name, c.value);
|
|
@@ -273,14 +273,15 @@ var RealtimeHub = class RealtimeHub {
|
|
|
273
273
|
* et retire le canal (libère timers/listeners). No-op si non abonné.
|
|
274
274
|
*/
|
|
275
275
|
unsubscribe(channel, sink) {
|
|
276
|
-
const
|
|
277
|
-
|
|
276
|
+
const channels = this.#channels;
|
|
277
|
+
const st = channels?.get(channel);
|
|
278
|
+
if (!channels || !st) return;
|
|
278
279
|
st.sinks.delete(sink);
|
|
279
280
|
if (st.sinks.size === 0) {
|
|
280
281
|
try {
|
|
281
282
|
st.dispose?.();
|
|
282
283
|
} catch {}
|
|
283
|
-
|
|
284
|
+
channels.delete(channel);
|
|
284
285
|
}
|
|
285
286
|
}
|
|
286
287
|
/**
|
|
@@ -356,7 +357,7 @@ var RealtimeHub = class RealtimeHub {
|
|
|
356
357
|
#isBroadcast(channel) {
|
|
357
358
|
const prefixes = this.#broadcastPrefixes;
|
|
358
359
|
if (prefixes === null) return false;
|
|
359
|
-
for (
|
|
360
|
+
for (const prefix of prefixes) if (channel.startsWith(prefix)) return true;
|
|
360
361
|
return false;
|
|
361
362
|
}
|
|
362
363
|
/**
|
|
@@ -372,7 +373,11 @@ var RealtimeHub = class RealtimeHub {
|
|
|
372
373
|
setBackplane(backplane) {
|
|
373
374
|
this.#backplane = backplane;
|
|
374
375
|
backplane.onMessage((msg) => this.#admitFromBackplane(msg));
|
|
375
|
-
backplane.start();
|
|
376
|
+
const started = backplane.start();
|
|
377
|
+
if (started instanceof Promise) started.catch((err) => {
|
|
378
|
+
if (this.#notice === null) throw err instanceof Error ? err : new Error(String(err));
|
|
379
|
+
this.#notifyOnce("backplane:start", `realtime: démarrage du backplane en échec — les autres pairs ne recevront rien (${err instanceof Error ? err.message : String(err)})`);
|
|
380
|
+
});
|
|
376
381
|
return backplane;
|
|
377
382
|
}
|
|
378
383
|
/**
|
|
@@ -433,7 +438,7 @@ var RealtimeHub = class RealtimeHub {
|
|
|
433
438
|
this.#revalidateTimer = setInterval(() => {
|
|
434
439
|
this.revalidateRevocable();
|
|
435
440
|
}, REVOCATION_REVALIDATE_MS);
|
|
436
|
-
this.#revalidateTimer.unref
|
|
441
|
+
this.#revalidateTimer.unref();
|
|
437
442
|
}
|
|
438
443
|
}
|
|
439
444
|
/**
|
|
@@ -557,7 +562,7 @@ var RealtimeHub = class RealtimeHub {
|
|
|
557
562
|
useAuthenticator(matcher, authenticator) {
|
|
558
563
|
const list = this.#authenticators ??= [];
|
|
559
564
|
const compiled = compileMatcher(matcher);
|
|
560
|
-
for (
|
|
565
|
+
for (const entry of list) if (entry.authenticator === authenticator) return;
|
|
561
566
|
list.push({
|
|
562
567
|
matcher: compiled,
|
|
563
568
|
authenticator
|
|
@@ -571,10 +576,7 @@ var RealtimeHub = class RealtimeHub {
|
|
|
571
576
|
resolveAuthenticator(handshake) {
|
|
572
577
|
const list = this.#authenticators;
|
|
573
578
|
if (list === null) return null;
|
|
574
|
-
for (
|
|
575
|
-
const entry = list[i];
|
|
576
|
-
if (entry.matcher.match(handshake)) return entry.authenticator;
|
|
577
|
-
}
|
|
579
|
+
for (const entry of list) if (entry.matcher.match(handshake)) return entry.authenticator;
|
|
578
580
|
return null;
|
|
579
581
|
}
|
|
580
582
|
/**
|
|
@@ -781,7 +783,7 @@ var RealtimeHub = class RealtimeHub {
|
|
|
781
783
|
if (exact !== void 0) return exact;
|
|
782
784
|
const patterns = this.#channelPolicyPatterns;
|
|
783
785
|
if (patterns === null) return null;
|
|
784
|
-
for (
|
|
786
|
+
for (const pattern of patterns) if (pattern.re.test(name)) return pattern.policy;
|
|
785
787
|
return null;
|
|
786
788
|
}
|
|
787
789
|
/** Des politiques de canal sont-elles déclarées (au moins une) ? */
|
|
@@ -57,17 +57,19 @@ var ServerRealtimeSocket = class {
|
|
|
57
57
|
/** Désabonne (ref-compté) : coupe le sink du hub au DERNIER consommateur. */
|
|
58
58
|
unsubscribe(channel) {
|
|
59
59
|
const c = channel;
|
|
60
|
-
const
|
|
61
|
-
|
|
60
|
+
const subs = this.#subs;
|
|
61
|
+
const cur = subs?.get(c);
|
|
62
|
+
if (subs === null || cur === void 0 || cur === 0) return;
|
|
62
63
|
if (cur > 1) {
|
|
63
|
-
|
|
64
|
+
subs.set(c, cur - 1);
|
|
64
65
|
return;
|
|
65
66
|
}
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
67
|
+
subs.delete(c);
|
|
68
|
+
const sinks = this.#sinks;
|
|
69
|
+
const sink = sinks?.get(c);
|
|
70
|
+
if (sinks && sink) {
|
|
69
71
|
this.hub.unsubscribe(c, sink);
|
|
70
|
-
|
|
72
|
+
sinks.delete(c);
|
|
71
73
|
}
|
|
72
74
|
}
|
|
73
75
|
/** Branche un handler de réception sur un canal. Renvoie un `dispose`. */
|
|
@@ -54,8 +54,9 @@ var RealtimeService = class extends Service {
|
|
|
54
54
|
* par la Module class). Aucun warn — c'est le cas commun.
|
|
55
55
|
*/
|
|
56
56
|
async init(_module) {
|
|
57
|
-
|
|
58
|
-
if (!
|
|
57
|
+
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
|
+
this.#config = config;
|
|
59
60
|
if (!this.#config.enabled) {
|
|
60
61
|
this.log("realtime enabled:false → service inerte (hub non câblé)", "INFO");
|
|
61
62
|
return this;
|
|
@@ -227,10 +228,10 @@ var RealtimeService = class extends Service {
|
|
|
227
228
|
* `allowList` (pas de wildcard).
|
|
228
229
|
*/
|
|
229
230
|
function buildOriginGuard(config) {
|
|
230
|
-
const c = config.csrf
|
|
231
|
-
if (!c
|
|
232
|
-
const allowSet = new Set(c.allowList
|
|
233
|
-
const allowMissing = c.allowMissingOrigin
|
|
231
|
+
const c = config.csrf.checkOrigin;
|
|
232
|
+
if (!c.enabled) return null;
|
|
233
|
+
const allowSet = new Set(c.allowList);
|
|
234
|
+
const allowMissing = c.allowMissingOrigin;
|
|
234
235
|
return (origin) => {
|
|
235
236
|
if (origin === void 0 || origin === "") return allowMissing;
|
|
236
237
|
return allowSet.has(origin);
|
|
@@ -98,11 +98,13 @@ function createRealtimeHarness(create, options = {}) {
|
|
|
98
98
|
hub.onPlatformNotice((message) => {
|
|
99
99
|
notices.push(message);
|
|
100
100
|
});
|
|
101
|
-
|
|
101
|
+
await waitFor(() => received.some((f) => f.method === "realtime:welcome"), 10);
|
|
102
|
+
const welcome = received.find((f) => f.method === "realtime:welcome");
|
|
103
|
+
if (!welcome) {
|
|
102
104
|
const cause = closes.length > 0 ? `socket fermée (code ${String(closes[0]?.code)})` : "aucune frame émise";
|
|
103
105
|
throw new Error(`createRealtimeHarness: pas de realtime:welcome — ${cause}. Vérifier l'Origin (seam #4) et l'authenticator (seam #2).`);
|
|
104
106
|
}
|
|
105
|
-
return
|
|
107
|
+
return welcome;
|
|
106
108
|
},
|
|
107
109
|
async subscribe(channel) {
|
|
108
110
|
await feed(rpc({
|
|
@@ -44,6 +44,6 @@ export type IRealtimeConfigInput = z.input<typeof realtimeConfigSchema>;
|
|
|
44
44
|
/** Config normalisée et gelée (sortie du builder, lue par `RealtimeService`). */
|
|
45
45
|
export type IRealtimeConfig = IRealtimeSchemaConfig & {
|
|
46
46
|
backplane: IRealtimeSchemaConfig["backplane"] & {
|
|
47
|
-
instance?: IBackplane;
|
|
47
|
+
instance?: IBackplane | undefined;
|
|
48
48
|
};
|
|
49
49
|
};
|
|
@@ -19,10 +19,10 @@
|
|
|
19
19
|
*/
|
|
20
20
|
export interface IChannelPolicy {
|
|
21
21
|
/** Exige une connexion authentifiée (token non anonyme). */
|
|
22
|
-
readonly authenticated?: boolean;
|
|
22
|
+
readonly authenticated?: boolean | undefined;
|
|
23
23
|
/** Un de ces rôles suffit (évalué AVEC la hiérarchie de rôles, côté security). */
|
|
24
|
-
readonly roles?: readonly string[];
|
|
24
|
+
readonly roles?: readonly string[] | undefined;
|
|
25
25
|
/** Un de ces scopes suffit (axe API : JWT/clé API ; session BFF n'en porte pas). */
|
|
26
|
-
readonly scopes?: readonly string[];
|
|
26
|
+
readonly scopes?: readonly string[] | undefined;
|
|
27
27
|
}
|
|
28
28
|
export default IChannelPolicy;
|
|
@@ -40,7 +40,7 @@ export interface IRealtimeAuthenticatorMatcher {
|
|
|
40
40
|
*/
|
|
41
41
|
export interface ICompiledRealtimeMatcher {
|
|
42
42
|
readonly pattern: RegExp;
|
|
43
|
-
readonly host?: string;
|
|
43
|
+
readonly host?: string | undefined;
|
|
44
44
|
/** Le matcher capture-t-il ce handshake ? */
|
|
45
45
|
match(handshake: IRealtimeHandshake): boolean;
|
|
46
46
|
}
|
|
@@ -21,7 +21,7 @@ export interface IRealtimeHandshake {
|
|
|
21
21
|
/** IP du client (déjà extraite — derrière proxy : voir `trust proxy`). */
|
|
22
22
|
readonly remoteAddress: string;
|
|
23
23
|
/** Origin RFC 6455 §10.2 — `undefined` si en-tête absent (clients non-browser). */
|
|
24
|
-
readonly origin?: string;
|
|
24
|
+
readonly origin?: string | undefined;
|
|
25
25
|
/** Sous-protocoles annoncés (`Sec-WebSocket-Protocol`). `[]` si aucun. */
|
|
26
26
|
readonly protocols: readonly string[];
|
|
27
27
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type RpcActionHandler, type EventsMap, type ActionsMap, type DefaultEventsMap, type DefaultActionsMap, type EventNames, type EventPayload, type ActionNames, type ActionParams, type ActionResult } from "nodefony";
|
|
2
|
+
import type { ContextType } from "@nodefony/http";
|
|
2
3
|
import { Controller } from "@nodefony/framework";
|
|
4
|
+
import type { ControllerScope } from "@nodefony/framework";
|
|
3
5
|
import type { IRealtimeController, RealtimePublish, RealtimeInboundHandler } from "../../interfaces/IRealtimeController.js";
|
|
4
6
|
/**
|
|
5
7
|
* RealtimeController — base d'un endpoint WebSocket temps réel SERVEUR (JSON-RPC 2.0).
|
|
@@ -24,6 +26,31 @@ import type { IRealtimeController, RealtimePublish, RealtimeInboundHandler } fro
|
|
|
24
26
|
* d'action n'est payé que sur une requête `id`. C'est aussi le seam du backplane Redis.
|
|
25
27
|
*/
|
|
26
28
|
export declare abstract class RealtimeController<Emit extends EventsMap = DefaultEventsMap, Actions extends ActionsMap = DefaultActionsMap> extends Controller implements IRealtimeController {
|
|
29
|
+
/**
|
|
30
|
+
* Une instance PAR CONNEXION, par construction — le défaut `singleton` des
|
|
31
|
+
* contrôleurs ne s'applique pas ici. Le pair JSON-RPC de la connexion capture
|
|
32
|
+
* l'instance du handshake, et {@link notifyClient} / {@link requestClient}
|
|
33
|
+
* désignent « CETTE connexion » par l'instance : partagée, ils liraient
|
|
34
|
+
* l'ALS de celui qui ÉMET (un écouteur du hub, un minuteur), donc une autre
|
|
35
|
+
* connexion, ou aucune. Cf {@link assertScope}.
|
|
36
|
+
*/
|
|
37
|
+
static scope: ControllerScope;
|
|
38
|
+
/**
|
|
39
|
+
* Refuse qu'une sous-classe se déclare singleton — au démarrage
|
|
40
|
+
* (`Router.setController`), et au constructeur pour qui construit par `new`
|
|
41
|
+
* hors du routeur (harnais de test). Sans ce refus, la socket s'ouvrirait
|
|
42
|
+
* sans jamais émettre `realtime:welcome` : une panne indiscernable d'un
|
|
43
|
+
* incident réseau.
|
|
44
|
+
*
|
|
45
|
+
* @throws BootConfigurationError si la classe se déclare `@Scope("singleton")`.
|
|
46
|
+
*/
|
|
47
|
+
static assertScope(): void;
|
|
48
|
+
/**
|
|
49
|
+
* @param name - nom du service (celui de la classe, par convention).
|
|
50
|
+
* @param context - le contexte de la connexion.
|
|
51
|
+
* @throws BootConfigurationError si la classe se déclare singleton.
|
|
52
|
+
*/
|
|
53
|
+
constructor(name: string, context: ContextType);
|
|
27
54
|
/**
|
|
28
55
|
* Map des factories de canaux EXACT, lue depuis les décorateurs `@RealtimeChannel`
|
|
29
56
|
* au handshake (cold-path) et mémoïsée par instance. `null` = pas de décorateur
|
|
@@ -55,8 +55,8 @@ export interface IRealtimeFrame {
|
|
|
55
55
|
}
|
|
56
56
|
/** Fermeture demandée par le serveur (code RFC 6455 + raison). */
|
|
57
57
|
export interface IHarnessClose {
|
|
58
|
-
readonly code?: number;
|
|
59
|
-
readonly reason?: string;
|
|
58
|
+
readonly code?: number | undefined;
|
|
59
|
+
readonly reason?: string | undefined;
|
|
60
60
|
}
|
|
61
61
|
/**
|
|
62
62
|
* Refus d'abonnement notifié au client (`realtime:denied`) — le contrat de
|
package/docs/actions.md
CHANGED
|
@@ -136,8 +136,8 @@ l'instance du contrôleur au handshake, ce qui donne accès au noyau, aux servic
|
|
|
136
136
|
connexion sans élargir le contrat.
|
|
137
137
|
|
|
138
138
|
**L'endpoint s'annonce lui-même.** La liste des actions exposées voyage dans la frame d'accueil —
|
|
139
|
-
`IRealtimeWelcome` (`RealtimeController.ts:
|
|
140
|
-
`RealtimeClient.serverMethods` (`RealtimeClient.ts:
|
|
139
|
+
`IRealtimeWelcome` (`RealtimeController.ts:12`) — et se lit côté client par
|
|
140
|
+
`RealtimeClient.serverMethods` (`RealtimeClient.ts:597`). Une interface n'écrit donc jamais un nom d'action en dur : elle
|
|
141
141
|
n'active un bouton que si le serveur a déclaré savoir le servir.
|
|
142
142
|
|
|
143
143
|
**Le compromis, dit franchement** : une action est **un aller-retour**, point. Elle ne diffuse pas,
|
|
@@ -277,7 +277,7 @@ Le trajet complet d'une requête, des étapes qu'elle traverse aux branches par
|
|
|
277
277
|
| Voie | Quand la choisir |
|
|
278
278
|
| ------------------------------------------------------ | ----------------------------------------------------------- |
|
|
279
279
|
| `@RealtimeAction("nom")` (`realtimeDecorators.ts:101`) | le cas courant — un nom fixe, une méthode, déclaratif |
|
|
280
|
-
| `realtimeActions()` (`RealtimeController.ts:
|
|
280
|
+
| `realtimeActions()` (`RealtimeController.ts:236`) | la table est **calculée** (noms dynamiques, boucle, config) |
|
|
281
281
|
|
|
282
282
|
Les deux sont fusionnées au handshake, et **l'override gagne** en cas de conflit de nom : une
|
|
283
283
|
classe peut ainsi remplacer une action héritée sans toucher au parent
|
|
@@ -445,7 +445,7 @@ fois ? »**.
|
|
|
445
445
|
|
|
446
446
|
Pour les mutations passant par le pont API, la clé n'est pas une convention : elle est **exigée
|
|
447
447
|
par la signature** de `mutate()` (`RealtimeClient.ts:797`), et c'est la garde `@Idempotent`
|
|
448
|
-
(`routerDecorators.ts:
|
|
448
|
+
(`routerDecorators.ts:1171`) qui, côté serveur, reconnaît le rejeu et rend la réponse déjà calculée
|
|
449
449
|
au lieu de refaire l'effet.
|
|
450
450
|
|
|
451
451
|
```ts ignore
|
|
@@ -510,8 +510,8 @@ il sera conçu avec son premier consommateur réel.
|
|
|
510
510
|
Un cas particulier mérite d'être connu avant d'écrire une action : **elle existe peut-être déjà en
|
|
511
511
|
HTTP**. Le pont API expose la méthode `api.request`, qui rejoue une route de contrôleur sur la
|
|
512
512
|
socket, avec la même garde et le même résultat qu'en REST — `invokeApiRequest()`
|
|
513
|
-
(`RealtimeController.ts:
|
|
514
|
-
`realtimeApiRequest()` (`RealtimeController.ts:
|
|
513
|
+
(`RealtimeController.ts:949`). Il est **désactivé par défaut** et s'active en surchargeant
|
|
514
|
+
`realtimeApiRequest()` (`RealtimeController.ts:276`).
|
|
515
515
|
|
|
516
516
|
```ts ignore
|
|
517
517
|
const modules = await socket.request("/nodefony/kernel/api/modules");
|
package/docs/architecture.md
CHANGED
|
@@ -126,7 +126,7 @@ Nodefony étiquette avec **JSON-RPC 2.0**, une norme publique plutôt qu'un form
|
|
|
126
126
|
Le point remarquable : le **même** moteur de protocole tourne des deux côtés du fil. La
|
|
127
127
|
classe `JsonRpcPeer` (`JsonRpcPeer.ts:271`) est du code isomorphe du cœur — le navigateur
|
|
128
128
|
l'exécute dans `RealtimeClient`, le serveur l'instancie une fois par connexion dans
|
|
129
|
-
`RealtimeController.onHandshake()` (`RealtimeController.ts:
|
|
129
|
+
`RealtimeController.onHandshake()` (`RealtimeController.ts:374`). Le serveur peut donc
|
|
130
130
|
appeler le client, pas seulement l'inverse : c'est du vrai duplex, pas un aller-retour
|
|
131
131
|
déguisé.
|
|
132
132
|
|
|
@@ -141,7 +141,7 @@ Trois partis pris distinguent cette pile d'un simple « serveur WebSocket ».
|
|
|
141
141
|
et explicite : `ServerRealtimeSocket.request()` (`ServerRealtimeSocket.ts:131`) **rejette
|
|
142
142
|
toujours** — un handle posé sur le hub n'a pas d'interlocuteur unique, puisque le hub est
|
|
143
143
|
multi-clients. Pour un appel serveur→client ciblé, on passe par la connexion :
|
|
144
|
-
`RealtimeController.requestClient()` (`RealtimeController.ts:
|
|
144
|
+
`RealtimeController.requestClient()` (`RealtimeController.ts:329`).
|
|
145
145
|
|
|
146
146
|
**Un provider par canal, pas un par client.** Si mille onglets s'abonnent au même canal de
|
|
147
147
|
santé, le calcul ne doit tourner qu'une fois. Le hub crée le producteur au **premier**
|
|
@@ -325,7 +325,7 @@ La pile se lit de haut en bas ; chaque étage ne connaît que son voisin du dess
|
|
|
325
325
|
| Protocole | `JsonRpcPeer` (`JsonRpcPeer.ts:271`) | étiqueter, corréler, refuser une frame | par où passent les octets |
|
|
326
326
|
| Transport | `WsConnectionTransport` (`WsConnectionTransport.ts:46`) | déplacer des octets, mesurer la file | ce que veut dire un message |
|
|
327
327
|
| Hub | `RealtimeHub` (`RealtimeHub.ts:213`) | table des abonnés locaux + fan-out | qu'il existe d'autres process |
|
|
328
|
-
| Backplane | `IBackplane` (`IBackplane.ts:
|
|
328
|
+
| Backplane | `IBackplane` (`IBackplane.ts:107`) | porter un message aux autres process | ce qu'est un abonné |
|
|
329
329
|
|
|
330
330
|
> [!NOTE]
|
|
331
331
|
> Une part de la documentation historique décrit cette pile en « 5 étages » en comptant
|
|
@@ -388,7 +388,7 @@ Le schéma ci-dessous rend ces étages vivants : active le temps réel et il res
|
|
|
388
388
|
|
|
389
389
|
## 🔌 Le cycle de vie d'une connexion
|
|
390
390
|
|
|
391
|
-
Tout se joue dans `RealtimeController.onHandshake()` (`RealtimeController.ts:
|
|
391
|
+
Tout se joue dans `RealtimeController.onHandshake()` (`RealtimeController.ts:374`), appelé
|
|
392
392
|
une seule fois par connexion, en chemin froid.
|
|
393
393
|
|
|
394
394
|
```mermaid
|
|
@@ -419,19 +419,19 @@ sequenceDiagram
|
|
|
419
419
|
|
|
420
420
|
Les étapes, dans l'ordre exact du code :
|
|
421
421
|
|
|
422
|
-
1. **Contrôle de l'origine** — `RealtimeHub.checkOrigin()` (`RealtimeHub.ts:
|
|
422
|
+
1. **Contrôle de l'origine** — `RealtimeHub.checkOrigin()` (`RealtimeHub.ts:986`). Refus →
|
|
423
423
|
fermeture `4003`. La politique vient de la configuration ; sans politique, tout passe.
|
|
424
424
|
2. **Résolution de l'identité** — `RealtimeHub.resolveAuthenticator()`
|
|
425
|
-
(`RealtimeHub.ts:
|
|
425
|
+
(`RealtimeHub.ts:961`) parcourt les authentificateurs enregistrés : **le premier motif
|
|
426
426
|
qui correspond capture**. Aucun ne correspond ? Le jeton anonyme gelé est posé
|
|
427
427
|
(`ANONYMOUS_REALTIME_TOKEN`) — la lecture d'identité ne rend donc jamais `null`. Un
|
|
428
428
|
échec d'authentification ferme en `4001`.
|
|
429
429
|
3. **Création du peer et du transport**, puis association `peer → jeton`
|
|
430
|
-
(`RealtimeHub.setTokenForPeer()`, `RealtimeHub.ts:
|
|
430
|
+
(`RealtimeHub.setTokenForPeer()`, `RealtimeHub.ts:1018`), stockée dans une `WeakMap` : le
|
|
431
431
|
jeton disparaît avec le peer, sans fuite.
|
|
432
432
|
4. **Enregistrement des actions** — celles des décorateurs `@RealtimeAction`, puis celles
|
|
433
433
|
de la surcharge `realtimeActions()`, qui gagne en cas de conflit. Le pont API
|
|
434
|
-
`api.request` n'est ajouté que si `realtimeApiRequest()` (`RealtimeController.ts:
|
|
434
|
+
`api.request` n'est ajouté que si `realtimeApiRequest()` (`RealtimeController.ts:276`)
|
|
435
435
|
rend `true`.
|
|
436
436
|
5. **Inscription aux registres** : sonde de connexion, révocation périodique si le jeton
|
|
437
437
|
est révocable, préfixes de canaux broadcast, canaux entrants.
|
|
@@ -439,7 +439,7 @@ Les étapes, dans l'ordre exact du code :
|
|
|
439
439
|
l'identité résolue (type, authentifié ou non, rôles, portées). Le client sait **qui il
|
|
440
440
|
est** sans appeler la moindre route.
|
|
441
441
|
|
|
442
|
-
À la fermeture, un unique `onFinish` (`RealtimeController.ts:
|
|
442
|
+
À la fermeture, un unique `onFinish` (`RealtimeController.ts:715`) fait le ménage complet :
|
|
443
443
|
désabonnement de chaque canal tenu, retrait des deux registres, `fireClose()` du transport,
|
|
444
444
|
`dispose()` du peer. C'est ce qui garantit qu'aucun minuteur ni écouteur ne survit à une
|
|
445
445
|
déconnexion.
|
|
@@ -459,13 +459,13 @@ déconnexion.
|
|
|
459
459
|
La seconde mérite une explication. Le verrou de frame est synchrone et lit une identité
|
|
460
460
|
figée au handshake : il ne peut donc pas voir une session qui meurt en cours de route (une
|
|
461
461
|
déconnexion HTTP, par exemple). Un minuteur — démarré au premier inscrit, arrêté dès que le
|
|
462
|
-
registre se vide (`RealtimeHub.registerRevocable()`, `RealtimeHub.ts:
|
|
462
|
+
registre se vide (`RealtimeHub.registerRevocable()`, `RealtimeHub.ts:794`) — relit
|
|
463
463
|
périodiquement ces identités et coupe les sockets orphelines. Seules les identités
|
|
464
464
|
**révocables** y entrent : un visiteur anonyme ne coûte rien.
|
|
465
465
|
|
|
466
466
|
## Le hub — canaux partagés et fan-out
|
|
467
467
|
|
|
468
|
-
Le hub est un singleton par process (`getRealtimeHub()`, `RealtimeHub.ts:
|
|
468
|
+
Le hub est un singleton par process (`getRealtimeHub()`, `RealtimeHub.ts:1451`). Il ne
|
|
469
469
|
dépend de rien : ce sont les fabriques fournies par les contrôleurs qui portent les
|
|
470
470
|
dépendances.
|
|
471
471
|
|
|
@@ -478,7 +478,7 @@ dépendances.
|
|
|
478
478
|
paquet du producteur atteigne bien ce premier abonné.
|
|
479
479
|
3. La fabrique du contrôleur rend `null` (canal inconnu de lui) ? Dernier recours : le
|
|
480
480
|
registre des **canaux système** (`RealtimeHub.registerSystemChannel()`,
|
|
481
|
-
`RealtimeHub.ts:
|
|
481
|
+
`RealtimeHub.ts:1297`), qu'un module bas niveau alimente au démarrage. Toujours `null` →
|
|
482
482
|
l'abonnement est refusé et rien n'est alloué.
|
|
483
483
|
|
|
484
484
|
Au dernier désabonnement, `RealtimeHub.unsubscribe()` (`RealtimeHub.ts:576`) appelle le
|
|
@@ -547,12 +547,12 @@ coûte chaque abonné supplémentaire.
|
|
|
547
547
|
|
|
548
548
|
### Le contrat, en cinq méthodes
|
|
549
549
|
|
|
550
|
-
`IBackplane` (`IBackplane.ts:
|
|
550
|
+
`IBackplane` (`IBackplane.ts:107`) est délibérément minuscule : le backplane ne connaît ni
|
|
551
551
|
les abonnés ni les canaux logiques. Tout l'état vit dans le hub.
|
|
552
552
|
|
|
553
553
|
| Membre | Rôle |
|
|
554
554
|
| ------------------------------------------------- | --------------------------------------------------------------- |
|
|
555
|
-
| `originId` (`IBackplane.ts:
|
|
555
|
+
| `originId` (`IBackplane.ts:109`) | l'étiquette de CE process, lue par l'anti-écho |
|
|
556
556
|
| `start()` (`IBackplane.ts:115`) | ouvrir le transport. Idempotent, synchrone ou asynchrone |
|
|
557
557
|
| `publish(channel, payload)` (`IBackplane.ts:121`) | propager aux **autres** pairs. Ne refait pas le fan-out local |
|
|
558
558
|
| `onMessage(handler)` (`IBackplane.ts:127`) | recevoir des pairs. Un seul gestionnaire à la fois |
|
|
@@ -743,19 +743,19 @@ Deux propriétés architecturales méritent d'être notées ici, parce qu'elles
|
|
|
743
743
|
choix de conception visibles partout dans le module :
|
|
744
744
|
|
|
745
745
|
**Coût nul quand la sécurité est absente.** Le verrou de frame n'est branché sur le peer que
|
|
746
|
-
si une politique existe (`RealtimeHub.hasFrameAuthorizer()`, `RealtimeHub.ts:
|
|
746
|
+
si une politique existe (`RealtimeHub.hasFrameAuthorizer()`, `RealtimeHub.ts:1058`, testé une
|
|
747
747
|
fois au handshake). Sans module de sécurité, `beforeDispatch` reste indéfini et le chemin
|
|
748
748
|
chaud ne paie **rien** du tout.
|
|
749
749
|
|
|
750
750
|
**Échec bruyant plutôt que faux sentiment de sécurité.** Si des canaux déclarent une
|
|
751
751
|
politique sans qu'aucun décideur ne soit câblé, `hasUnenforcedChannelPolicies()`
|
|
752
|
-
(`RealtimeHub.ts:
|
|
752
|
+
(`RealtimeHub.ts:1117`) le détecte et un avertissement est émis une fois par process
|
|
753
753
|
(`RealtimeController.ts:487`). Un canal qui **se croit** gardé alors qu'il est ouvert est
|
|
754
754
|
bien plus dangereux qu'un canal ouvertement public.
|
|
755
755
|
|
|
756
756
|
## 📡 Observabilité — la sonde et Studio
|
|
757
757
|
|
|
758
|
-
`RealtimeHub.probe()` (`RealtimeHub.ts:
|
|
758
|
+
`RealtimeHub.probe()` (`RealtimeHub.ts:865`) rend un instantané en **lecture pure** : aucune
|
|
759
759
|
allocation sur le chemin chaud, jamais d'exception. Les compteurs sont des primitives
|
|
760
760
|
incrémentées en O(1) ; ils sont **monotones**, ce qui laisse au lecteur le soin de dériver
|
|
761
761
|
un débit.
|
|
@@ -808,7 +808,7 @@ révocables, authentificateurs, politiques de canal, association peer→jeton :
|
|
|
808
808
|
`null`. Un process sans abonné n'alloue **rien** (`RealtimeHub.ts:141` et suivants).
|
|
809
809
|
|
|
810
810
|
**Aucun minuteur au repos.** Le tick de révocation démarre au premier inscrit et s'arrête
|
|
811
|
-
dès que le registre se vide — `RealtimeHub.unregisterRevocable()` (`RealtimeHub.ts:
|
|
811
|
+
dès que le registre se vide — `RealtimeHub.unregisterRevocable()` (`RealtimeHub.ts:809`).
|
|
812
812
|
Les minuteurs sont détachés de la boucle d'événements : ils ne retiennent jamais l'arrêt du
|
|
813
813
|
process.
|
|
814
814
|
|
package/docs/configuration.md
CHANGED
|
@@ -283,7 +283,7 @@ valeurs sont des constantes du code, pas des clés.
|
|
|
283
283
|
|
|
284
284
|
Poser `false` charge le module sans rien activer. Concrètement : pas d'API d'administration
|
|
285
285
|
enregistrée, pas de backplane câblé, pas de sonde de pod
|
|
286
|
-
(`Realtime.onKernelBoot()`, `src/packages/@nodefony/realtime/index.ts:
|
|
286
|
+
(`Realtime.onKernelBoot()`, `src/packages/@nodefony/realtime/index.ts:239`), et le service devient
|
|
287
287
|
inerte (`RealtimeService.init()`, `RealtimeService.ts:77`).
|
|
288
288
|
|
|
289
289
|
> [!WARNING]
|
|
@@ -344,7 +344,7 @@ canal exotique.
|
|
|
344
344
|
Ne concerne, là aussi, que les transports **partagés**. Un bus pub/sub ne dit pas _qui_ a publié :
|
|
345
345
|
sans secret, tout ce qui atterrit sur le canal — venu d'un pod ou d'un tiers qui sait écrire dans ce
|
|
346
346
|
Redis — est traité comme un message légitime. Poser un secret fait porter à chaque message un sceau
|
|
347
|
-
HMAC-SHA256, vérifié à l'arrivée (`openBackplaneEnvelope()`, `envelope.ts:
|
|
347
|
+
HMAC-SHA256, vérifié à l'arrivée (`openBackplaneEnvelope()`, `envelope.ts:112`) : un message non
|
|
348
348
|
scellé, altéré ou repointé vers un autre canal est **ignoré**, sans exception possible.
|
|
349
349
|
|
|
350
350
|
Le secret doit être **identique sur tous les pods** — c'est lui qui les reconnaît entre eux. En
|
|
@@ -378,7 +378,7 @@ d'éteindre une sonde sur un pod en incident sans redéployer une configuration.
|
|
|
378
378
|
### `slowConsumer.bytes` — un compteur, pas un frein
|
|
379
379
|
|
|
380
380
|
Seuil de `bufferedAmount` — les octets en attente d'envoi sur une socket — au-delà duquel la sonde
|
|
381
|
-
compte la connexion comme « lente » (`RealtimeHub.probe()`, `RealtimeHub.ts:
|
|
381
|
+
compte la connexion comme « lente » (`RealtimeHub.probe()`, `RealtimeHub.ts:865`).
|
|
382
382
|
|
|
383
383
|
> [!IMPORTANT]
|
|
384
384
|
> **Cette clé observe, elle n'agit pas.** Elle ne change que le compteur `slowConsumers` de la
|
|
@@ -434,7 +434,7 @@ des canaux d'état, où seul le dernier instantané compte.
|
|
|
434
434
|
Chaque canal ouvert coûte un producteur côté hub, un minuteur le plus souvent, et une entrée de table
|
|
435
435
|
côté connexion. Sans borne, **un seul** client peut s'abonner jusqu'à épuiser la mémoire du
|
|
436
436
|
processus. Le plafond est vérifié à chaque abonnement (`RealtimeController.startChannel()`,
|
|
437
|
-
`RealtimeController.ts:
|
|
437
|
+
`RealtimeController.ts:815`).
|
|
438
438
|
|
|
439
439
|
Quatre propriétés qui décident du bon réglage :
|
|
440
440
|
|
|
@@ -457,7 +457,7 @@ les clients — un tableau de bord interne qui compose des dizaines de flux, par
|
|
|
457
457
|
### `csrf.checkOrigin` — qui a le droit d'ouvrir la socket
|
|
458
458
|
|
|
459
459
|
Trois clés qui forment une seule politique, appliquée à l'ouverture de la connexion
|
|
460
|
-
(`RealtimeHub.checkOrigin()`, `RealtimeHub.ts:
|
|
460
|
+
(`RealtimeHub.checkOrigin()`, `RealtimeHub.ts:986`). Une origine refusée ferme la socket avec le code
|
|
461
461
|
`4003`.
|
|
462
462
|
|
|
463
463
|
Le défaut est **désactivé**. C'est le seul défaut du module qui n'est pas le réglage recommandé :
|
|
@@ -705,7 +705,7 @@ Deux fonctions publiques, utiles surtout aux tests et aux outils.
|
|
|
705
705
|
|
|
706
706
|
`defineRealtimeConfig(config?, options?)` (`defineModuleConfig.ts:35`) analyse, applique la variable
|
|
707
707
|
de driver et **gèle**. Le module l'appelle lui-même à l'enregistrement
|
|
708
|
-
(`Realtime.onKernelRegister()`, `src/packages/@nodefony/realtime/index.ts:
|
|
708
|
+
(`Realtime.onKernelRegister()`, `src/packages/@nodefony/realtime/index.ts:213`) : tu n'as pas à
|
|
709
709
|
l'invoquer dans une application. En revanche, c'est l'outil qui permet de vérifier une configuration
|
|
710
710
|
sans démarrer un serveur.
|
|
711
711
|
|
package/docs/cookbook-chat.md
CHANGED
|
@@ -134,7 +134,7 @@ le moment venu.
|
|
|
134
134
|
|
|
135
135
|
```bash
|
|
136
136
|
# 1. Créer l'application (profil complet : http, framework, realtime, sécurité, frontend)
|
|
137
|
-
npx nodefony create app mon-chat --complete
|
|
137
|
+
npx nodefony create app mon-chat --preset complete
|
|
138
138
|
cd mon-chat
|
|
139
139
|
|
|
140
140
|
# 2. Créer le module qui portera le salon
|
|
@@ -1598,22 +1598,22 @@ Pour qui veut lire l'implémentation derrière chaque étape :
|
|
|
1598
1598
|
| Ce que tu as utilisé | Où c'est écrit |
|
|
1599
1599
|
| --- | --- |
|
|
1600
1600
|
| Les trois décorateurs | `RealtimeAction()` (`realtimeDecorators.ts:101`), `RealtimeChannel()` (`realtimeDecorators.ts:192`), `RealtimeInbound()` (`realtimeDecorators.ts:231`) |
|
|
1601
|
-
| La porte WebSocket | `RealtimeController.handleRealtime()` (`RealtimeController.ts:
|
|
1602
|
-
| Le pont HTTP ⇄ WebSocket | `RealtimeController.realtimeApiRequest()` (`RealtimeController.ts:
|
|
1603
|
-
| L'abonnement et son cycle de vie | `RealtimeHub.subscribe()` (`RealtimeHub.ts:462`), `RealtimeController.startChannel()` (`RealtimeController.ts:
|
|
1601
|
+
| La porte WebSocket | `RealtimeController.handleRealtime()` (`RealtimeController.ts:299`) |
|
|
1602
|
+
| Le pont HTTP ⇄ WebSocket | `RealtimeController.realtimeApiRequest()` (`RealtimeController.ts:276`) |
|
|
1603
|
+
| L'abonnement et son cycle de vie | `RealtimeHub.subscribe()` (`RealtimeHub.ts:462`), `RealtimeController.startChannel()` (`RealtimeController.ts:815`) |
|
|
1604
1604
|
| La publication et le fan-out | `RealtimeHub.publish()` (`RealtimeHub.ts:604`), réinjection locale `RealtimeHub.publishLocal()` (`RealtimeHub.ts:623`) |
|
|
1605
1605
|
| Les canaux diffusables | `RealtimeBroadcast` (`realtimeDecorators.ts:342`), `RealtimeHub.markBroadcastChannel()` (`RealtimeHub.ts:668`) |
|
|
1606
1606
|
| Le branchement du backplane | `RealtimeHub.setBackplane()` (`RealtimeHub.ts:714`), registre `registerBackplaneDriver()` (`backplaneRegistry.ts:55`), cloison `resolveBackplaneOriginId()` (`originId.ts:24`) |
|
|
1607
|
-
| Les politiques de canal | `IChannelPolicy` (`IChannelPolicy.ts:20`), `RealtimeHub.registerChannelPolicy()` (`RealtimeHub.ts:
|
|
1607
|
+
| Les politiques de canal | `IChannelPolicy` (`IChannelPolicy.ts:20`), `RealtimeHub.registerChannelPolicy()` (`RealtimeHub.ts:1143`), garde-fou `RealtimeHub.hasUnenforcedChannelPolicies()` (`RealtimeHub.ts:1117`) |
|
|
1608
1608
|
| Le branchement automatique du firewall | `Firewall.#wireRealtime()` (`firewall.ts:279`) |
|
|
1609
|
-
| Les refus | `IRealtimeDenied` (`RealtimeEventMap.ts:269`), erreur `unauthorized` (`JsonRpcPeer.ts:
|
|
1610
|
-
| La révocation en cours de session | `RealtimeHub.revalidateRevocable()` (`RealtimeHub.ts:
|
|
1609
|
+
| Les refus | `IRealtimeDenied` (`RealtimeEventMap.ts:269`), erreur `unauthorized` (`JsonRpcPeer.ts:417`), fermetures `origin not allowed` (`RealtimeController.ts:327`) et `unauthorized` (`RealtimeController.ts:397`) |
|
|
1610
|
+
| La révocation en cours de session | `RealtimeHub.revalidateRevocable()` (`RealtimeHub.ts:826`) |
|
|
1611
1611
|
| Le consommateur lent | seuil `SLOW_CONSUMER_BYTES` (`RealtimeHub.ts:56`), fermeture `slow consumer` (`RealtimeHub.ts:63`) |
|
|
1612
|
-
| Le plafond de canaux | `RealtimeHub.maxChannelsPerConnection` (`RealtimeHub.ts:
|
|
1612
|
+
| Le plafond de canaux | `RealtimeHub.maxChannelsPerConnection` (`RealtimeHub.ts:1000`) |
|
|
1613
1613
|
| L'accueil et l'identité | `IRealtimeWelcome` (`RealtimeEventMap.ts:231`) |
|
|
1614
|
-
| La sonde | `RealtimeHub.probe()` (`RealtimeHub.ts:
|
|
1614
|
+
| La sonde | `RealtimeHub.probe()` (`RealtimeHub.ts:865`), `IRealtimeProbe` (`IRealtimeProbe.ts:61`), `IRealtimeChannelStat` (`IRealtimeProbe.ts:47`) |
|
|
1615
1615
|
| Le point de mesure d'administration | `createRealtimeAdminApi()` (`RealtimeAdminApi.ts:98`), `buildRealtimeHealth()` (`RealtimeAdminApi.ts:74`), `buildOwnHealth()` (`RealtimeAdminApi.ts:52`) |
|
|
1616
1616
|
| Le service injectable | `RealtimeService.publish()` (`RealtimeService.ts:161`), `RealtimeService.probe()` (`RealtimeService.ts:213`), `RealtimeService.markBroadcastChannel()` (`RealtimeService.ts:222`) |
|
|
1617
|
-
| Les hooks React | `useNodefony()` (`client/react/index.ts:145`), `useNodefonyState()` (`client/react/index.ts:163`), `useNodefonyIdentity()` (`client/react/index.ts:182`), `useNodefonyChannel()` (`client/react/index.ts:198`), `useNodefonyChannelData()` (`client/react/index.ts:221`), `useNodefonyAdaptiveChannel()` (`client/react/index.ts:
|
|
1617
|
+
| Les hooks React | `useNodefony()` (`client/react/index.ts:145`), `useNodefonyState()` (`client/react/index.ts:163`), `useNodefonyIdentity()` (`client/react/index.ts:182`), `useNodefonyChannel()` (`client/react/index.ts:198`), `useNodefonyChannelData()` (`client/react/index.ts:221`), `useNodefonyAdaptiveChannel()` (`client/react/index.ts:255`) |
|
|
1618
1618
|
| Le client navigateur | `RealtimeClient.connect()` (`RealtimeClient.ts:389`), `RealtimeClient.subscribe()` (`RealtimeClient.ts:530`), `RealtimeClient.request()` (`RealtimeClient.ts:729`), `RealtimeClient.onDenied()` (`RealtimeClient.ts:471`) |
|
|
1619
|
-
| L'upload | `UploadedFile` (`routerDecorators.ts:
|
|
1619
|
+
| L'upload | `UploadedFile` (`routerDecorators.ts:1286`), `IUploadedFile` (`IUpload.ts:49`), `queryFile` (`Request.ts:200`) |
|
package/docs/index.md
CHANGED
|
@@ -325,7 +325,7 @@ vue de tous les workers d'un pod.
|
|
|
325
325
|
|
|
326
326
|
Le data plane admin expose `/nodefony/realtime/api/health` — canaux et abonnés, compteurs de
|
|
327
327
|
diffusion, connexions, octets et frames, pression d'écriture. C'est la même donnée que celle rendue à
|
|
328
|
-
l'écran, servie par la sonde du hub (`RealtimeHub.probe()`, `RealtimeHub.ts:
|
|
328
|
+
l'écran, servie par la sonde du hub (`RealtimeHub.probe()`, `RealtimeHub.ts:865`).
|
|
329
329
|
|
|
330
330
|
## 🧪 Tests & couverture
|
|
331
331
|
|
|
@@ -367,6 +367,3 @@ l'infrastructure sous la main.
|
|
|
367
367
|
- 🏛️ **Transverse** : [vue d'ensemble du framework](../../../../../docs/architecture/vue-ensemble.md) ·
|
|
368
368
|
[le pipeline d'une requête](../../../../../docs/architecture/pipeline-requete.md)
|
|
369
369
|
- 📖 [Lexique général](../../../../../docs/lexique.md) du framework.
|
|
370
|
-
</content>
|
|
371
|
-
|
|
372
|
-
</invoke>
|
package/docs/observabilite.md
CHANGED
|
@@ -96,7 +96,7 @@ temps de réponse — elle mesure **ce qui n'est pas encore parti**.
|
|
|
96
96
|
Trois partis pris, tous lisibles dans le code.
|
|
97
97
|
|
|
98
98
|
**1. La sonde est une lecture pure, jamais un collecteur.** `RealtimeHub.probe()`
|
|
99
|
-
(`RealtimeHub.ts:
|
|
99
|
+
(`RealtimeHub.ts:865`) ne fait qu'additionner des primitives déjà tenues à jour par le chemin chaud.
|
|
100
100
|
Aucune entrée-sortie, aucune exception possible, aucun état de lecture conservé. Appeler la sonde
|
|
101
101
|
mille fois par seconde ne changerait rien à ce que le hub fait par ailleurs.
|
|
102
102
|
|
|
@@ -265,7 +265,7 @@ Le contrat est `IRealtimeProbe` (`IRealtimeProbe.ts:61`). Quatre familles, un di
|
|
|
265
265
|
| ---------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
|
266
266
|
| `publishTotal` | Appels à `publish`. **Ce que le serveur a voulu envoyer.** |
|
|
267
267
|
| `fanoutTotal` | Livraisons effectives (`publish` × abonnés). **Ce que ça a réellement coûté.** |
|
|
268
|
-
| `inboundTotal` | Frames poussées par les clients sur les canaux entrants, via `RealtimeHub.recordInbound()` (`RealtimeHub.ts:
|
|
268
|
+
| `inboundTotal` | Frames poussées par les clients sur les canaux entrants, via `RealtimeHub.recordInbound()` (`RealtimeHub.ts:854`). |
|
|
269
269
|
| `ingressRejectedTotal` | Messages venus du **backplane** et jetés parce que leur canal n'est pas déclaré diffusable. |
|
|
270
270
|
|
|
271
271
|
Le rapport `fanoutTotal / publishTotal` est la mesure la plus parlante de la page : c'est la **taille
|
|
@@ -588,6 +588,3 @@ npm run coverage # rapport vitest — jamais de pourcentage figé dans cette p
|
|
|
588
588
|
- [Sécurité](securite.md) — qui a le droit de s'abonner à un canal de santé, et pourquoi ça compte.
|
|
589
589
|
- [Cookbook — un chat](cookbook-chat.md) — le patron appliqué de bout en bout sur une vraie fonctionnalité.
|
|
590
590
|
- [Vocabulaire](vocabulaire.md) — les douze mots de la socket.
|
|
591
|
-
</content>
|
|
592
|
-
|
|
593
|
-
</invoke>
|
package/docs/protocole.md
CHANGED
|
@@ -246,7 +246,7 @@ Sur le fil, dans l'inspecteur réseau du navigateur, exactement cinq frames :
|
|
|
246
246
|
```jsonc
|
|
247
247
|
// 1. serveur → client : l'accueil, aussitôt après le handshake
|
|
248
248
|
{"jsonrpc":"2.0","method":"realtime:welcome","params":{
|
|
249
|
-
"ts":1770000000000,"protocol":"jsonrpc-2.0",
|
|
249
|
+
"ts":1770000000000,"protocol":"jsonrpc-2.0","env":"development",
|
|
250
250
|
"channels":["chat:room-42"],"methods":["chat:history"],
|
|
251
251
|
"identity":{"type":"anonymous","authenticated":false,
|
|
252
252
|
"userIdentifier":"anonymous","roles":["ROLE_ANONYMOUS"],"scopes":[]}}}
|
|
@@ -264,9 +264,11 @@ Sur le fil, dans l'inspecteur réseau du navigateur, exactement cinq frames :
|
|
|
264
264
|
{"jsonrpc":"2.0","method":"chat:room-42","params":{"text":"salut"}}
|
|
265
265
|
```
|
|
266
266
|
|
|
267
|
-
L'accueil porte
|
|
268
|
-
(`IRealtimeWelcome`, `RealtimeEventMap.ts:231`)
|
|
269
|
-
(`
|
|
267
|
+
L'accueil porte cinq champs — `ts`, `protocol`, `channels`, `methods`, `identity`
|
|
268
|
+
(`IRealtimeWelcome`, `RealtimeEventMap.ts:231`) — plus un sixième, `env`, **uniquement hors
|
|
269
|
+
production** (`welcomeEnv()`, `welcomeEnv.ts:19`) : c'est lui que porte l'exemple ci-dessus, capturé
|
|
270
|
+
en développement. Émis par le contrôleur au handshake (`peer.notify("realtime:welcome")`,
|
|
271
|
+
`RealtimeController.ts:708`). Il n'y a pas de champ `version`.
|
|
270
272
|
|
|
271
273
|
## 🔌 Anatomie d'une frame, champ par champ
|
|
272
274
|
|
|
@@ -335,19 +337,19 @@ ouvertes à l'application. Colonne `id` : présent = requête (réponse due), ab
|
|
|
335
337
|
|
|
336
338
|
| Méthode | Direction | `id` ? | Rôle | Ancrage |
|
|
337
339
|
| ------------------ | ------------- | :-----: | --------------------------------------------------------------------- | --------------------------- |
|
|
338
|
-
| `subscribe` | client→server | non | « pousse-moi ce canal » — `params.channel` | `RealtimeController.ts:
|
|
339
|
-
| `unsubscribe` | client→server | non | « arrête » — dernier abonné, le producteur est libéré | `RealtimeController.ts:
|
|
340
|
-
| `ping` | client→server | non | Battement de cœur — **no-op serveur**, aucun pong | `RealtimeClient.ts:
|
|
341
|
-
| `<canal>` | server→client | non | Push d'un message : le **nom du canal est la `method`** | `RealtimeController.ts:
|
|
340
|
+
| `subscribe` | client→server | non | « pousse-moi ce canal » — `params.channel` | `RealtimeController.ts:522` |
|
|
341
|
+
| `unsubscribe` | client→server | non | « arrête » — dernier abonné, le producteur est libéré | `RealtimeController.ts:775` |
|
|
342
|
+
| `ping` | client→server | non | Battement de cœur — **no-op serveur**, aucun pong | `RealtimeClient.ts:882` |
|
|
343
|
+
| `<canal>` | server→client | non | Push d'un message : le **nom du canal est la `method`** | `RealtimeController.ts:972` |
|
|
342
344
|
| `<canal entrant>` | client→server | non | Le client pousse sur un canal déclaré entrant | `RealtimeController.ts:736` |
|
|
343
345
|
| `realtime:welcome` | server→client | non | L'accueil : 5 champs, dont l'identité résolue | `RealtimeController.ts:693` |
|
|
344
|
-
| `realtime:denied` | server→client | non | Rend OBSERVABLE le refus d'une notification | `RealtimeController.ts:
|
|
345
|
-
| `api.request` | client→server | **oui** | Pont API — rejoue une route HTTP sur la socket (désactivé par défaut) | `RealtimeController.ts:
|
|
346
|
+
| `realtime:denied` | server→client | non | Rend OBSERVABLE le refus d'une notification | `RealtimeController.ts:496` |
|
|
347
|
+
| `api.request` | client→server | **oui** | Pont API — rejoue une route HTTP sur la socket (désactivé par défaut) | `RealtimeController.ts:582` |
|
|
346
348
|
| `<action>` | client→server | **oui** | Toute action déclarée par `@RealtimeAction` | `realtimeDecorators.ts:101` |
|
|
347
349
|
|
|
348
350
|
> [!TIP]
|
|
349
351
|
> **L'accueil est ta carte du territoire.** `methods` et `channels` sont construits à partir de ce
|
|
350
|
-
> que l'endpoint expose réellement (`RealtimeController.ts:
|
|
352
|
+
> que l'endpoint expose réellement (`RealtimeController.ts:710`) : un client peut activer ou griser
|
|
351
353
|
> ses commandes sans rien coder en dur. Côté navigateur, ils se lisent en `socket.serverMethods` et
|
|
352
354
|
> `socket.serverChannels`.
|
|
353
355
|
|
|
@@ -357,12 +359,12 @@ Les quatre formes de frame circulent en permanence sous tes yeux — ce schéma
|
|
|
357
359
|
> `nodefony:kernel:ping` et `nodefony:kernel:gc` (`StudioRealtimeController.ts:114`) sont des exemples d'actions,
|
|
358
360
|
> **pas des méthodes du cœur temps réel** : elles sont déclarées par le contrôleur
|
|
359
361
|
> d'administration de `@nodefony/studio`. Un endpoint applicatif ne les expose pas. Le helper
|
|
360
|
-
> `RealtimeClient.ping()` (`RealtimeClient.ts:
|
|
362
|
+
> `RealtimeClient.ping()` (`RealtimeClient.ts:878`) mesure le RTT en les appelant — il suppose donc
|
|
361
363
|
> un endpoint qui les déclare, contrairement à la notification `ping` du battement de cœur, qui
|
|
362
364
|
> n'attend jamais de réponse.
|
|
363
365
|
|
|
364
366
|
`subscribe` et `unsubscribe` ne sont **pas** des actions enregistrées : elles sont traitées dans
|
|
365
|
-
`onRealtimeNotification()` (`RealtimeController.ts:
|
|
367
|
+
`onRealtimeNotification()` (`RealtimeController.ts:784`). Envoyées avec un `id`, elles seraient
|
|
366
368
|
classées « requête », ne trouveraient aucun handler et récolteraient un `-32601`.
|
|
367
369
|
|
|
368
370
|
## Une conversation type, de bout en bout
|
|
@@ -400,7 +402,7 @@ de `RpcError`.
|
|
|
400
402
|
| `-32603` | même méthode (`JsonRpcPeer.ts:528`) | le handler a levé une exception **ordinaire** | `internal error` — générique, rien d'autre |
|
|
401
403
|
| `-32001` | le refus du verrou de frame (`JsonRpcPeer.ts:400`) | `beforeDispatch` a dit non **sur une requête** | `unauthorized`, sans jamais dire pourquoi |
|
|
402
404
|
| `-32000` | défaut du constructeur de `RpcError` (`JsonRpcPeer.ts:74`) | le handler expose volontairement son refus | le message ET le `data` choisis par le handler |
|
|
403
|
-
| `-32602` | le pont API, via `RpcError` (`RealtimeController.ts:
|
|
405
|
+
| `-32602` | le pont API, via `RpcError` (`RealtimeController.ts:787`) | `api.request` appelé avec un `params.path` invalide | message explicite (l'appel est malformé) |
|
|
404
406
|
|
|
405
407
|
Et un échec qui n'est **pas** une frame : l'expiration. `startCall()` ne reçoit rien dans le délai
|
|
406
408
|
imparti, supprime l'entrée en attente et rejette localement avec `RPC timeout: <méthode>`
|
|
@@ -431,12 +433,12 @@ radicalement :
|
|
|
431
433
|
| La frame refusée est… | Ce qui part | Pourquoi |
|
|
432
434
|
| --------------------- | --------------------------------------------------------------- | ---------------------------------------------------- |
|
|
433
435
|
| une **requête** | `-32001 "unauthorized"` (`JsonRpcPeer.ts:413`) | Un `id` existe : il y a un canal de réponse |
|
|
434
|
-
| une **notification** | la notification `realtime:denied` (`RealtimeController.ts:
|
|
436
|
+
| une **notification** | la notification `realtime:denied` (`RealtimeController.ts:496`) | Aucun `id` : sans elle, le client se croirait abonné |
|
|
435
437
|
|
|
436
438
|
`IRealtimeDenied` (`RealtimeEventMap.ts:269`) porte `channel` et `reason` — plus un `detail` optionnel, posé hors production seulement — et le motif
|
|
437
439
|
est **générique**. Jamais « il te manque `ROLE_ADMIN` » : ce serait un oracle d'autorisation, un
|
|
438
440
|
attaquant y lirait la carte des droits. Deux motifs circulent : `forbidden` (le verrou a dit non) et
|
|
439
|
-
`limit` (le plafond de canaux de la connexion est atteint, `RealtimeController.ts:
|
|
441
|
+
`limit` (le plafond de canaux de la connexion est atteint, `RealtimeController.ts:811`). Côté client,
|
|
440
442
|
`onDenied()` (`RealtimeClient.ts:469`) branche un handler dessus.
|
|
441
443
|
|
|
442
444
|
> [!CAUTION]
|
|
@@ -474,7 +476,7 @@ décrits dans [la page sécurité](./securite.md).
|
|
|
474
476
|
| L'exception du serveur n'arrive jamais au client | Zero Trust : tout throw ordinaire devient `-32603` générique (`JsonRpcPeer.ts:528`) | Lever une `RpcError` pour exposer volontairement code et `data` |
|
|
475
477
|
| Une notification refusée disparaît sans trace côté client | Sans `id`, aucune réponse possible (`beforeDispatch`, `JsonRpcPeer.ts:151`) | Écouter `realtime:denied` via `onDenied()` (`RealtimeClient.ts:471`) |
|
|
476
478
|
| `nodefony:kernel:ping` répond `-32601` sur mon endpoint | L'action `nodefony:kernel:ping` est déclarée par `@nodefony/studio` (`StudioRealtimeController.ts:114`) | Déclarer la sienne, ou lire `serverMethods` avant d'appeler |
|
|
477
|
-
| Le battement de cœur ne renvoie aucun pong | La notification `ping` est un no-op serveur (`RealtimeController.ts:
|
|
479
|
+
| Le battement de cœur ne renvoie aucun pong | La notification `ping` est un no-op serveur (`RealtimeController.ts:791`) | Pour mesurer un RTT, utiliser une action RPC — `ping()` (`RealtimeClient.ts:878`) |
|
|
478
480
|
| Une réponse reçue est ignorée sans message | Corrélation sur `id` **numériques** seulement (`JsonRpcPeer.ts:537`) | Ne pas fabriquer soi-même de réponse à `id` chaîne |
|
|
479
481
|
| Les premières frames envoyées après `connect()` semblent perdues | Le transport n'est branché qu'une fois le handshake terminé | Attendre `realtime:welcome` — le client le fait déjà |
|
|
480
482
|
|
package/docs/securite.md
CHANGED
|
@@ -118,7 +118,7 @@ politiques.**
|
|
|
118
118
|
`security/nodefony/config/config.ts:109`). L'opt-out est explicite ; un opt-in aurait été
|
|
119
119
|
_fail-open_ (une zone qui oublie le flag laisserait le WS anonyme).
|
|
120
120
|
- Le verrou de frame consulte la **même** fonction de match que le HTTP, `Firewall.matchPath()`
|
|
121
|
-
(`firewall.ts:
|
|
121
|
+
(`firewall.ts:719`), et la **même** hiérarchie de rôles, `Firewall.hasRole()`
|
|
122
122
|
(`firewall.ts:482`). Invariant par construction : `api.request {path}` n'accorde jamais plus que
|
|
123
123
|
`GET {path}`.
|
|
124
124
|
- L'identité du handshake est celle du firewall HTTP : `FirewallRealtimeAuthenticator`
|
|
@@ -261,13 +261,13 @@ Nodefony pose **deux barrières successives**, à deux étages différents.
|
|
|
261
261
|
| Allowlist stricte | `@nodefony/realtime` | **inactif** (`enabled: false`) | configurable | `close 4003` |
|
|
262
262
|
|
|
263
263
|
**Barrière 1 — transport, active sans rien faire.** `HttpKernel.checkWebsocketOrigin()`
|
|
264
|
-
(`http-kernel.ts:
|
|
264
|
+
(`http-kernel.ts:621`) exige que l'`Origin` du handshake corresponde au `Host` servi, avec tolérance
|
|
265
265
|
loopback en développement et une allowlist optionnelle (`allowedOrigins`,
|
|
266
266
|
`http/nodefony/config/config.ts:552`) acceptant le hostname exact ou un wildcard à un label. Une
|
|
267
267
|
requête **sans** `Origin` est acceptée : un attaquant non-navigateur n'a pas besoin de CSWSH.
|
|
268
268
|
|
|
269
269
|
**Barrière 2 — module realtime, opt-in et plus stricte.** `RealtimeHub.checkOrigin()`
|
|
270
|
-
(`RealtimeHub.ts:
|
|
270
|
+
(`RealtimeHub.ts:986`) consulte une garde compilée une fois au boot par `buildOriginGuard()`
|
|
271
271
|
(`RealtimeService.ts:313`) depuis `checkOriginSchema` (`realtime/nodefony/config/config.ts:162`).
|
|
272
272
|
Trois différences comptent :
|
|
273
273
|
|
|
@@ -292,18 +292,18 @@ n'est jamais traitée.
|
|
|
292
292
|
|
|
293
293
|
### Le pipeline exact
|
|
294
294
|
|
|
295
|
-
`RealtimeController.onHandshake()` (`RealtimeController.ts:
|
|
295
|
+
`RealtimeController.onHandshake()` (`RealtimeController.ts:374`) exécute, une fois par connexion :
|
|
296
296
|
|
|
297
297
|
1. Construction d'un DTO neutre `IRealtimeHandshake` par `buildHandshakeFromContext()`
|
|
298
|
-
(`RealtimeController.ts:
|
|
298
|
+
(`RealtimeController.ts:1198`) — headers, cookies aplatis, url, origin, sous-protocoles. Aucune
|
|
299
299
|
dépendance à `@nodefony/security` dans le contrat.
|
|
300
300
|
2. Contrôle d'origine (verrou 1).
|
|
301
|
-
3. Résolution de l'authenticator par `RealtimeHub.resolveAuthenticator()` (`RealtimeHub.ts:
|
|
301
|
+
3. Résolution de l'authenticator par `RealtimeHub.resolveAuthenticator()` (`RealtimeHub.ts:961`) :
|
|
302
302
|
les matchers sont testés dans l'ordre d'enregistrement, **le premier qui matche capture**.
|
|
303
303
|
4. `authenticator.authenticate(handshake)` — **async autorisé** (on est en cold path, une fois par
|
|
304
304
|
connexion : lire un store est acceptable ici, jamais par frame).
|
|
305
305
|
5. Pose du token sur la WeakMap `peer → token` via `RealtimeHub.setTokenForPeer()`
|
|
306
|
-
(`RealtimeHub.ts:
|
|
306
|
+
(`RealtimeHub.ts:1018`), **avant** l'envoi du `welcome` : le lookup est garanti dès la première
|
|
307
307
|
frame.
|
|
308
308
|
|
|
309
309
|
Un `throw` de `authenticate()` ferme la socket en `4001` « unauthorized », après un log `WARNING`
|
|
@@ -312,8 +312,8 @@ d'audit défectueux ne peut pas empêcher la fermeture.
|
|
|
312
312
|
|
|
313
313
|
### Les matchers — quelle porte, quel vigile
|
|
314
314
|
|
|
315
|
-
`RealtimeHub.useAuthenticator()` (`RealtimeHub.ts:
|
|
316
|
-
`compileMatcher()` (`RealtimeHub.ts:
|
|
315
|
+
`RealtimeHub.useAuthenticator()` (`RealtimeHub.ts:943`) associe un sélecteur à une stratégie.
|
|
316
|
+
`compileMatcher()` (`RealtimeHub.ts:1418`) le compile une fois :
|
|
317
317
|
|
|
318
318
|
- `pattern` chaîne → RegExp **préfixe ancrée**, méta-caractères échappés par `RegExp.escape`.
|
|
319
319
|
`"/admin/"` matche `/admin/` et `/admin/foo`, littéralement.
|
|
@@ -321,7 +321,7 @@ d'audit défectueux ne peut pas empêcher la fermeture.
|
|
|
321
321
|
- `host` optionnel → comparaison **stricte** (insensible à la casse) sur l'en-tête `Host`, sans
|
|
322
322
|
wildcard.
|
|
323
323
|
- Le match porte sur le **path**, query comprise, jamais sur l'URL absolue : `handshakePath()`
|
|
324
|
-
(`RealtimeController.ts:
|
|
324
|
+
(`RealtimeController.ts:1250`) extrait `pathname + search` du `WebsocketContext.url`, qui est
|
|
325
325
|
absolu. Sans cette extraction, un matcher `^/nodefony/…` ne se déclencherait jamais.
|
|
326
326
|
|
|
327
327
|
`@nodefony/security` enregistre ces matchers automatiquement dans `Firewall.#wireRealtime()`
|
|
@@ -330,7 +330,7 @@ identité d'instance et une instance partagée n'enregistrerait que le premier m
|
|
|
330
330
|
|
|
331
331
|
### Zero Trust — il y a toujours un token
|
|
332
332
|
|
|
333
|
-
`RealtimeHub.getTokenForPeer()` (`RealtimeHub.ts:
|
|
333
|
+
`RealtimeHub.getTokenForPeer()` (`RealtimeHub.ts:1030`) ne renvoie **jamais** `null` : à défaut de
|
|
334
334
|
token posé, c'est `ANONYMOUS_REALTIME_TOKEN` (`AnonymousRealtimeToken.ts:18`), singleton gelé,
|
|
335
335
|
`isAuthenticated() === false`, `roles: ["ROLE_ANONYMOUS"]`. Le code consommateur (verrou, audit)
|
|
336
336
|
n'a jamais à écrire « et s'il n'y a pas de token ? ».
|
|
@@ -451,7 +451,7 @@ peut **resserrer**, jamais desserrer.
|
|
|
451
451
|
|
|
452
452
|
| Cible | Politique par défaut |
|
|
453
453
|
| ------------------------------------------ | -------------------------------------------------------------- |
|
|
454
|
-
| `nodefony:audit` | authentifié + `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts:
|
|
454
|
+
| `nodefony:audit` | authentifié + `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts:120`) |
|
|
455
455
|
| tout le reste de `nodefony:` | authentifié + `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts:89`) |
|
|
456
456
|
| tout canal contenant `:health` ou `:stats` | authentifié + `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts:253`) |
|
|
457
457
|
| tout le reste | libre, sauf policy déclarée |
|
|
@@ -472,7 +472,7 @@ Trois durcissements méritent d'être connus :
|
|
|
472
472
|
donc re-cibler `nodefony:syslog` sur `ROLE_SECURITY_AUDITOR` ; on ne peut pas l'ouvrir à l'anonyme.
|
|
473
473
|
|
|
474
474
|
Le canal du journal d'audit (`nodefony:audit`) est enregistré comme **canal système** sur le hub
|
|
475
|
-
(`RealtimeHub.registerSystemChannel()`, `RealtimeHub.ts:
|
|
475
|
+
(`RealtimeHub.registerSystemChannel()`, `RealtimeHub.ts:1297`) : il devient servable par n'importe
|
|
476
476
|
quel endpoint realtime, sans qu'aucun controller ne le connaisse — et il est gardé par sa règle
|
|
477
477
|
dédiée. Son enregistrement est **couplé** à la pose du verrou (même condition), donc il n'existe
|
|
478
478
|
jamais de canal d'audit non gardé.
|
|
@@ -536,15 +536,15 @@ C'est exactement le test de `Firewall.#wireRealtime()` (`firewall.ts:279`) : san
|
|
|
536
536
|
évaluée — ni métier, ni système. `nodefony:syslog` redevient un canal ordinaire.
|
|
537
537
|
|
|
538
538
|
Deuxième subtilité : `beforeDispatch` n'est branché sur une connexion que si le verrou est **déjà**
|
|
539
|
-
posé au moment de son handshake (`RealtimeController.ts:
|
|
540
|
-
`RealtimeHub.hasFrameAuthorizer()` — `RealtimeHub.ts:
|
|
539
|
+
posé au moment de son handshake (`RealtimeController.ts:504`, via
|
|
540
|
+
`RealtimeHub.hasFrameAuthorizer()` — `RealtimeHub.ts:1058`). Choix de performance délibéré (un hub
|
|
541
541
|
non sécurisé garde un coût nul par frame), mais avec une conséquence : **une connexion ouverte avant
|
|
542
542
|
la pose du verrou n'est jamais gardée**, et ce jusqu'à sa fermeture. En fonctionnement normal le
|
|
543
543
|
firewall se construit au boot, avant tout trafic ; le cas ne se présente qu'en appelant
|
|
544
544
|
`setFrameAuthorizer()` à chaud.
|
|
545
545
|
|
|
546
546
|
**Le refus de dégrader en silence.** Quand des policies sont déclarées sans décideur câblé,
|
|
547
|
-
`RealtimeHub.hasUnenforcedChannelPolicies()` (`RealtimeHub.ts:
|
|
547
|
+
`RealtimeHub.hasUnenforcedChannelPolicies()` (`RealtimeHub.ts:1117`) renvoie `true` et le controller
|
|
548
548
|
émet un WARNING explicite, une seule fois par process (`RealtimeController.ts:558`) :
|
|
549
549
|
|
|
550
550
|
```text
|
|
@@ -600,16 +600,16 @@ Nodefony ferme l'écart par deux mécanismes de granularité différente.
|
|
|
600
600
|
|
|
601
601
|
| Surface | Re-validation | Fenêtre d'exposition | Où |
|
|
602
602
|
| --------------------------- | -------------------- | -------------------- | --------------------------------------------------------------------- |
|
|
603
|
-
| `api.request` (data plane) | **à chaque frame** | nulle | `RealtimeController.invokeApiRequest()` (`RealtimeController.ts:
|
|
604
|
-
| `subscribe` / flux de canal | **périodique**, 30 s | ≤ 30 s | `RealtimeHub.revalidateRevocable()` (`RealtimeHub.ts:
|
|
603
|
+
| `api.request` (data plane) | **à chaque frame** | nulle | `RealtimeController.invokeApiRequest()` (`RealtimeController.ts:949`) |
|
|
604
|
+
| `subscribe` / flux de canal | **périodique**, 30 s | ≤ 30 s | `RealtimeHub.revalidateRevocable()` (`RealtimeHub.ts:826`) |
|
|
605
605
|
|
|
606
606
|
**Sur `api.request`**, `token.isValid()` est appelé avant l'exécution de l'action ; identité périmée
|
|
607
607
|
ou changée → `-32000` avec `status: 401`, et le client bascule sur un `fetch` HTTP porteur du cookie
|
|
608
608
|
courant. Une erreur de re-validation vaut refus (fail-closed).
|
|
609
609
|
|
|
610
610
|
**Sur les canaux**, le hub n'inscrit au registre de révocation que les connexions dont le token porte
|
|
611
|
-
`isValid` (`RealtimeController.ts:
|
|
612
|
-
`RealtimeHub.registerRevocable()` (`RealtimeHub.ts:
|
|
611
|
+
`isValid` (`RealtimeController.ts:659`) — anonymes et JWT n'y entrent jamais, coût nul.
|
|
612
|
+
`RealtimeHub.registerRevocable()` (`RealtimeHub.ts:794`) démarre un `setInterval` `unref` au premier
|
|
613
613
|
inscrit et l'arrête dès que le registre se vide : zéro timer au repos. Période :
|
|
614
614
|
`REVOCATION_REVALIDATE_MS` (`RealtimeHub.ts:111`), 30 s, alignée sur le heartbeat WS.
|
|
615
615
|
|
|
@@ -652,7 +652,7 @@ pas** borné.
|
|
|
652
652
|
Chaque canal ouvert coûte un provider, un ticker et une entrée de Map. Sans borne, une connexion
|
|
653
653
|
peut abonner jusqu'à l'OOM — un déni de service mémoire déclenché par **un seul** client.
|
|
654
654
|
|
|
655
|
-
`RealtimeController.startChannel()` (`RealtimeController.ts:
|
|
655
|
+
`RealtimeController.startChannel()` (`RealtimeController.ts:815`) refuse au-delà de
|
|
656
656
|
`limits.maxChannelsPerConnection` (`realtime/nodefony/config/config.ts:142`), défaut **256**,
|
|
657
657
|
`null` pour illimité. Points prouvés par `realtimeChannelCap.attack.test.ts` :
|
|
658
658
|
|
|
@@ -693,7 +693,7 @@ au défaut de la librairie `ws`.
|
|
|
693
693
|
| Limite de **fréquence** des frames entrantes | Un client authentifié peut inonder le peer ; seul le coût CPU le freine |
|
|
694
694
|
| Limite de **connexions par IP ou par utilisateur** | Rien n'empêche N sockets par client au niveau du module |
|
|
695
695
|
| Plafond **global** de canaux du process | Le plafond est par connexion ; M connexions × 256 canaux reste possible |
|
|
696
|
-
| Seuils de back-pressure **configurables** | `slowConsumer.bytes` ne pilote que le **comptage** de la sonde, pas les seuils de drop/close (`WsConnectionTransport` est construit sans override, `RealtimeController.ts:
|
|
696
|
+
| Seuils de back-pressure **configurables** | `slowConsumer.bytes` ne pilote que le **comptage** de la sonde, pas les seuils de drop/close (`WsConnectionTransport` est construit sans override, `RealtimeController.ts:421`) |
|
|
697
697
|
|
|
698
698
|
## ⚙️ Configuration de sécurité
|
|
699
699
|
|
|
@@ -773,7 +773,7 @@ n'est pas appliquée.
|
|
|
773
773
|
## 📡 Observabilité — Studio
|
|
774
774
|
|
|
775
775
|
- **Santé de la socket** : `/nodefony/realtime/api/health`, alimenté par `RealtimeHub.probe()`
|
|
776
|
-
(`RealtimeHub.ts:
|
|
776
|
+
(`RealtimeHub.ts:865`) — canaux, abonnés, fan-out, connexions, back-pressure et carte d'identité
|
|
777
777
|
du backplane. Un `drops` qui grimpe signale des clients en souffrance ; un `slowConsumers` non nul
|
|
778
778
|
précède souvent une fermeture `1013`. Même snapshot en flux sur le canal `nodefony:socket`
|
|
779
779
|
(namespace réservé : `ROLE_NODEFONY_ADMIN`).
|
package/docs/vocabulaire.md
CHANGED
|
@@ -127,7 +127,7 @@ vers un autre service — et le consommateur ne voit jamais la différence
|
|
|
127
127
|
**`contrôleur` est le même mot qu'en HTTP.** Un endpoint temps réel est un contrôleur qui étend
|
|
128
128
|
`RealtimeController` (`RealtimeController.ts:144`) et porte une route WebSocket : HTTP et WebSocket
|
|
129
129
|
vivent dans le **même contexte de contrôleur**. C'est ce qui permet au pont `api.request`
|
|
130
|
-
(`RealtimeController.realtimeApiRequest()`, `RealtimeController.ts:
|
|
130
|
+
(`RealtimeController.realtimeApiRequest()`, `RealtimeController.ts:276`) de rejouer sur la socket
|
|
131
131
|
**la même action** que celle servie en REST.
|
|
132
132
|
|
|
133
133
|
## 🚀 Démarrage rapide — les mots en situation
|
|
@@ -270,7 +270,7 @@ Un handler qui lève une erreur rend un `-32603` **générique** ; seule une `Rp
|
|
|
270
270
|
|
|
271
271
|
Un canal où le **client a le droit d'émettre** vers le serveur. Défaut sûr : **aucun**. Un canal
|
|
272
272
|
n'accepte d'entrée que déclaré explicitement, par `@RealtimeInbound` (`realtimeDecorators.ts:231`) ou
|
|
273
|
-
par l'override `realtimeInbound()` (`RealtimeController.ts:
|
|
273
|
+
par l'override `realtimeInbound()` (`RealtimeController.ts:262`).
|
|
274
274
|
|
|
275
275
|
Le handler reçoit `(params, reply)` — `params` vient du réseau, donc **jamais fiable** :
|
|
276
276
|
`RealtimeInboundHandler` (`IRealtimeController.ts:16`).
|
|
@@ -297,7 +297,7 @@ importable **sans** aucune dépendance serveur, ce qui est la condition de l'iso
|
|
|
297
297
|
(`ServerRealtimeSocket.ts:223`) : un service métier tient un handle et publie **comme une page
|
|
298
298
|
front**. Une différence assumée : `request()` n'y est pas supporté — au-dessus du hub il n'y a pas
|
|
299
299
|
**un** pair mais N clients. Pour un appel serveur → un client précis, c'est `requestClient()`
|
|
300
|
-
(`RealtimeController.ts:
|
|
300
|
+
(`RealtimeController.ts:329`).
|
|
301
301
|
|
|
302
302
|
### `accueil` (welcome) — la première frame
|
|
303
303
|
|
|
@@ -335,7 +335,7 @@ handshake, jamais renégociée par frame.
|
|
|
335
335
|
### `hub` — le broker du process
|
|
336
336
|
|
|
337
337
|
Le **standard téléphonique** du pod : il tient la table « canal → abonnés locaux » et diffuse. Un
|
|
338
|
-
process = un hub, obtenu par `getRealtimeHub()` (`RealtimeHub.ts:
|
|
338
|
+
process = un hub, obtenu par `getRealtimeHub()` (`RealtimeHub.ts:1451`). Il ne connaît **ni** les
|
|
339
339
|
contrôleurs, **ni** le métier : ce sont les providers qui portent les dépendances.
|
|
340
340
|
|
|
341
341
|
`RealtimeHub` (`RealtimeHub.ts:213`). ⚠️ « hub » désigne **toujours** le serveur ; ce que tient le
|
|
@@ -395,7 +395,7 @@ Un canal dont la fabrique est déclarée par un module bas niveau (le journal d'
|
|
|
395
395
|
qui devient servable par **n'importe quel** endpoint, présent ou futur, sans qu'aucun contrôleur ne
|
|
396
396
|
le connaisse. Consulté seulement quand la fabrique du contrôleur a dit « inconnu ».
|
|
397
397
|
|
|
398
|
-
`RealtimeHub.registerSystemChannel()` (`RealtimeHub.ts:
|
|
398
|
+
`RealtimeHub.registerSystemChannel()` (`RealtimeHub.ts:1297`).
|
|
399
399
|
|
|
400
400
|
### `canal broadcast` — celui qui traverse le backplane
|
|
401
401
|
|
|
@@ -420,7 +420,7 @@ La classe de base d'un endpoint : elle porte tout le protocole (handshake, accue
|
|
|
420
420
|
des frames, cycle de vie des canaux) et ne laisse au métier que ses canaux et ses actions.
|
|
421
421
|
|
|
422
422
|
`RealtimeController` (`RealtimeController.ts:144`), point d'entrée `handleRealtime()`
|
|
423
|
-
(`RealtimeController.ts:
|
|
423
|
+
(`RealtimeController.ts:299`). C'est un **contrôleur** au sens habituel de Nodefony : la même classe
|
|
424
424
|
peut porter des routes HTTP.
|
|
425
425
|
|
|
426
426
|
### `service realtime` — la façade d'injection
|
|
@@ -437,8 +437,8 @@ L'option qui expose la méthode `api.request { path }` : la connexion rejoue **l
|
|
|
437
437
|
contrôleur** que celle servie en REST, avec **la même garde**. Le pont n'atteint que les routes qui
|
|
438
438
|
déclarent explicitement le transport WebSocket — aucun contournement possible.
|
|
439
439
|
|
|
440
|
-
`realtimeApiRequest()` (`RealtimeController.ts:
|
|
441
|
-
(`RealtimeController.ts:
|
|
440
|
+
`realtimeApiRequest()` (`RealtimeController.ts:276`), mise en œuvre `invokeApiRequest()`
|
|
441
|
+
(`RealtimeController.ts:949`). Désactivé par défaut.
|
|
442
442
|
|
|
443
443
|
## 🔌 Le protocole et le transport — ce qui passe sur le fil
|
|
444
444
|
|
|
@@ -630,7 +630,7 @@ l'authenticator tourne et que l'identité est figée. Tout ce qui coûte cher se
|
|
|
630
630
|
frame.
|
|
631
631
|
|
|
632
632
|
DTO neutre `IRealtimeHandshake` (`IRealtimeHandshake.ts:14`) — en-têtes, cookies, URL, origine,
|
|
633
|
-
sous-protocoles. Traitement dans `onHandshake()` (`RealtimeController.ts:
|
|
633
|
+
sous-protocoles. Traitement dans `onHandshake()` (`RealtimeController.ts:374`).
|
|
634
634
|
|
|
635
635
|
### `authenticator` — du handshake à l'identité
|
|
636
636
|
|
|
@@ -639,7 +639,7 @@ La stratégie qui transforme une poignée de main en identité : `supports` (est
|
|
|
639
639
|
HTTP — un seul modèle mental pour les deux transports.
|
|
640
640
|
|
|
641
641
|
`IRealtimeAuthenticator` (`IRealtimeAuthenticator.ts:24`), enregistrement
|
|
642
|
-
`RealtimeHub.useAuthenticator()` (`RealtimeHub.ts:
|
|
642
|
+
`RealtimeHub.useAuthenticator()` (`RealtimeHub.ts:943`).
|
|
643
643
|
|
|
644
644
|
### `matcher` — qui capture quoi
|
|
645
645
|
|
|
@@ -647,7 +647,7 @@ Le sélecteur (motif d'URL, hôte optionnel) qui décide **quel** authenticator
|
|
|
647
647
|
**premier** qui capture gagne : on enregistre donc du plus spécifique au plus général.
|
|
648
648
|
|
|
649
649
|
`IRealtimeAuthenticatorMatcher` (`IRealtimeAuthenticatorMatcher.ts:25`), résolution
|
|
650
|
-
`resolveAuthenticator()` (`RealtimeHub.ts:
|
|
650
|
+
`resolveAuthenticator()` (`RealtimeHub.ts:961`).
|
|
651
651
|
|
|
652
652
|
### `jeton` (token) — l'identité figée
|
|
653
653
|
|
|
@@ -664,7 +664,7 @@ Les exigences déclarées d'un canal : authentifié, rôles, scopes. Elles s'att
|
|
|
664
664
|
canal, pas à la méthode qui l'implémente — parce que c'est le nom que le pare-feu résout.
|
|
665
665
|
|
|
666
666
|
`IChannelPolicy` (`IChannelPolicy.ts:20`), déclaration par `@RealtimeChannel`
|
|
667
|
-
(`realtimeDecorators.ts:192`), résolution `resolveChannelPolicy()` (`RealtimeHub.ts:
|
|
667
|
+
(`realtimeDecorators.ts:192`), résolution `resolveChannelPolicy()` (`RealtimeHub.ts:1228`).
|
|
668
668
|
|
|
669
669
|
### `verrou de frame` — la décision par frame
|
|
670
670
|
|
|
@@ -672,8 +672,8 @@ La fonction qui répond « cette frame passe-t-elle ? » à partir du jeton **d
|
|
|
672
672
|
**Synchrone par doctrine** : attendre une réponse distante à chaque frame sérialiserait le flux d'une
|
|
673
673
|
connexion. Un contrôle qui doit interroger le réseau se fait au handshake, pas ici.
|
|
674
674
|
|
|
675
|
-
`FrameAuthorizer` (`RealtimeHub.ts:39`), pose `setFrameAuthorizer()` (`RealtimeHub.ts:
|
|
676
|
-
`runAuthorizer()` (`RealtimeHub.ts:
|
|
675
|
+
`FrameAuthorizer` (`RealtimeHub.ts:39`), pose `setFrameAuthorizer()` (`RealtimeHub.ts:1039`), appel
|
|
676
|
+
`runAuthorizer()` (`RealtimeHub.ts:1328`).
|
|
677
677
|
|
|
678
678
|
### `contrôle d'origine` — la défense d'ouverture
|
|
679
679
|
|
|
@@ -689,7 +689,7 @@ Le problème que ce mot nomme : une connexion ouverte pourrait survivre à la se
|
|
|
689
689
|
autorisée, puisque le verrou de frame ne relit rien. Un contrôle périodique referme l'écart en
|
|
690
690
|
re-validant les identités révocables et en fermant celles qui ne valent plus.
|
|
691
691
|
|
|
692
|
-
`registerRevocable()` (`RealtimeHub.ts:
|
|
692
|
+
`registerRevocable()` (`RealtimeHub.ts:794`), `revalidateRevocable()` (`RealtimeHub.ts:826`), période
|
|
693
693
|
`REVOCATION_REVALIDATE_MS` (`RealtimeHub.ts:111`). Une re-validation en erreur **ferme** — jamais
|
|
694
694
|
l'inverse.
|
|
695
695
|
|
|
@@ -728,7 +728,7 @@ ressources ; sans borne, une seule connexion peut les épuiser. Au-delà, l'abon
|
|
|
728
728
|
client en est informé.
|
|
729
729
|
|
|
730
730
|
`limits.maxChannelsPerConnection` (`config.ts:142`), pose `setMaxChannelsPerConnection()`
|
|
731
|
-
(`RealtimeHub.ts:
|
|
731
|
+
(`RealtimeHub.ts:995`).
|
|
732
732
|
|
|
733
733
|
## 📡 L'observabilité — ce que la socket dit d'elle-même
|
|
734
734
|
|
|
@@ -747,7 +747,7 @@ L'oscilloscope du module : « voici mon état, maintenant ». Canaux et abonnés
|
|
|
747
747
|
livraisons, connexions, octets, back-pressure, carte d'identité du backplane. Lecture **pure**, sans
|
|
748
748
|
allocation sur le chemin chaud.
|
|
749
749
|
|
|
750
|
-
`RealtimeHub.probe()` (`RealtimeHub.ts:
|
|
750
|
+
`RealtimeHub.probe()` (`RealtimeHub.ts:865`) rend un `IRealtimeProbe` (`IRealtimeProbe.ts:61`).
|
|
751
751
|
|
|
752
752
|
> [!WARNING]
|
|
753
753
|
> `IRealtimeProbe` est la **forme des données**, pas une interface à implémenter avec une méthode
|
|
@@ -807,7 +807,7 @@ elle-même par le même chemin que les autres modules.
|
|
|
807
807
|
| Deux déploiements se mélangent sur un même serveur Redis | pas de **cloison** — le numéro de base ne cloisonne pas le pub/sub | Poser `backplane.namespace` (`config.ts:59`) |
|
|
808
808
|
| Le fan-out disparaît entre conteneurs identiques | **origine** dérivée du seul identifiant de processus : deux conteneurs sont tous deux le n° 1, l'anti-écho avale tout | `resolveBackplaneOriginId()` (`originId.ts:24`) dérive du pod ou de l'hôte |
|
|
809
809
|
| Deux tickers pour le même tableau de bord | **cadence** différente = **canal** différent, jamais réconcilié | Fabriquer le nom via `rateChannel()` (`channelRate.ts:44`) des deux côtés |
|
|
810
|
-
| Un canal se croit protégé mais laisse tout passer | **politique** déclarée sans **verrou de frame** posé pour la faire respecter | Le hub le détecte et avertit : `hasUnenforcedChannelPolicies()` (`RealtimeHub.ts:
|
|
810
|
+
| Un canal se croit protégé mais laisse tout passer | **politique** déclarée sans **verrou de frame** posé pour la faire respecter | Le hub le détecte et avertit : `hasUnenforcedChannelPolicies()` (`RealtimeHub.ts:1117`) |
|
|
811
811
|
| Une connexion garde ses flux après une déconnexion applicative | le **verrou de frame** ne relit rien : par construction, il ne voit pas la session mourir | Inscrire l'identité au registre de **révocation** (`RealtimeHub.ts:433`) |
|
|
812
812
|
| « Le provider a planté après la fermeture d'un onglet » | **provider** confondu avec **sink** : le provider est partagé, il survit à la connexion créatrice | N'y capturer que des dépendances à longue vie (`ChannelFactory`, `RealtimeHub.ts:170`) |
|
|
813
813
|
| « Notre store realtime est indisponible » | **store** employé pour **driver** : le backplane ne conserve rien, il fait passer | Dire « le driver de backplane » — les données ont des stores, les flux ont des drivers |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nodefony/realtime",
|
|
3
|
-
"version": "10.0.0-
|
|
3
|
+
"version": "10.0.0-beta.1",
|
|
4
4
|
"description": "Couche temps réel serveur de Nodefony : canaux, appels bidirectionnels JSON-RPC 2.0, contre-pression, et diffusion entre plusieurs instances (boucle locale, IPC de grappe, Redis)",
|
|
5
5
|
"author": "Christophe CAMENSULI <ccamensuli@gmail.com>",
|
|
6
6
|
"type": "module",
|
|
@@ -41,20 +41,20 @@
|
|
|
41
41
|
"esm"
|
|
42
42
|
],
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"@nodefony/framework": "^10.0.0-
|
|
45
|
-
"@nodefony/http": "^10.0.0-
|
|
46
|
-
"nodefony": "^10.0.0-
|
|
44
|
+
"@nodefony/framework": "^10.0.0-beta.1",
|
|
45
|
+
"@nodefony/http": "^10.0.0-beta.1",
|
|
46
|
+
"nodefony": "^10.0.0-beta.1",
|
|
47
47
|
"reflect-metadata": "0.2.2",
|
|
48
48
|
"zod": "^4.6.5"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@nodefony/framework": "^10.0.0-
|
|
52
|
-
"@nodefony/http": "^10.0.0-
|
|
53
|
-
"@types/node": "26.6.
|
|
54
|
-
"@vitest/coverage-v8": "5.0.
|
|
55
|
-
"nodefony": "^10.0.0-
|
|
51
|
+
"@nodefony/framework": "^10.0.0-beta.1",
|
|
52
|
+
"@nodefony/http": "^10.0.0-beta.1",
|
|
53
|
+
"@types/node": "26.6.3",
|
|
54
|
+
"@vitest/coverage-v8": "5.0.2",
|
|
55
|
+
"nodefony": "^10.0.0-beta.1",
|
|
56
56
|
"reflect-metadata": "0.2.2",
|
|
57
|
-
"vitest": "5.0.
|
|
57
|
+
"vitest": "5.0.2"
|
|
58
58
|
},
|
|
59
59
|
"private": false,
|
|
60
60
|
"engines": {
|