@nodefony/realtime 10.0.0-alpha.8 → 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.
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.150.0/helpers/esm/decorate.js
1
+ //#region \0@oxc-project+runtime@0.151.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.150.0/helpers/esm/decorateMetadata.js
1
+ //#region \0@oxc-project+runtime@0.151.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.150.0/helpers/esm/decorateMetadata.js";
19
- import __decorate from "./_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorate.js";
18
+ import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
19
+ import __decorate from "./_virtual/_@oxc-project_runtime@0.151.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 expected = seal(JSON.stringify(msg.originId), JSON.stringify(msg.channel), JSON.stringify(msg.payload) ?? "null", secret);
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
- if (this.#richProbe === null) this.#richProbe = new RichProcessProbe();
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 !== null && authenticator.supports(handshake)) try {
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 in channelPolicies) hub.registerChannelPolicy(name, channelPolicies[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
- hub.onPlatformNotice((message, severity) => this.log(message, severity));
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 (let i = 0; i < broadcast.length; i++) hub.markBroadcastChannel(broadcast[i]);
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[PLATFORM_INBOUND.syslogUplink] === void 0) {
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?.("onFinish", () => {
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 (servedByPattern) getRealtimeHub().noticeUnguardedDynamicChannel(channel);
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
- const sink = state?.channels.get(channel);
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 st = this.#channels?.get(channel);
277
- if (!st) return;
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
- this.#channels.delete(channel);
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 (let i = 0; i < prefixes.length; i++) if (channel.startsWith(prefixes[i])) return true;
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 (let i = 0; i < list.length; i++) if (list[i].authenticator === authenticator) return;
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 (let i = 0; i < list.length; i++) {
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 (let i = 0; i < patterns.length; i++) if (patterns[i].re.test(name)) return patterns[i].policy;
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 cur = this.#subs?.get(c);
61
- if (!cur) return;
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
- this.#subs.set(c, cur - 1);
64
+ subs.set(c, cur - 1);
64
65
  return;
65
66
  }
66
- this.#subs.delete(c);
67
- const sink = this.#sinks?.get(c);
68
- if (sink) {
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
- this.#sinks.delete(c);
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
- this.#config = this.module.config;
58
- if (!this.#config) throw new Error(`${serviceName}: realtimeConfig absente (this.module.config vide) — la Module class doit la valider à onKernelRegister`);
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?.checkOrigin;
231
- if (!c || c.enabled !== true) return null;
232
- const allowSet = new Set(c.allowList ?? []);
233
- const allowMissing = c.allowMissingOrigin === true;
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
- if (!await waitFor(() => received.some((f) => f.method === "realtime:welcome"), 10)) {
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 received.find((f) => f.method === "realtime:welcome");
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:8`) — et se lit côté client par
140
- `RealtimeClient.serverMethods` (`RealtimeClient.ts:591`). Une interface n'écrit donc jamais un nom d'action en dur : elle
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:179`) | la table est **calculée** (noms dynamiques, boucle, config) |
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
@@ -287,7 +287,7 @@ allouée au premier enregistrement seulement.
287
287
  ### Ce que ton `return` et ton `throw` deviennent sur le fil
288
288
 
289
289
  Le retour du handler est envoyé tel quel en `result`. Les erreurs, elles, suivent une règle Zero
290
- Trust stricte, appliquée dans `JsonRpcPeer.handleRequest()` (`JsonRpcPeer.ts:506`) :
290
+ Trust stricte, appliquée dans `JsonRpcPeer.handleRequest()` (`JsonRpcPeer.ts:499`) :
291
291
 
292
292
  | Côté serveur | Ce que reçoit le client | Pourquoi |
293
293
  | ------------------------------- | ---------------------------------------- | ----------------------------------------------- |
@@ -345,21 +345,22 @@ homonyme), elle est conservée : le défaut ne rétrograde jamais une règle plu
345
345
  ### Situation 2 — les namespaces réservés portent un plancher
346
346
 
347
347
  Un nom qui commence par `nodefony:` hérite d'un plancher non contournable : authentifié **et**
348
- `ROLE_ADMIN` (`SYSTEM_CHANNEL_POLICY`, `frameAuthorizer.ts:70`). Un seul préfixe couvre donc tout
349
- ce que la plateforme expose (`DEFAULT_SYSTEM_PREFIXES`, `frameAuthorizer.ts:86`). C'est ce qui
348
+ `ROLE_NODEFONY_ADMIN` (`SYSTEM_CHANNEL_POLICY`, `frameAuthorizer.ts:89`) — un rôle de la plateforme,
349
+ pas le `ROLE_ADMIN` d'une organisation. Un seul préfixe couvre donc tout
350
+ ce que la plateforme expose (`DEFAULT_SYSTEM_PREFIXES`, `frameAuthorizer.ts:106`). C'est ce qui
350
351
  protège `nodefony:kernel:gc` : le nom **est** la garde, et la comparaison est insensible à la casse
351
352
  pour qu'un `NODEFONY:kernel:gc` ne passe pas à côté.
352
353
 
353
354
  Ce plancher **prime sur ta déclaration** — y compris sur `{ authenticated: false }`. Et il ne
354
355
  s'arrête pas aux préfixes : tout nom **contenant** `:health` ou `:stats` est traité comme un canal
355
- d'observabilité (`matchSystemPolicy`, `frameAuthorizer.ts:202`). Une action `public:health` reste
356
+ d'observabilité (`matchSystemPolicy`, `frameAuthorizer.ts:243`). Une action `public:health` reste
356
357
  donc réservée, quoi qu'en dise son décorateur. À savoir avant de nommer une action « ouverte ».
357
358
 
358
359
  ### Situation 3 — couvrir SES actions par la configuration
359
360
 
360
361
  Pour exiger un rôle sur tes propres actions, on déclare une règle de préfixe dans la
361
362
  configuration de sécurité — la même liste que pour les canaux
362
- (`realtimeChannels`, `security/nodefony/config/config.ts:1124`) :
363
+ (`realtimeChannels`, `security/nodefony/config/config.ts:1130`) :
363
364
 
364
365
  ```ts ignore
365
366
  use("@nodefony/security", {
@@ -402,7 +403,7 @@ await socket.request("orders:export", { scope }, 120_000); // action longue
402
403
 
403
404
  | Défaut | Valeur | Ancrage |
404
405
  | --------- | --------- | -------------------- |
405
- | `request` | 30 000 ms | `JsonRpcPeer.ts:310` |
406
+ | `request` | 30 000 ms | `JsonRpcPeer.ts:319` |
406
407
 
407
408
  À l'expiration, l'entrée en attente est **retirée** et la `Promise` rejetée avec
408
409
  `RPC timeout: <méthode>` (`JsonRpcPeer.ts:455`). Conséquence à connaître : une réponse qui
@@ -444,7 +445,7 @@ fois ? »**.
444
445
 
445
446
  Pour les mutations passant par le pont API, la clé n'est pas une convention : elle est **exigée
446
447
  par la signature** de `mutate()` (`RealtimeClient.ts:797`), et c'est la garde `@Idempotent`
447
- (`routerDecorators.ts:1103`) qui, côté serveur, reconnaît le rejeu et rend la réponse déjà calculée
448
+ (`routerDecorators.ts:1171`) qui, côté serveur, reconnaît le rejeu et rend la réponse déjà calculée
448
449
  au lieu de refaire l'effet.
449
450
 
450
451
  ```ts ignore
@@ -509,8 +510,8 @@ il sera conçu avec son premier consommateur réel.
509
510
  Un cas particulier mérite d'être connu avant d'écrire une action : **elle existe peut-être déjà en
510
511
  HTTP**. Le pont API expose la méthode `api.request`, qui rejoue une route de contrôleur sur la
511
512
  socket, avec la même garde et le même résultat qu'en REST — `invokeApiRequest()`
512
- (`RealtimeController.ts:878`). Il est **désactivé par défaut** et s'active en surchargeant
513
- `realtimeApiRequest()` (`RealtimeController.ts:219`).
513
+ (`RealtimeController.ts:949`). Il est **désactivé par défaut** et s'active en surchargeant
514
+ `realtimeApiRequest()` (`RealtimeController.ts:276`).
514
515
 
515
516
  ```ts ignore
516
517
  const modules = await socket.request("/nodefony/kernel/api/modules");
@@ -529,7 +530,7 @@ passe par le pont pour tout ce qui est déjà une route. Le détail du pont vit
529
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** |
530
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 |
531
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 |
532
- | `-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_ADMIN` |
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` |
533
534
  | Une action sensible est appelable par un anonyme | une action applicative est **libre** tant qu'aucune politique ne la couvre | ajouter une règle de préfixe (`security/nodefony/config/config.ts:906`) |
534
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 |
535
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 |