@nodefony/realtime 10.0.0-alpha.9 → 10.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +2 -2
- 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 +19 -19
- package/docs/architecture.md +35 -35
- package/docs/configuration.md +35 -30
- package/docs/cookbook-chat.md +19 -17
- package/docs/index.md +13 -14
- package/docs/observabilite.md +6 -8
- package/docs/protocole.md +24 -19
- package/docs/securite.md +43 -39
- package/docs/vocabulaire.md +23 -23
- package/package.json +10 -10
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region \0@oxc-project+runtime@0.
|
|
1
|
+
//#region \0@oxc-project+runtime@0.152.0/helpers/esm/decorate.js
|
|
2
2
|
function __decorate(decorators, target, key, desc) {
|
|
3
3
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
4
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region \0@oxc-project+runtime@0.
|
|
1
|
+
//#region \0@oxc-project+runtime@0.152.0/helpers/esm/decorateMetadata.js
|
|
2
2
|
function __decorateMetadata(k, v) {
|
|
3
3
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
4
4
|
}
|
package/dist/index.js
CHANGED
|
@@ -15,8 +15,8 @@ import { ClusterBackplane, processIpcTransport } from "./nodefony/src/backplane/
|
|
|
15
15
|
import { getBackplaneDriver, listBackplaneDrivers, registerBackplaneDriver } from "./nodefony/src/backplane/backplaneRegistry.js";
|
|
16
16
|
import { ClusterProbeClient, clusterProbeHealth, clusterProbeInstance, clusterProbeRequestEnrich, mergeClusterHealth, processProbeTransport, setClusterProbeClient } from "./nodefony/src/cluster/ClusterProbeClient.js";
|
|
17
17
|
import { buildOwnHealth, buildRealtimeHealth, createRealtimeAdminApi } from "./nodefony/src/server/RealtimeAdminApi.js";
|
|
18
|
-
import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.
|
|
19
|
-
import __decorate from "./_virtual/_@oxc-project_runtime@0.
|
|
18
|
+
import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js";
|
|
19
|
+
import __decorate from "./_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorate.js";
|
|
20
20
|
import { Kernel, Module, services, withTimeout } from "nodefony";
|
|
21
21
|
//#region index.ts
|
|
22
22
|
/**
|
|
@@ -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
|
|
@@ -485,7 +485,7 @@ prudente est donc de **s'abonner avant** de lancer l'appel.
|
|
|
485
485
|
|
|
486
486
|
Nodefony a choisi la garantie côté serveur plutôt que la discipline côté client : le producteur du
|
|
487
487
|
canal **rejoue son historique** au nouvel abonné, de sorte qu'un arrivant tardif voit tout depuis
|
|
488
|
-
le début (`ScaffoldService.subscribe()`, `ScaffoldService.ts:
|
|
488
|
+
le début (`ScaffoldService.subscribe()`, `ScaffoldService.ts:456`). C'est ce qui autorise le front
|
|
489
489
|
à faire l'appel d'abord et à s'abonner ensuite, sans rien perdre.
|
|
490
490
|
|
|
491
491
|
> [!TIP]
|
|
@@ -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");
|
|
@@ -524,18 +524,18 @@ passe par le pont pour tout ce qui est déjà une route. Le détail du pont vit
|
|
|
524
524
|
|
|
525
525
|
## ⚠️ Pièges
|
|
526
526
|
|
|
527
|
-
| Symptôme
|
|
528
|
-
|
|
|
529
|
-
| `RPC timeout: <méthode>` alors que le serveur a bien répondu
|
|
530
|
-
| Le client attend indéfiniment, aucune erreur
|
|
531
|
-
| `-32601 method not found`
|
|
532
|
-
| `-32603 internal error` sans détail
|
|
533
|
-
| `-32001 unauthorized` sur une action légitime
|
|
534
|
-
| Une action
|
|
535
|
-
| Un travail relancé crée deux jobs
|
|
536
|
-
| `abort()` introuvable sur la socket
|
|
537
|
-
| Les premières lignes d'un job manquent
|
|
538
|
-
| Toutes les `Promise` rejettent d'un coup
|
|
527
|
+
| Symptôme | Cause | Correction |
|
|
528
|
+
| ------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
|
529
|
+
| `RPC timeout: <méthode>` alors que le serveur a bien répondu | le handler dure plus que 30 s, le défaut | passer le délai en **3ᵉ argument** : `request(m, p, 120_000)` |
|
|
530
|
+
| Le client attend indéfiniment, aucune erreur | le handler ne rend jamais (il publie au lieu de retourner) — pas de frame `result` | toujours `return` une valeur ; publier **en plus**, jamais **à la place** |
|
|
531
|
+
| `-32601 method not found` | nom mal orthographié, ou action déclarée sur un **autre** endpoint | vérifier `socket.serverMethods` — c'est la liste réelle de CETTE connexion |
|
|
532
|
+
| `-32603 internal error` sans détail | un `throw` ordinaire est rendu opaque au client (Zero Trust) | lever une `RpcError` avec un code et un message publiables |
|
|
533
|
+
| `-32001 unauthorized` sur une action légitime | le nom commence par `nodefony:`, le namespace réservé à la plateforme | renommer hors de `nodefony:`, ou obtenir `ROLE_NODEFONY_ADMIN` |
|
|
534
|
+
| Une action est refusée à un anonyme alors qu'on la croyait publique | une action est fermée par défaut (`{ authenticated: true }`) | déclarer `{ authenticated: false }` pour l'ouvrir ; un rôle se pose par une règle de préfixe |
|
|
535
|
+
| Un travail relancé crée deux jobs | action non idempotente rejouée après une reconnexion | action compagnon d'annulation, ou identifiant fourni par l'appelant + mémorisation |
|
|
536
|
+
| `abort()` introuvable sur la socket | il n'existe pas — et n'arrêterait pas le serveur de toute façon | exposer une action d'annulation qui prend l'identifiant du travail |
|
|
537
|
+
| Les premières lignes d'un job manquent | abonnement au canal après le début de la production, sans historique rejoué | s'abonner **avant** de lancer, ou faire rejouer l'historique par le producteur |
|
|
538
|
+
| Toutes les `Promise` rejettent d'un coup | la connexion s'est fermée : `dispose()` vide la table des appels en attente | attendu ; relancer après reconnexion, l'appel n'a pas survécu |
|
|
539
539
|
|
|
540
540
|
## 🧪 Tests & couverture
|
|
541
541
|
|