@nodefony/realtime 10.0.0-beta.2 → 10.0.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +2 -2
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  4. package/dist/index.js +17 -10
  5. package/dist/nodefony/config/config.js +6 -3
  6. package/dist/nodefony/config/configSchema.js +19 -0
  7. package/dist/nodefony/config/defineModuleConfig.js +42 -15
  8. package/dist/nodefony/decorators/realtimeDecorators.js +68 -17
  9. package/dist/nodefony/src/backplane/RedisBackplane.js +21 -4
  10. package/dist/nodefony/src/backplane/bootNotices.js +43 -0
  11. package/dist/nodefony/src/backplane/publishQueue.js +1 -4
  12. package/dist/nodefony/src/cluster/ClusterProbeClient.js +15 -4
  13. package/dist/nodefony/src/guards.js +41 -0
  14. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +1 -4
  15. package/dist/nodefony/src/server/RealtimeController.js +120 -50
  16. package/dist/nodefony/src/server/RealtimeHub.js +29 -51
  17. package/dist/nodefony/src/server/ServerRealtimeSocket.js +13 -5
  18. package/dist/nodefony/src/server/syslogUplink.js +4 -2
  19. package/dist/nodefony/src/service/RealtimeService.js +3 -2
  20. package/dist/nodefony/testing/index.js +41 -1
  21. package/dist/types/nodefony/config/configSchema.d.ts +12 -0
  22. package/dist/types/nodefony/config/defineModuleConfig.d.ts +29 -6
  23. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +19 -2
  24. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +1 -1
  25. package/dist/types/nodefony/interfaces/IRealtimeHandshake.d.ts +1 -1
  26. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +7 -0
  27. package/dist/types/nodefony/src/backplane/bootNotices.d.ts +25 -0
  28. package/dist/types/nodefony/src/guards.d.ts +32 -0
  29. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +13 -0
  30. package/dist/types/nodefony/src/server/RealtimeController.d.ts +24 -4
  31. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +22 -16
  32. package/dist/types/nodefony/src/service/RealtimeService.d.ts +1 -1
  33. package/dist/types/nodefony/testing/index.d.ts +2 -2
  34. package/docs/actions.md +111 -28
  35. package/docs/architecture.md +77 -75
  36. package/docs/cadence-et-contre-pression.md +634 -0
  37. package/docs/configuration.md +39 -34
  38. package/docs/cookbook-chat.md +31 -27
  39. package/docs/index.md +35 -21
  40. package/docs/observabilite.md +17 -15
  41. package/docs/protocole.md +74 -63
  42. package/docs/securite.md +49 -49
  43. package/docs/vocabulaire.md +76 -75
  44. package/package.json +16 -12
  45. package/docs/couverture-bancs.md +0 -139
package/README.md CHANGED
@@ -31,9 +31,9 @@ Le client navigateur **n'est pas dans ce module** : il vit dans le cœur, import
31
31
  du serveur (isomorphisme). Le subpath est **`nodefony/client`**.
32
32
 
33
33
  ```typescript
34
- import { RealtimeClient } from "nodefony/client";
34
+ import { NodefonySocket } from "nodefony/client";
35
35
 
36
- const socket = RealtimeClient.shared({ url: "wss://app.com/realtime" });
36
+ const socket = NodefonySocket.shared({ url: "wss://app.com/realtime" });
37
37
 
38
38
  await socket.subscribe("chat:room");
39
39
  socket.on("chat:room", (msg) => console.log(msg));
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.152.0/helpers/esm/decorate.js
1
+ //#region \0@oxc-project+runtime@0.153.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.152.0/helpers/esm/decorateMetadata.js
1
+ //#region \0@oxc-project+runtime@0.153.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
@@ -1,5 +1,7 @@
1
1
  import config from "./nodefony/config/config.js";
2
- import { defineRealtimeConfig, realtimeConfigJsonSchema } from "./nodefony/config/defineModuleConfig.js";
2
+ import { buildRealtimeConfig, defineRealtimeConfig, realtimeConfigJsonSchema } from "./nodefony/config/defineModuleConfig.js";
3
+ import { isCallable, isRecord } from "./nodefony/src/guards.js";
4
+ import { realtimeBackplaneDownNotice, realtimeLocalOnlyNotice } from "./nodefony/src/backplane/bootNotices.js";
3
5
  import resolveBackplaneOriginId from "./nodefony/src/backplane/originId.js";
4
6
  import { LoopbackBackplane } from "./nodefony/src/backplane/LoopbackBackplane.js";
5
7
  import { ANONYMOUS_REALTIME_TOKEN } from "./nodefony/src/server/AnonymousRealtimeToken.js";
@@ -10,14 +12,14 @@ import { BROWSER_ORIGIN, MAX_CLIENT_SEVERITY, createSyslogUplinkHandler } from "
10
12
  import { WsConnectionTransport } from "./nodefony/src/transport/WsConnectionTransport.js";
11
13
  import { RealtimeController } from "./nodefony/src/server/RealtimeController.js";
12
14
  import { ServerRealtimeSocket, serverSocket } from "./nodefony/src/server/ServerRealtimeSocket.js";
13
- import { REDIS_RT_CHANNEL, RedisBackplane, createRedisServiceTransport, resolveRedisChannel } from "./nodefony/src/backplane/RedisBackplane.js";
15
+ import { REDIS_RT_CHANNEL, RedisBackplane, createRedisServiceTransport, isRedisPublisher, isRedisSubscriber, resolveRedisChannel } from "./nodefony/src/backplane/RedisBackplane.js";
14
16
  import { ClusterBackplane, processIpcTransport } from "./nodefony/src/backplane/ClusterBackplane.js";
15
17
  import { getBackplaneDriver, listBackplaneDrivers, registerBackplaneDriver } from "./nodefony/src/backplane/backplaneRegistry.js";
16
18
  import { ClusterProbeClient, clusterProbeHealth, clusterProbeInstance, clusterProbeRequestEnrich, mergeClusterHealth, processProbeTransport, setClusterProbeClient } from "./nodefony/src/cluster/ClusterProbeClient.js";
17
19
  import { buildOwnHealth, buildRealtimeHealth, createRealtimeAdminApi } from "./nodefony/src/server/RealtimeAdminApi.js";
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
- import { Kernel, Module, services, withTimeout } from "nodefony";
20
+ import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js";
21
+ import __decorate from "./_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorate.js";
22
+ import { IDENTITY_CHANGED_EVENT, Kernel, Module, services, withTimeout } from "nodefony";
21
23
  //#region index.ts
22
24
  /**
23
25
  * @nodefony/realtime — Couche realtime serveur Nodefony.
@@ -51,13 +53,13 @@ registerBackplaneDriver(LoopbackBackplane.driver, () => null);
51
53
  registerBackplaneDriver(ClusterBackplane.driver, (ctx) => ctx.role === "WORKER" && process.env.NF_CLUSTER === "1" ? new ClusterBackplane(processIpcTransport, ctx.originId) : null);
52
54
  registerBackplaneDriver(RedisBackplane.driver, (ctx) => {
53
55
  const redisService = ctx.module.kernel?.container?.get("redis");
54
- if (!redisService) {
56
+ if (!isRecord(redisService) || !isCallable(redisService.getClient)) {
55
57
  ctx.module.log(`driver "${RedisBackplane.driver}" : module @nodefony/redis absent (non listé dans @modules) — RealtimeHub reste local`, "WARNING");
56
58
  return null;
57
59
  }
58
60
  const publisher = redisService.getClient("publish");
59
61
  const subscriber = redisService.getClient("subscribe");
60
- if (!publisher || !subscriber) {
62
+ if (!isRedisPublisher(publisher) || !isRedisSubscriber(subscriber)) {
61
63
  ctx.module.log(`driver "${RedisBackplane.driver}" : connexions Redis publish/subscribe indisponibles — RealtimeHub reste local`, "WARNING");
62
64
  return null;
63
65
  }
@@ -96,7 +98,7 @@ let Realtime = class Realtime extends Module {
96
98
  * sans dupliquer la validation.
97
99
  */
98
100
  async onKernelRegister() {
99
- const validated = defineRealtimeConfig(this.options ?? {});
101
+ const validated = buildRealtimeConfig(this.options);
100
102
  this.options = validated;
101
103
  return this;
102
104
  }
@@ -117,7 +119,10 @@ let Realtime = class Realtime extends Module {
117
119
  return this;
118
120
  }
119
121
  const broker = this.kernel?.container?.get("adminBroker");
120
- if (broker && !broker.has("realtime")) broker.register(createRealtimeAdminApi());
122
+ if (isRecord(broker) && isCallable(broker.has) && isCallable(broker.register) && broker.has("realtime") !== true) broker.register(createRealtimeAdminApi());
123
+ this.kernel?.on(IDENTITY_CHANGED_EVENT, (event) => {
124
+ getRealtimeHub().refreshIdentity(event.identifier);
125
+ });
121
126
  await this.#wireBackplane();
122
127
  this.#wireClusterProbe(this.#clusterRole);
123
128
  return this;
@@ -157,12 +162,14 @@ let Realtime = class Realtime extends Module {
157
162
  });
158
163
  if (!backplane) {
159
164
  this.log(`realtime backplane driver=${driverName} kind=local cross-pod=no (hub local)`, "INFO");
165
+ this.kernel?.reportBootNotice(realtimeLocalOnlyNotice(driverName));
160
166
  return;
161
167
  }
162
168
  try {
163
169
  await this.#startWithTimeout(backplane, BACKPLANE_START_TIMEOUT_MS);
164
170
  } catch (e) {
165
- this.log(`realtime backplane driver=${driverName} indisponible (${e.message}) — fallback hub LOCAL, boot poursuivi (pas de fan-out cross-pod)`, "WARNING");
171
+ this.log(`realtime backplane driver=${driverName} indisponible (${e instanceof Error ? e.message : String(e)}) — fallback hub LOCAL, boot poursuivi (pas de fan-out cross-pod)`, "WARNING");
172
+ this.kernel?.reportBootNotice(realtimeBackplaneDownNotice(driverName, e instanceof Error ? e.message : String(e)));
166
173
  return;
167
174
  }
168
175
  hub.setBackplane(backplane);
@@ -24,9 +24,12 @@ import { z } from "zod";
24
24
  */
25
25
  const probeSchema = z.strictObject({ enabled: z.boolean().default(true).describe("Branche le ClusterProbeClient (sonde agrégée pod, Phase 4c) en worker `nodefony cluster`. true = sonde active (timer + IPC). false = bypass total (0 timer / 0 listener / 0 IPC, l'endpoint santé sert la vue per-instance). Override env : NF_CLUSTER_PROBE=0 force la désactivation.") }).describe("Sonde agrégée pod (Phase 4c).");
26
26
  const backplaneSchema = z.strictObject({
27
- driver: z.string().default("loopback").describe("Nom du driver IBackplane, résolu dans le registre de drivers (`listBackplaneDrivers()`) — PAS d'enum en dur ici : la liste réelle est la source de vérité du registre, ouverte aux drivers custom utilisateur (`registerBackplaneDriver(name, factory)`). Natifs : `loopback` (mono, hub local sans IPC), `cluster` (IPC workers `nodefony cluster`, actif si NF_CLUSTER=1), `redis` (multi-host pub/sub). Driver inconnu au boot → warn fail-soft, le hub reste local."),
28
- namespace: z.string().min(1).regex(/^[\w.-]+$/).optional().describe("Cloison logique du transport partagé (drivers cross-pod : redis, futur kafka) — suffixe le canal pub/sub (`nodefony:realtime:<ns>`). Le `database` Redis ne cloisonnant PAS le pub/sub, deux apps sur un Redis mutualisé SANS namespace se parleraient (cross-talk). Défaut : dérivé du nom d'app (`kernel.projectName`). À poser EXPLICITEMENT quand deux déploiements de la même app (staging/prod) partagent un Redis. Caractères : alphanumériques, `_`, `.`, `-`. Env : `NF_REALTIME_BACKPLANE_NAMESPACE` (prioritaire — le namespace distingue des DÉPLOIEMENTS, il n'a pas à être figé dans le code)."),
29
- secret: z.string().min(32).optional().describe("Secret de SCELLEMENT des messages du backplane, partagé par tous les pods de l'app (drivers à transport PARTAGÉ : redis, futurs drivers cross-host). Un bus pub/sub n'authentifie PAS l'émetteur : sans ce secret, quiconque écrit dans le Redis (autre app d'un Redis mutualisé, credential fuité, SSRF) publie sur les canaux de TOUS les pods. Posé, chaque message porte un HMAC-SHA256 et l'ingress devient fail-closed strict (non scellé ou mal scellé = ignoré, aucun downgrade). Sans lui, le boot ALERTE et le bus reste ouvert. Inutile pour `cluster` (IPC master↔workers : aucun tiers ne peut y écrire). Env : `NF_REALTIME_BACKPLANE_SECRET`. Minimum 32 caractères.").meta({ secret: true }),
27
+ driver: z.string().default("loopback").describe("Nom du driver IBackplane, résolu dans le registre de drivers (`listBackplaneDrivers()`) — PAS d'enum en dur ici : la liste réelle est la source de vérité du registre, ouverte aux drivers custom utilisateur (`registerBackplaneDriver(name, factory)`). Natifs : `loopback` (mono, hub local sans IPC), `cluster` (IPC workers `nodefony cluster`, actif si NF_CLUSTER=1), `redis` (multi-host pub/sub). Driver inconnu au boot → warn fail-soft, le hub reste local.").meta({ env: "NF_REALTIME_DRIVER" }),
28
+ namespace: z.string().min(1).regex(/^[\w.-]+$/).optional().describe("Cloison logique du transport partagé (drivers cross-pod : redis, futur kafka) — suffixe le canal pub/sub (`nodefony:realtime:<ns>`). Le `database` Redis ne cloisonnant PAS le pub/sub, deux apps sur un Redis mutualisé SANS namespace se parleraient (cross-talk). Défaut : dérivé du nom d'app (`kernel.projectName`). À poser EXPLICITEMENT quand deux déploiements de la même app (staging/prod) partagent un Redis. Caractères : alphanumériques, `_`, `.`, `-`. Env : `NF_REALTIME_BACKPLANE_NAMESPACE` (prioritaire — le namespace distingue des DÉPLOIEMENTS, il n'a pas à être figé dans le code).").meta({ env: "NF_REALTIME_BACKPLANE_NAMESPACE" }),
29
+ secret: z.string().min(32).optional().describe("Secret de SCELLEMENT des messages du backplane, partagé par tous les pods de l'app (drivers à transport PARTAGÉ : redis, futurs drivers cross-host). Un bus pub/sub n'authentifie PAS l'émetteur : sans ce secret, quiconque écrit dans le Redis (autre app d'un Redis mutualisé, credential fuité, SSRF) publie sur les canaux de TOUS les pods. Posé, chaque message porte un HMAC-SHA256 et l'ingress devient fail-closed strict (non scellé ou mal scellé = ignoré, aucun downgrade). Sans lui, le boot ALERTE et le bus reste ouvert. Inutile pour `cluster` (IPC master↔workers : aucun tiers ne peut y écrire). Env : `NF_REALTIME_BACKPLANE_SECRET`. Minimum 32 caractères.").meta({
30
+ env: "NF_REALTIME_BACKPLANE_SECRET",
31
+ secret: true
32
+ }),
30
33
  maxQueueBytes: z.number().int().nonnegative().default(1 << 23).describe("Seuil d'octets PUBLIÉS mais pas encore acquittés par le bus, au-delà duquel les publications suivantes sont JETÉES (drivers à transport réseau : redis, drivers cross-host userland). Un `publish` de backplane est fire-and-forget : quand le bus ne draine plus, la file interne du client réseau grossit sans limite (583 MB observés sous rafale sur le banc multi-pods). Même doctrine que le back-pressure WS : on sacrifie du fan-out cross-pod pour tenir la mémoire du pod, jamais en silence (compteurs `backplane.queue` dans la sonde + Studio, alerte au franchissement). Défaut 8 MiB, soit trois ordres de grandeur au-dessus d'un régime sain (bus acquitté en quelques ms). `0` = illimité (opt-out explicite : la mémoire n'est alors bornée par rien).")
31
34
  }).describe("Driver IBackplane (fan-out cluster realtime cross-process).");
32
35
  const clusterSchema = z.strictObject({ probe: probeSchema.default(() => probeSchema.parse({})) }).describe("Comportement spécifique au mode cluster (worker IPC).");
@@ -0,0 +1,19 @@
1
+ import { realtimeConfigSchema } from "./config.js";
2
+ import { z } from "zod";
3
+ //#region nodefony/config/configSchema.ts
4
+ /**
5
+ * JSON Schema de la configuration de `@nodefony/realtime` — lu SANS démarrer
6
+ * l'application, par `nodefony env` et la notice `.env.example`, qui y
7
+ * trouvent les noms courts (`.meta({ env })`).
8
+ *
9
+ * Déclaré dans `package.json` → `nodefony.configSchema` ; le nom de l'export
10
+ * est FIXE. Fichier FEUILLE : il n'importe que zod et le schéma, et n'ouvre
11
+ * rien à l'import — une commande qui ne démarre pas l'application l'exécute.
12
+ *
13
+ * @returns le JSON Schema, métadonnées Nodefony comprises.
14
+ */
15
+ function configJsonSchema() {
16
+ return z.toJSONSchema(realtimeConfigSchema);
17
+ }
18
+ //#endregion
19
+ export { configJsonSchema };
@@ -1,6 +1,6 @@
1
1
  import { realtimeConfigSchema } from "./config.js";
2
+ import { configJsonSchema } from "./configSchema.js";
2
3
  import { parseModuleConfig } from "nodefony";
3
- import { z } from "zod";
4
4
  //#region nodefony/config/defineModuleConfig.ts
5
5
  /**
6
6
  * Builder type-safe de la configuration de `@nodefony/realtime`.
@@ -20,11 +20,13 @@ import { z } from "zod";
20
20
  * faire dans un JSON Schema Studio). L'instance, si fournie, prend précédence
21
21
  * sur `driver` côté service ; ce dernier reste source d'introspection/UI.
22
22
  *
23
- * **Layering d'environnement** (convention-frère avec `REDIS_*` de
24
- * `@nodefony/redis`) : `NF_REALTIME_DRIVER` surcharge le driver backplane
25
- * APRÈS le parse (schéma pur, déterministe). Indispensable en déploiement
26
- * (k8s/Docker : `NF_REALTIME_DRIVER=redis`) et fiable quel que soit le
27
- * timing du merge `module-realtime` côté app (cf chantier ordering config).
23
+ * **Variables d'environnement** : `NF_REALTIME_DRIVER`,
24
+ * `NF_REALTIME_BACKPLANE_SECRET` et `NF_REALTIME_BACKPLANE_NAMESPACE` ne se
25
+ * lisent PAS ici. Chacune est déclarée sur son champ dans `config.ts`
26
+ * (`.meta({ env })`) et posée par le Kernel AVANT la validation : le schéma
27
+ * les valide comme une valeur de l'application (un secret de moins de 32
28
+ * caractères arrête le démarrage). Un appel direct de ce builder, hors Kernel,
29
+ * ne les voit donc pas.
28
30
  *
29
31
  * @param config - configuration brute (sections omises = défauts sûrs).
30
32
  * @param options.backplane - instance `IBackplane` custom (override le driver à
@@ -33,28 +35,53 @@ import { z } from "zod";
33
35
  * @throws ZodError si invalide.
34
36
  */
35
37
  function defineRealtimeConfig(config = {}, options = {}) {
38
+ return buildRealtimeConfig(config, options);
39
+ }
40
+ /**
41
+ * Le builder pour une entrée NON typée — la config reçue par le module au
42
+ * démarrage, dont le schéma décide seul de la forme. Interne au paquet : une
43
+ * application passe par {@link defineRealtimeConfig}, qui type ce qu'elle écrit.
44
+ *
45
+ * @param config - configuration brute, validée par le schéma
46
+ * @param options.backplane - instance `IBackplane` custom
47
+ * @returns config gelée prête pour `RealtimeService`.
48
+ * @throws BootConfigurationError si invalide.
49
+ */
50
+ function buildRealtimeConfig(config = {}, options = {}) {
36
51
  const parsed = parseModuleConfig(realtimeConfigSchema, config, "@nodefony/realtime");
37
- const driver = process.env.NF_REALTIME_DRIVER || parsed.backplane.driver;
38
- const secret = process.env.NF_REALTIME_BACKPLANE_SECRET || parsed.backplane.secret;
39
- const namespace = process.env.NF_REALTIME_BACKPLANE_NAMESPACE || parsed.backplane.namespace;
40
52
  const out = {
41
53
  ...parsed,
42
54
  backplane: {
43
55
  ...parsed.backplane,
44
- driver,
45
- secret,
46
- namespace,
47
56
  instance: options.backplane
48
57
  }
49
58
  };
50
- return Object.freeze(out);
59
+ const frozen = Object.freeze(out);
60
+ built.add(frozen);
61
+ return frozen;
62
+ }
63
+ /**
64
+ * Configs produites par ce builder. Marquer la sortie plutôt que d'en vérifier
65
+ * la forme : une seule appartenance (`has`) prouve que l'objet a traversé le
66
+ * schéma, ce qu'aucune inspection de champs ne dit.
67
+ */
68
+ const built = /* @__PURE__ */ new WeakSet();
69
+ /**
70
+ * Vrai si la valeur est une config produite par {@link buildRealtimeConfig}
71
+ * (donc validée par le schéma). Lu une fois, à l'init du service.
72
+ *
73
+ * @param value - la config lue sur le module
74
+ * @returns `true` si elle sort du builder
75
+ */
76
+ function isRealtimeConfig(value) {
77
+ return typeof value === "object" && value !== null && built.has(value);
51
78
  }
52
79
  /**
53
80
  * JSON Schema introspectable de la config realtime — destiné au formulaire
54
81
  * d'édition Studio (futur). N'inclut PAS `backplane.instance` (non sérialisable).
55
82
  */
56
83
  function realtimeConfigJsonSchema() {
57
- return z.toJSONSchema(realtimeConfigSchema);
84
+ return configJsonSchema();
58
85
  }
59
86
  //#endregion
60
- export { defineRealtimeConfig, realtimeConfigJsonSchema };
87
+ export { buildRealtimeConfig, defineRealtimeConfig, isRealtimeConfig, realtimeConfigJsonSchema };
@@ -1,3 +1,4 @@
1
+ import { isCallable, isRecord } from "../src/guards.js";
1
2
  import "reflect-metadata";
2
3
  //#region nodefony/decorators/realtimeDecorators.ts
3
4
  /** Clés de métadonnées posées sur le constructor de la classe controller. */
@@ -5,10 +6,41 @@ const ACTIONS_KEY = "realtime:actions";
5
6
  const CHANNELS_KEY = "realtime:channels";
6
7
  const INBOUND_KEY = "realtime:inbound";
7
8
  const POLICIES_KEY = "realtime:policies";
9
+ /**
10
+ * Lit une métadonnée de classe posée par ces décorateurs : un objet nom → valeur,
11
+ * ou `undefined` quand la classe n'en porte pas. Les valeurs restent `unknown` :
12
+ * `Reflect.defineMetadata` est public, n'importe qui peut écrire sous ces clés.
13
+ * Lu au démarrage et à la poignée de main, jamais par frame.
14
+ */
15
+ function readMetadataMap(key, ctor) {
16
+ const raw = Reflect.getMetadata(key, ctor);
17
+ return isRecord(raw) ? raw : void 0;
18
+ }
19
+ /** Vrai si la valeur est un tableau de chaînes. */
20
+ function isStringArray(value) {
21
+ return Array.isArray(value) && value.every((v) => typeof v === "string");
22
+ }
23
+ /** Vrai si la valeur a la forme d'une {@link IChannelPolicy}. */
24
+ function isChannelPolicy(value) {
25
+ if (!isRecord(value)) return false;
26
+ const { authenticated, roles, scopes } = value;
27
+ return (authenticated === void 0 || typeof authenticated === "boolean") && (roles === void 0 || isStringArray(roles)) && (scopes === void 0 || isStringArray(scopes));
28
+ }
29
+ /** Vrai si chaque entrée du registre est une {@link IChannelPolicy}. */
30
+ function isPolicyMap(map) {
31
+ for (const name in map) if (!isChannelPolicy(map[name])) return false;
32
+ return true;
33
+ }
34
+ /** Méthode de l'instance que désigne une entrée de registre, ou `undefined`. */
35
+ function methodOf(instance, prop) {
36
+ return typeof prop === "string" || typeof prop === "symbol" ? Reflect.get(instance, prop) : void 0;
37
+ }
38
+ /** Libération d'un canal dont la fabrique décorée n'en a rendu aucune. */
39
+ const NO_DISPOSE = () => {};
8
40
  /** Enregistre (idempotent) une politique non vide sous son nom de canal/méthode. */
9
41
  function definePolicy(ctor, name, policy) {
10
42
  if (!policy || policy.authenticated === void 0 && policy.roles === void 0 && policy.scopes === void 0) return;
11
- const map = Reflect.getMetadata(POLICIES_KEY, ctor) ?? {};
43
+ const map = { ...readMetadataMap(POLICIES_KEY, ctor) };
12
44
  map[name] = policy;
13
45
  Reflect.defineMetadata(POLICIES_KEY, map, ctor);
14
46
  }
@@ -21,7 +53,7 @@ function definePolicy(ctor, name, policy) {
21
53
  const DEFAULT_ACTION_POLICY = Object.freeze({ authenticated: true });
22
54
  /** Une politique est-elle DÉJÀ déclarée sous ce nom ? */
23
55
  function hasPolicy(ctor, name) {
24
- const map = Reflect.getMetadata(POLICIES_KEY, ctor);
56
+ const map = readMetadataMap(POLICIES_KEY, ctor);
25
57
  return map ? Object.prototype.hasOwnProperty.call(map, name) : false;
26
58
  }
27
59
  /**
@@ -72,7 +104,7 @@ function hasPolicy(ctor, name) {
72
104
  function RealtimeAction(method, policy) {
73
105
  return function(target, propertyKey) {
74
106
  const ctor = target.constructor;
75
- const map = Reflect.getMetadata(ACTIONS_KEY, ctor) ?? {};
107
+ const map = { ...readMetadataMap(ACTIONS_KEY, ctor) };
76
108
  map[method] = propertyKey;
77
109
  Reflect.defineMetadata(ACTIONS_KEY, map, ctor);
78
110
  if (policy) definePolicy(ctor, method, policy);
@@ -109,7 +141,7 @@ function RealtimeAction(method, policy) {
109
141
  function RealtimeChannel(channel, policy) {
110
142
  return function(target, propertyKey) {
111
143
  const ctor = target.constructor;
112
- const map = Reflect.getMetadata(CHANNELS_KEY, ctor) ?? {};
144
+ const map = { ...readMetadataMap(CHANNELS_KEY, ctor) };
113
145
  map[channel] = propertyKey;
114
146
  Reflect.defineMetadata(CHANNELS_KEY, map, ctor);
115
147
  definePolicy(ctor, channel, policy);
@@ -140,42 +172,58 @@ function RealtimeChannel(channel, policy) {
140
172
  function RealtimeInbound(method, policy) {
141
173
  return function(target, propertyKey) {
142
174
  const ctor = target.constructor;
143
- const map = Reflect.getMetadata(INBOUND_KEY, ctor) ?? {};
175
+ const map = { ...readMetadataMap(INBOUND_KEY, ctor) };
144
176
  map[method] = propertyKey;
145
177
  Reflect.defineMetadata(INBOUND_KEY, map, ctor);
146
178
  definePolicy(ctor, method, policy);
147
179
  };
148
180
  }
149
- /** Lecture (lazy, au handshake) du registre des actions décorées. */
150
- function getRealtimeActions(instance) {
151
- const map = Reflect.getMetadata(ACTIONS_KEY, instance.constructor);
181
+ /**
182
+ * Lecture (lazy, au handshake) du registre des actions décorées.
183
+ *
184
+ * @param instance - le contrôleur de la connexion
185
+ * @param wrap - enveloppe optionnelle de chaque action (résolution des
186
+ * paramètres décorés) ; sans elle, l'action reçoit la charge JSON-RPC brute
187
+ * @returns les handlers par nom d'action, ou `null` si aucune n'est décorée
188
+ */
189
+ function getRealtimeActions(instance, wrap) {
190
+ const map = readMetadataMap(ACTIONS_KEY, instance.constructor);
152
191
  if (!map) return null;
153
192
  const out = {};
154
193
  for (const [name, prop] of Object.entries(map)) {
155
- const fn = instance[prop];
156
- if (typeof fn === "function") out[name] = fn.bind(instance);
194
+ if (typeof prop !== "string" && typeof prop !== "symbol") continue;
195
+ const fn = methodOf(instance, prop);
196
+ if (isCallable(fn)) {
197
+ const bound = fn.bind(instance);
198
+ out[name] = wrap !== void 0 ? wrap(bound, prop) : bound;
199
+ }
157
200
  }
158
201
  return out;
159
202
  }
160
203
  /** Lecture (lazy, au handshake) du registre des canaux décorés. */
161
204
  function getRealtimeChannels(instance) {
162
- const map = Reflect.getMetadata(CHANNELS_KEY, instance.constructor);
205
+ const map = readMetadataMap(CHANNELS_KEY, instance.constructor);
163
206
  if (!map) return null;
164
207
  const out = {};
165
208
  for (const [name, prop] of Object.entries(map)) {
166
- const fn = instance[prop];
167
- if (typeof fn === "function") out[name] = fn.bind(instance);
209
+ const fn = methodOf(instance, prop);
210
+ if (!isCallable(fn)) continue;
211
+ const factory = fn.bind(instance);
212
+ out[name] = (channel, publish) => {
213
+ const dispose = factory(channel, publish);
214
+ return isCallable(dispose) ? () => void dispose() : NO_DISPOSE;
215
+ };
168
216
  }
169
217
  return out;
170
218
  }
171
219
  /** Lecture (lazy, au handshake) du registre des canaux inbound décorés. */
172
220
  function getRealtimeInbound(instance) {
173
- const map = Reflect.getMetadata(INBOUND_KEY, instance.constructor);
221
+ const map = readMetadataMap(INBOUND_KEY, instance.constructor);
174
222
  if (!map) return null;
175
223
  const out = {};
176
224
  for (const [name, prop] of Object.entries(map)) {
177
- const fn = instance[prop];
178
- if (typeof fn === "function") out[name] = fn.bind(instance);
225
+ const fn = methodOf(instance, prop);
226
+ if (isCallable(fn)) out[name] = fn.bind(instance);
179
227
  }
180
228
  return out;
181
229
  }
@@ -187,7 +235,10 @@ function getRealtimeInbound(instance) {
187
235
  * hub realtime, qui l'expose à `@nodefony/security`.
188
236
  */
189
237
  function getRealtimeChannelPolicies(instance) {
190
- return Reflect.getMetadata(POLICIES_KEY, instance.constructor) ?? null;
238
+ const map = readMetadataMap(POLICIES_KEY, instance.constructor);
239
+ if (map === void 0) return null;
240
+ if (!isPolicyMap(map)) throw new TypeError(`${instance.constructor.name} : politique de canal temps réel illisible`);
241
+ return map;
191
242
  }
192
243
  /**
193
244
  * Préfixes de canaux **broadcast** déclarés statiquement, tous controllers
@@ -1,3 +1,4 @@
1
+ import { isCallable, isRecord, isThenable } from "../guards.js";
1
2
  import resolveBackplaneOriginId from "./originId.js";
2
3
  import { openBackplaneEnvelope, sealBackplaneEnvelope } from "./envelope.js";
3
4
  import { BackplanePublishQueue } from "./publishQueue.js";
@@ -43,20 +44,36 @@ function resolveRedisChannel(namespace) {
43
44
  * Le listener Redis est conservé pour un `unsubscribe(channel, listener)` ciblé
44
45
  * (n'arrache pas d'éventuels autres abonnés du même client).
45
46
  */
47
+ /**
48
+ * Vrai si la valeur expose la surface d'un client Redis publisher — vérifié au
49
+ * démarrage, sur le client que rend le service `redis`.
50
+ */
51
+ function isRedisPublisher(value) {
52
+ return isRecord(value) && isCallable(value.publish);
53
+ }
54
+ /** Vrai si la valeur expose la surface d'un client Redis subscriber dédié. */
55
+ function isRedisSubscriber(value) {
56
+ return isRecord(value) && isCallable(value.subscribe) && isCallable(value.unsubscribe);
57
+ }
58
+ /** Ramène l'acquittement d'une commande Redis au contrat du transport. */
59
+ function settled(ack) {
60
+ return isThenable(ack) ? ack.then(() => void 0) : void 0;
61
+ }
46
62
  function createRedisServiceTransport(publisher, subscriber) {
47
63
  let listener = null;
48
64
  return {
49
65
  publish(channel, message) {
50
- return publisher.publish(channel, message);
66
+ const ack = publisher.publish(channel, message);
67
+ return isThenable(ack) ? ack : void 0;
51
68
  },
52
69
  subscribe(channel, onMessage) {
53
70
  listener = (message) => onMessage(message);
54
- return subscriber.subscribe(channel, listener);
71
+ return settled(subscriber.subscribe(channel, listener));
55
72
  },
56
73
  unsubscribe(channel) {
57
74
  const current = listener;
58
75
  listener = null;
59
- return current ? subscriber.unsubscribe(channel, current) : subscriber.unsubscribe(channel);
76
+ return settled(current ? subscriber.unsubscribe(channel, current) : subscriber.unsubscribe(channel));
60
77
  }
61
78
  };
62
79
  }
@@ -173,4 +190,4 @@ var RedisBackplane = class RedisBackplane {
173
190
  }
174
191
  };
175
192
  //#endregion
176
- export { REDIS_RT_CHANNEL, RedisBackplane, RedisBackplane as default, createRedisServiceTransport, resolveRedisChannel };
193
+ export { REDIS_RT_CHANNEL, RedisBackplane, RedisBackplane as default, createRedisServiceTransport, isRedisPublisher, isRedisSubscriber, resolveRedisChannel };
@@ -0,0 +1,43 @@
1
+ //#region nodefony/src/backplane/bootNotices.ts
2
+ /** Le geste qui rend le temps réel partagé entre processus et pods. */
3
+ const REDIS_BACKPLANE_FIX = "use(\"@nodefony/realtime\", { backplane: { driver: \"redis\" } }) + NF_REDIS_URL=redis://…";
4
+ /**
5
+ * Le temps réel ne dépasse pas ce processus : aucun backplane actif (driver
6
+ * `loopback`, `cluster` hors d'un worker, ou fabrique inactive ici).
7
+ *
8
+ * Simple information en développement mono-processus — c'est le cas normal —
9
+ * mais à savoir avant de déployer plusieurs exemplaires : un message publié
10
+ * sur l'un n'atteindrait pas les clients connectés aux autres.
11
+ *
12
+ * @param driver - le driver déclaré.
13
+ * @returns le point à déclarer.
14
+ */
15
+ function realtimeLocalOnlyNotice(driver) {
16
+ return {
17
+ code: "REALTIME_LOCAL_ONLY",
18
+ level: "info",
19
+ message: `Temps réel limité à ce processus (backplane « ${driver} » inactif ici)`,
20
+ fix: REDIS_BACKPLANE_FIX
21
+ };
22
+ }
23
+ /**
24
+ * Le backplane déclaré n'a pas pu démarrer — le hub est retombé en local.
25
+ *
26
+ * Distinct de {@link realtimeLocalOnlyNotice} : ici, l'application a DEMANDÉ
27
+ * un fan-out entre exemplaires et ne l'a pas. Deux pods servent alors deux
28
+ * mondes temps réel disjoints, sans erreur visible.
29
+ *
30
+ * @param driver - le driver déclaré.
31
+ * @param reason - la cause (message de l'échec).
32
+ * @returns le point à déclarer.
33
+ */
34
+ function realtimeBackplaneDownNotice(driver, reason) {
35
+ return {
36
+ code: "REALTIME_BACKPLANE_DOWN",
37
+ level: "warning",
38
+ message: `Backplane temps réel « ${driver} » indisponible (${reason}) — retombé sur ce seul processus`,
39
+ fix: driver === "redis" ? "vérifier NF_REDIS_URL et que Redis répond" : void 0
40
+ };
41
+ }
42
+ //#endregion
43
+ export { realtimeBackplaneDownNotice, realtimeLocalOnlyNotice };
@@ -1,3 +1,4 @@
1
+ import { isThenable } from "../guards.js";
1
2
  /**
2
3
  * Seuil par défaut d'octets en vol vers le bus : **8 MiB**, aligné sur le seuil de
3
4
  * coupure d'un slow-consumer WS. Un bus sain acquitte en quelques millisecondes
@@ -5,10 +6,6 @@
5
6
  * au-dessus du régime nominal — le pod a un problème bien avant d'y arriver.
6
7
  */
7
8
  const DEFAULT_MAX_QUEUE_BYTES = 1 << 23;
8
- /** Vrai si la valeur se comporte comme une promesse (transport asynchrone). */
9
- function isThenable(v) {
10
- return typeof v === "object" && v !== null && typeof v.then === "function";
11
- }
12
9
  /**
13
10
  * File d'envoi bornée : décide d'admettre ou de jeter une publication, puis suit
14
11
  * son acquittement pour rendre la place.
@@ -1,5 +1,15 @@
1
+ import { isRecord } from "../guards.js";
1
2
  import { CLUSTER_PROBE_CTL_KIND, CLUSTER_PROBE_KIND, CLUSTER_PROBE_SNAPSHOT_KIND, RichProcessProbe, isClusterMessage, isClusterProbeEnrich, readOrmRich } from "nodefony";
2
3
  //#region nodefony/src/cluster/ClusterProbeClient.ts
4
+ /**
5
+ * Vrai si la valeur est une liste de santés d'instance. Le snapshot vient du
6
+ * master du cluster par IPC — un process de la MÊME application, pas le
7
+ * réseau : on vérifie la forme qui sert de clé (`instanceId`), le reste est
8
+ * la sonde du même code.
9
+ */
10
+ function isRealtimeHealthList(value) {
11
+ return Array.isArray(value) && value.every((h) => isRecord(h) && typeof h.instanceId === "string");
12
+ }
3
13
  /** Transport de production : `process.send` + `process.on("message")`. */
4
14
  const processProbeTransport = {
5
15
  send(report) {
@@ -213,10 +223,11 @@ var ClusterProbeClient = class {
213
223
  return;
214
224
  }
215
225
  if (!isClusterMessage(msg) || msg.kind !== CLUSTER_PROBE_SNAPSHOT_KIND) return;
216
- const snap = msg;
217
- if (Array.isArray(snap.instances)) {
218
- this.#lastInstances = snap.instances;
219
- this.#snapTs = typeof snap.ts === "number" ? snap.ts : Date.now();
226
+ if (!isRecord(msg)) return;
227
+ const { ts, instances } = msg;
228
+ if (isRealtimeHealthList(instances)) {
229
+ this.#lastInstances = instances;
230
+ this.#snapTs = typeof ts === "number" ? ts : Date.now();
220
231
  }
221
232
  }
222
233
  /** Vue POD agrégée, ou `null` tant qu'aucun snapshot n'est reçu (cold start). */
@@ -0,0 +1,41 @@
1
+ //#region nodefony/src/guards.ts
2
+ /**
3
+ * Gardes de type internes au paquet — elles remplacent une conversion (`as`)
4
+ * par une vérification faite à l'exécution, pour qu'une valeur d'une autre
5
+ * forme soit écartée au lieu de traverser le code sous un type qui ment.
6
+ *
7
+ * Non exportées par le paquet : ce sont des détails d'implémentation.
8
+ */
9
+ /**
10
+ * Vrai si la valeur est un objet non nul : ses propriétés se lisent alors,
11
+ * typées `unknown`, et chacune se vérifie avant usage.
12
+ *
13
+ * @param value - valeur venue du réseau, d'une métadonnée ou du conteneur
14
+ * @returns `true` pour tout objet (tableau compris), `false` pour `null` et les primitives
15
+ */
16
+ function isRecord(value) {
17
+ return typeof value === "object" && value !== null;
18
+ }
19
+ /**
20
+ * Vrai si la valeur est une fonction. Toute fonction JavaScript s'appelle avec
21
+ * n'importe quels arguments et rend une valeur inconnue : la signature annoncée
22
+ * ne promet rien de plus que ce que `typeof` a vérifié.
23
+ *
24
+ * @param value - valeur lue par son nom (métadonnée, propriété dynamique)
25
+ * @returns `true` si la valeur est appelable
26
+ */
27
+ function isCallable(value) {
28
+ return typeof value === "function";
29
+ }
30
+ /**
31
+ * Vrai si la valeur se comporte comme une promesse (transport asynchrone) —
32
+ * un client Redis rend une promesse, un faux transport de test rend `void`.
33
+ *
34
+ * @param value - retour d'un transport
35
+ * @returns `true` si la valeur porte une méthode `then`
36
+ */
37
+ function isThenable(value) {
38
+ return isRecord(value) && typeof value.then === "function";
39
+ }
40
+ //#endregion
41
+ export { isCallable, isRecord, isThenable };
@@ -12,7 +12,6 @@
12
12
  * token avec `isAuthenticated() === false` et appliqueront leur politique
13
13
  * (Zero Trust : zone protégée → frame refusée par `beforeDispatch`).
14
14
  */
15
- const anonymousAttributes = Object.freeze({});
16
15
  const ANONYMOUS_REALTIME_TOKEN = Object.freeze({
17
16
  type: "anonymous",
18
17
  getUserIdentifier() {
@@ -27,9 +26,7 @@ const ANONYMOUS_REALTIME_TOKEN = Object.freeze({
27
26
  getScopes() {
28
27
  return [];
29
28
  },
30
- getAttribute(key) {
31
- return anonymousAttributes[key];
32
- }
29
+ getAttribute(_key) {}
33
30
  });
34
31
  //#endregion
35
32
  export { ANONYMOUS_REALTIME_TOKEN, ANONYMOUS_REALTIME_TOKEN as default };