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

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 (63) hide show
  1. package/README.md +2 -2
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  3. package/dist/index.js +18 -13
  4. package/dist/nodefony/config/config.js +7 -5
  5. package/dist/nodefony/config/configSchema.js +18 -0
  6. package/dist/nodefony/config/defineModuleConfig.js +43 -17
  7. package/dist/nodefony/decorators/realtimeDecorators.js +69 -19
  8. package/dist/nodefony/interfaces/IRealtimeConnectionState.js +1 -0
  9. package/dist/nodefony/src/backplane/ClusterBackplane.js +1 -2
  10. package/dist/nodefony/src/backplane/LoopbackBackplane.js +1 -2
  11. package/dist/nodefony/src/backplane/RedisBackplane.js +22 -6
  12. package/dist/nodefony/src/backplane/backplaneRegistry.js +1 -2
  13. package/dist/nodefony/src/backplane/bootNotices.js +42 -0
  14. package/dist/nodefony/src/backplane/envelope.js +1 -2
  15. package/dist/nodefony/src/backplane/originId.js +1 -2
  16. package/dist/nodefony/src/backplane/publishQueue.js +1 -5
  17. package/dist/nodefony/src/cluster/ClusterProbeClient.js +16 -6
  18. package/dist/nodefony/src/guards.js +40 -0
  19. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +2 -6
  20. package/dist/nodefony/src/server/RealtimeAdminApi.js +1 -2
  21. package/dist/nodefony/src/server/RealtimeController.js +226 -82
  22. package/dist/nodefony/src/server/RealtimeHub.js +32 -54
  23. package/dist/nodefony/src/server/ServerRealtimeSocket.js +30 -13
  24. package/dist/nodefony/src/server/deniedDetail.js +1 -2
  25. package/dist/nodefony/src/server/subscribedAck.js +29 -0
  26. package/dist/nodefony/src/server/syslogUplink.js +5 -4
  27. package/dist/nodefony/src/server/welcomeEnv.js +1 -2
  28. package/dist/nodefony/src/service/RealtimeService.js +4 -4
  29. package/dist/nodefony/src/transport/WsConnectionTransport.js +42 -2
  30. package/dist/nodefony/testing/index.js +118 -5
  31. package/dist/types/index.d.ts +1 -0
  32. package/dist/types/nodefony/config/configSchema.d.ts +12 -0
  33. package/dist/types/nodefony/config/defineModuleConfig.d.ts +29 -6
  34. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +19 -2
  35. package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +40 -0
  36. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +1 -1
  37. package/dist/types/nodefony/interfaces/IRealtimeHandshake.d.ts +1 -1
  38. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +13 -0
  39. package/dist/types/nodefony/interfaces/index.d.ts +1 -0
  40. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +7 -0
  41. package/dist/types/nodefony/src/backplane/bootNotices.d.ts +25 -0
  42. package/dist/types/nodefony/src/guards.d.ts +32 -0
  43. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +13 -0
  44. package/dist/types/nodefony/src/server/RealtimeController.d.ts +82 -4
  45. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +22 -16
  46. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +18 -8
  47. package/dist/types/nodefony/src/server/subscribedAck.d.ts +23 -0
  48. package/dist/types/nodefony/src/service/RealtimeService.d.ts +1 -1
  49. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +26 -1
  50. package/dist/types/nodefony/testing/index.d.ts +8 -3
  51. package/docs/actions.md +129 -45
  52. package/docs/architecture.md +143 -135
  53. package/docs/cadence-et-contre-pression.md +643 -0
  54. package/docs/configuration.md +126 -97
  55. package/docs/cookbook-chat.md +50 -43
  56. package/docs/index.md +63 -38
  57. package/docs/observabilite.md +58 -56
  58. package/docs/protocole.md +110 -76
  59. package/docs/securite.md +128 -117
  60. package/docs/vocabulaire.md +133 -129
  61. package/package.json +17 -13
  62. package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
  63. 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,9 +1,8 @@
1
- //#region \0@oxc-project+runtime@0.152.0/helpers/esm/decorate.js
1
+ // \0@oxc-project+runtime@0.155.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);
5
5
  else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
6
  return c > 3 && r && Object.defineProperty(target, key, r), r;
7
7
  }
8
- //#endregion
9
8
  export { __decorate as default };
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,15 +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";
21
- //#region index.ts
20
+ import __decorate from "./_virtual/_@oxc-project_runtime@0.155.0/helpers/esm/decorate.js";
21
+ import { IDENTITY_CHANGED_EVENT, Module, services, withTimeout } from "nodefony";
22
+ // index.ts
22
23
  /**
23
24
  * @nodefony/realtime — Couche realtime serveur Nodefony.
24
25
  *
@@ -51,13 +52,13 @@ registerBackplaneDriver(LoopbackBackplane.driver, () => null);
51
52
  registerBackplaneDriver(ClusterBackplane.driver, (ctx) => ctx.role === "WORKER" && process.env.NF_CLUSTER === "1" ? new ClusterBackplane(processIpcTransport, ctx.originId) : null);
52
53
  registerBackplaneDriver(RedisBackplane.driver, (ctx) => {
53
54
  const redisService = ctx.module.kernel?.container?.get("redis");
54
- if (!redisService) {
55
+ if (!isRecord(redisService) || !isCallable(redisService.getClient)) {
55
56
  ctx.module.log(`driver "${RedisBackplane.driver}" : module @nodefony/redis absent (non listé dans @modules) — RealtimeHub reste local`, "WARNING");
56
57
  return null;
57
58
  }
58
59
  const publisher = redisService.getClient("publish");
59
60
  const subscriber = redisService.getClient("subscribe");
60
- if (!publisher || !subscriber) {
61
+ if (!isRedisPublisher(publisher) || !isRedisSubscriber(subscriber)) {
61
62
  ctx.module.log(`driver "${RedisBackplane.driver}" : connexions Redis publish/subscribe indisponibles — RealtimeHub reste local`, "WARNING");
62
63
  return null;
63
64
  }
@@ -96,7 +97,7 @@ let Realtime = class Realtime extends Module {
96
97
  * sans dupliquer la validation.
97
98
  */
98
99
  async onKernelRegister() {
99
- const validated = defineRealtimeConfig(this.options ?? {});
100
+ const validated = buildRealtimeConfig(this.options);
100
101
  this.options = validated;
101
102
  return this;
102
103
  }
@@ -117,7 +118,10 @@ let Realtime = class Realtime extends Module {
117
118
  return this;
118
119
  }
119
120
  const broker = this.kernel?.container?.get("adminBroker");
120
- if (broker && !broker.has("realtime")) broker.register(createRealtimeAdminApi());
121
+ if (isRecord(broker) && isCallable(broker.has) && isCallable(broker.register) && broker.has("realtime") !== true) broker.register(createRealtimeAdminApi());
122
+ this.kernel?.on(IDENTITY_CHANGED_EVENT, (event) => {
123
+ getRealtimeHub().refreshIdentity(event.identifier);
124
+ });
121
125
  await this.#wireBackplane();
122
126
  this.#wireClusterProbe(this.#clusterRole);
123
127
  return this;
@@ -157,12 +161,14 @@ let Realtime = class Realtime extends Module {
157
161
  });
158
162
  if (!backplane) {
159
163
  this.log(`realtime backplane driver=${driverName} kind=local cross-pod=no (hub local)`, "INFO");
164
+ this.kernel?.reportBootNotice(realtimeLocalOnlyNotice(driverName));
160
165
  return;
161
166
  }
162
167
  try {
163
168
  await this.#startWithTimeout(backplane, BACKPLANE_START_TIMEOUT_MS);
164
169
  } catch (e) {
165
- this.log(`realtime backplane driver=${driverName} indisponible (${e.message}) — fallback hub LOCAL, boot poursuivi (pas de fan-out cross-pod)`, "WARNING");
170
+ 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");
171
+ this.kernel?.reportBootNotice(realtimeBackplaneDownNotice(driverName, e instanceof Error ? e.message : String(e)));
166
172
  return;
167
173
  }
168
174
  hub.setBackplane(backplane);
@@ -212,7 +218,6 @@ let Realtime = class Realtime extends Module {
212
218
  }
213
219
  }
214
220
  };
215
- Realtime = __decorate([services([RealtimeService]), __decorateMetadata("design:paramtypes", [typeof Kernel === "undefined" ? Object : Kernel])], Realtime);
221
+ Realtime = __decorate([services([RealtimeService])], Realtime);
216
222
  var realtime_default = Realtime;
217
- //#endregion
218
223
  export { ANONYMOUS_REALTIME_TOKEN, BROWSER_ORIGIN, ClusterBackplane, ClusterProbeClient, LoopbackBackplane, MAX_CLIENT_SEVERITY, REDIS_RT_CHANNEL, RESERVED_SYSTEM_PREFIXES, Realtime, RealtimeAction, RealtimeBroadcast, RealtimeChannel, RealtimeController, RealtimeHub, RealtimeInbound, RealtimeService, RedisBackplane, SLOW_CONSUMER_BYTES, ServerRealtimeSocket, WsConnectionTransport, buildOwnHealth, buildRealtimeHealth, clusterProbeHealth, clusterProbeInstance, clusterProbeRequestEnrich, createRealtimeAdminApi, createRedisServiceTransport, createSyslogUplinkHandler, realtime_default as default, defineRealtimeConfig, getBackplaneDriver, getDeclaredBroadcastPrefixes, getRealtimeHub, isReservedSystemChannel, listBackplaneDrivers, mergeClusterHealth, processIpcTransport, processProbeTransport, realtimeConfigJsonSchema, registerBackplaneDriver, resolveBackplaneOriginId, resolveRedisChannel, serverSocket, setClusterProbeClient };
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- //#region nodefony/config/config.ts
2
+ // nodefony/config/config.ts
3
3
  /**
4
4
  * @nodefony/realtime — CONFIGURATION DU MODULE (schéma Zod = source unique).
5
5
  *
@@ -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).");
@@ -59,5 +62,4 @@ const realtimeConfigSchema = z.strictObject({
59
62
  * valides par construction ; passés au `super(..., config)` du Module class.
60
63
  */
61
64
  const config = realtimeConfigSchema.parse({});
62
- //#endregion
63
65
  export { config as default, realtimeConfigSchema };
@@ -0,0 +1,18 @@
1
+ import { realtimeConfigSchema } from "./config.js";
2
+ import { z } from "zod";
3
+ // 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
+ export { configJsonSchema };
@@ -1,7 +1,7 @@
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
- //#region nodefony/config/defineModuleConfig.ts
4
+ // nodefony/config/defineModuleConfig.ts
5
5
  /**
6
6
  * Builder type-safe de la configuration de `@nodefony/realtime`.
7
7
  *
@@ -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,52 @@ 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
- //#endregion
60
- export { defineRealtimeConfig, realtimeConfigJsonSchema };
86
+ export { buildRealtimeConfig, defineRealtimeConfig, isRealtimeConfig, realtimeConfigJsonSchema };
@@ -1,14 +1,46 @@
1
+ import { isCallable, isRecord } from "../src/guards.js";
1
2
  import "reflect-metadata";
2
- //#region nodefony/decorators/realtimeDecorators.ts
3
+ // nodefony/decorators/realtimeDecorators.ts
3
4
  /** Clés de métadonnées posées sur le constructor de la classe controller. */
4
5
  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
@@ -236,5 +287,4 @@ function getDeclaredBroadcastPrefixes() {
236
287
  function applyDeclaredBroadcastPrefixes(hub) {
237
288
  for (const prefix of declaredBroadcastPrefixes) hub.markBroadcastChannel(prefix);
238
289
  }
239
- //#endregion
240
290
  export { DEFAULT_ACTION_POLICY, RealtimeAction, RealtimeBroadcast, RealtimeChannel, RealtimeInbound, applyDeclaredBroadcastPrefixes, getDeclaredBroadcastPrefixes, getRealtimeActions, getRealtimeChannelPolicies, getRealtimeChannels, getRealtimeInbound };
@@ -1,6 +1,6 @@
1
1
  import resolveBackplaneOriginId from "./originId.js";
2
2
  import { CLUSTER_RT_KIND } from "nodefony";
3
- //#region nodefony/src/backplane/ClusterBackplane.ts
3
+ // nodefony/src/backplane/ClusterBackplane.ts
4
4
  /**
5
5
  * Transport de production : `process.send` (émission) + `process.on("message")`
6
6
  * (réception). Standalone — utilisable tel quel. En vrai câblage cluster (Phase 3c),
@@ -101,5 +101,4 @@ var ClusterBackplane = class ClusterBackplane {
101
101
  };
102
102
  }
103
103
  };
104
- //#endregion
105
104
  export { ClusterBackplane, ClusterBackplane as default, processIpcTransport };
@@ -1,5 +1,5 @@
1
1
  import resolveBackplaneOriginId from "./originId.js";
2
- //#region nodefony/src/backplane/LoopbackBackplane.ts
2
+ // nodefony/src/backplane/LoopbackBackplane.ts
3
3
  /**
4
4
  * Backplane **mono-process no-op** — implémentation de RÉFÉRENCE du port
5
5
  * {@link IBackplane} quand il n'existe **aucun pair** (1 process = 1 pod).
@@ -36,5 +36,4 @@ var LoopbackBackplane = class LoopbackBackplane {
36
36
  };
37
37
  }
38
38
  };
39
- //#endregion
40
39
  export { LoopbackBackplane, LoopbackBackplane as default };
@@ -1,7 +1,8 @@
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";
4
- //#region nodefony/src/backplane/RedisBackplane.ts
5
+ // nodefony/src/backplane/RedisBackplane.ts
5
6
  /**
6
7
  * BASE du canal Redis **dédié** transportant les enveloppes realtime entre pods.
7
8
  * Un seul canal pub/sub par app (les canaux logiques voyagent DANS l'enveloppe,
@@ -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
  }
@@ -172,5 +189,4 @@ var RedisBackplane = class RedisBackplane {
172
189
  };
173
190
  }
174
191
  };
175
- //#endregion
176
- export { REDIS_RT_CHANNEL, RedisBackplane, RedisBackplane as default, createRedisServiceTransport, resolveRedisChannel };
192
+ export { REDIS_RT_CHANNEL, RedisBackplane, RedisBackplane as default, createRedisServiceTransport, isRedisPublisher, isRedisSubscriber, resolveRedisChannel };
@@ -1,4 +1,4 @@
1
- //#region nodefony/src/backplane/backplaneRegistry.ts
1
+ // nodefony/src/backplane/backplaneRegistry.ts
2
2
  const drivers = /* @__PURE__ */ new Map();
3
3
  /**
4
4
  * Enregistre (ou remplace) la fabrique d'un driver backplane. Appelé par les
@@ -16,5 +16,4 @@ function getBackplaneDriver(name) {
16
16
  function listBackplaneDrivers() {
17
17
  return [...drivers.keys()];
18
18
  }
19
- //#endregion
20
19
  export { getBackplaneDriver, listBackplaneDrivers, registerBackplaneDriver };
@@ -0,0 +1,42 @@
1
+ // 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
+ export { realtimeBackplaneDownNotice, realtimeLocalOnlyNotice };
@@ -1,5 +1,5 @@
1
1
  import { createHmac, timingSafeEqual } from "node:crypto";
2
- //#region nodefony/src/backplane/envelope.ts
2
+ // nodefony/src/backplane/envelope.ts
3
3
  /**
4
4
  * **Sceau d'enveloppe backplane** — authenticité des messages transportés sur un
5
5
  * bus **PARTAGÉ** (Redis pub/sub, et tout driver userland cross-host : NATS,
@@ -106,5 +106,4 @@ function openBackplaneEnvelope(raw, secret) {
106
106
  if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
107
107
  return msg;
108
108
  }
109
- //#endregion
110
109
  export { openBackplaneEnvelope, sealBackplaneEnvelope };
@@ -1,6 +1,6 @@
1
1
  import { hostname } from "node:os";
2
2
  import { randomUUID } from "node:crypto";
3
- //#region nodefony/src/backplane/originId.ts
3
+ // nodefony/src/backplane/originId.ts
4
4
  /**
5
5
  * Résout l'identifiant d'origine d'un backplane — l'étiquette que l'anti-écho
6
6
  * compare pour jeter les messages qui reviennent à leur émetteur.
@@ -31,5 +31,4 @@ function resolveBackplaneOriginId() {
31
31
  if (!host) return randomUUID();
32
32
  return `${host}:${process.pid}`;
33
33
  }
34
- //#endregion
35
34
  export { resolveBackplaneOriginId as default, resolveBackplaneOriginId };
@@ -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.
@@ -92,5 +89,4 @@ var BackplanePublishQueue = class {
92
89
  };
93
90
  }
94
91
  };
95
- //#endregion
96
92
  export { BackplanePublishQueue, BackplanePublishQueue as default, DEFAULT_MAX_QUEUE_BYTES };