@nodefony/realtime 10.0.0-alpha.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.
Files changed (81) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +207 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/index.js +218 -0
  6. package/dist/nodefony/config/config.js +63 -0
  7. package/dist/nodefony/config/defineModuleConfig.js +60 -0
  8. package/dist/nodefony/decorators/realtimeDecorators.js +240 -0
  9. package/dist/nodefony/interfaces/IBackplane.js +1 -0
  10. package/dist/nodefony/interfaces/IChannelPolicy.js +1 -0
  11. package/dist/nodefony/interfaces/IClientLogsLimits.js +1 -0
  12. package/dist/nodefony/interfaces/IRealtimeAuthenticator.js +1 -0
  13. package/dist/nodefony/interfaces/IRealtimeAuthenticatorMatcher.js +1 -0
  14. package/dist/nodefony/interfaces/IRealtimeController.js +1 -0
  15. package/dist/nodefony/interfaces/IRealtimeHandshake.js +1 -0
  16. package/dist/nodefony/interfaces/IRealtimeProbe.js +1 -0
  17. package/dist/nodefony/interfaces/IRealtimeToken.js +1 -0
  18. package/dist/nodefony/interfaces/index.js +1 -0
  19. package/dist/nodefony/src/backplane/ClusterBackplane.js +105 -0
  20. package/dist/nodefony/src/backplane/LoopbackBackplane.js +40 -0
  21. package/dist/nodefony/src/backplane/RedisBackplane.js +176 -0
  22. package/dist/nodefony/src/backplane/backplaneRegistry.js +20 -0
  23. package/dist/nodefony/src/backplane/envelope.js +109 -0
  24. package/dist/nodefony/src/backplane/originId.js +35 -0
  25. package/dist/nodefony/src/backplane/publishQueue.js +96 -0
  26. package/dist/nodefony/src/cluster/ClusterProbeClient.js +284 -0
  27. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +35 -0
  28. package/dist/nodefony/src/server/RealtimeAdminApi.js +87 -0
  29. package/dist/nodefony/src/server/RealtimeController.js +601 -0
  30. package/dist/nodefony/src/server/RealtimeHub.js +926 -0
  31. package/dist/nodefony/src/server/ServerRealtimeSocket.js +168 -0
  32. package/dist/nodefony/src/server/deniedDetail.js +29 -0
  33. package/dist/nodefony/src/server/syslogUplink.js +105 -0
  34. package/dist/nodefony/src/server/welcomeEnv.js +25 -0
  35. package/dist/nodefony/src/service/RealtimeService.js +240 -0
  36. package/dist/nodefony/src/transport/WsConnectionTransport.js +94 -0
  37. package/dist/nodefony/testing/index.js +163 -0
  38. package/dist/types/index.d.ts +99 -0
  39. package/dist/types/nodefony/config/config.d.ts +43 -0
  40. package/dist/types/nodefony/config/defineModuleConfig.d.ts +49 -0
  41. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +190 -0
  42. package/dist/types/nodefony/interfaces/IBackplane.d.ts +128 -0
  43. package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +28 -0
  44. package/dist/types/nodefony/interfaces/IClientLogsLimits.d.ts +22 -0
  45. package/dist/types/nodefony/interfaces/IRealtimeAuthenticator.d.ts +53 -0
  46. package/dist/types/nodefony/interfaces/IRealtimeAuthenticatorMatcher.d.ts +46 -0
  47. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +36 -0
  48. package/dist/types/nodefony/interfaces/IRealtimeHandshake.d.ts +27 -0
  49. package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +206 -0
  50. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +48 -0
  51. package/dist/types/nodefony/interfaces/index.d.ts +18 -0
  52. package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +76 -0
  53. package/dist/types/nodefony/src/backplane/LoopbackBackplane.d.ts +29 -0
  54. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +164 -0
  55. package/dist/types/nodefony/src/backplane/backplaneRegistry.d.ts +51 -0
  56. package/dist/types/nodefony/src/backplane/envelope.d.ts +27 -0
  57. package/dist/types/nodefony/src/backplane/originId.d.ts +22 -0
  58. package/dist/types/nodefony/src/backplane/publishQueue.d.ts +71 -0
  59. package/dist/types/nodefony/src/cluster/ClusterProbeClient.d.ts +90 -0
  60. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +3 -0
  61. package/dist/types/nodefony/src/server/RealtimeAdminApi.d.ts +18 -0
  62. package/dist/types/nodefony/src/server/RealtimeController.d.ts +165 -0
  63. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +516 -0
  64. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +63 -0
  65. package/dist/types/nodefony/src/server/deniedDetail.d.ts +25 -0
  66. package/dist/types/nodefony/src/server/syslogUplink.d.ts +58 -0
  67. package/dist/types/nodefony/src/server/welcomeEnv.d.ts +21 -0
  68. package/dist/types/nodefony/src/service/RealtimeService.d.ts +167 -0
  69. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +78 -0
  70. package/dist/types/nodefony/testing/index.d.ts +169 -0
  71. package/docs/actions.md +573 -0
  72. package/docs/architecture.md +889 -0
  73. package/docs/configuration.md +803 -0
  74. package/docs/cookbook-chat.md +1619 -0
  75. package/docs/couverture-bancs.md +139 -0
  76. package/docs/index.md +372 -0
  77. package/docs/observabilite.md +592 -0
  78. package/docs/protocole.md +518 -0
  79. package/docs/securite.md +845 -0
  80. package/docs/vocabulaire.md +845 -0
  81. package/package.json +83 -0
package/README.md ADDED
@@ -0,0 +1,207 @@
1
+ # @nodefony/realtime
2
+
3
+ Couche realtime serveur Nodefony — **la Socket Nodefony** : hub WebSocket fan-out, protocole
4
+ JSON-RPC 2.0 (peer isomorphe partagé avec le client du cœur) et backplane cluster interchangeable
5
+ (`loopback`, `cluster` IPC, `redis`) ouvert aux drivers écrits par l'application.
6
+
7
+ > 📖 **La doc complète est dans [`docs/`](./docs/)** — vulgarisée, avec analogies physiques,
8
+ > schémas et exemples. Lis-la dans Studio (`/nodefony/documentation` → section Realtime) pour le
9
+ > rendu Mermaid + admonitions + blocs de code copiables.
10
+
11
+ ## Installation
12
+
13
+ Workspace npm — déjà inclus dans `nodefony-core`. Dans une application, le module se déclare dans
14
+ le manifeste `modules` de `nodefony.config.ts` :
15
+
16
+ ```typescript
17
+ import { defineConfig, use } from "nodefony";
18
+
19
+ export default defineConfig(() => ({
20
+ modules: [
21
+ "@nodefony/http",
22
+ "@nodefony/framework",
23
+ use("@nodefony/realtime", { backplane: { driver: "loopback" } }),
24
+ ],
25
+ }));
26
+ ```
27
+
28
+ ## Usage côté client
29
+
30
+ Le client navigateur **n'est pas dans ce module** : il vit dans le cœur, importable sans dépendre
31
+ du serveur (isomorphisme). Le subpath est **`nodefony/client`**.
32
+
33
+ ```typescript
34
+ import { RealtimeClient } from "nodefony/client";
35
+
36
+ const socket = RealtimeClient.shared({ url: "wss://app.com/realtime" });
37
+
38
+ await socket.subscribe("chat:room");
39
+ socket.on("chat:room", (msg) => console.log(msg));
40
+
41
+ // RPC bidirectionnel
42
+ const pong = await socket.request("nodefony:kernel:ping");
43
+ ```
44
+
45
+ En React, les hooks sont sous `nodefony/react` (`useNodefony`, `useNodefonyChannel`…).
46
+
47
+ ## Usage côté serveur
48
+
49
+ Un controller realtime est un controller Nodefony ordinaire qui étend la classe abstraite
50
+ `RealtimeController`. Le protocole (poignée de main, découpage des frames, abonnements, nettoyage)
51
+ est porté par la base ; le controller ne déclare que son métier.
52
+
53
+ ```typescript
54
+ import { controller, route } from "@nodefony/framework";
55
+ import {
56
+ RealtimeController,
57
+ RealtimeChannel,
58
+ RealtimeInbound,
59
+ } from "@nodefony/realtime";
60
+ import type { RealtimePublish } from "@nodefony/realtime";
61
+ import type { ContextType } from "@nodefony/http";
62
+
63
+ @controller("/chat")
64
+ class ChatController extends RealtimeController {
65
+ constructor(context: ContextType) {
66
+ super("chat", context);
67
+ }
68
+
69
+ /** La porte : l'URL WebSocket que le navigateur ouvre. */
70
+ @route("chat-realtime", {
71
+ path: "/realtime",
72
+ requirements: { methods: ["WEBSOCKET"] },
73
+ })
74
+ async realtime(message: string | Buffer | null): Promise<void> {
75
+ this.handleRealtime(message);
76
+ }
77
+
78
+ /** Un canal sortant : appelé au PREMIER abonné, rend sa fonction de nettoyage. */
79
+ @RealtimeChannel("chat:room", { authenticated: true })
80
+ room(_channel: string, _publish: RealtimePublish): () => void {
81
+ return () => {};
82
+ }
83
+
84
+ /** Un canal entrant : le client pousse vers le serveur. Rien n'est ouvert par défaut. */
85
+ @RealtimeInbound("chat:send", { authenticated: true })
86
+ onSend(params: unknown, reply: (payload: unknown) => void): void {
87
+ // `params` vient du réseau : le valider avant toute chose.
88
+ reply({ ok: true });
89
+ }
90
+ }
91
+ ```
92
+
93
+ Les trois décorateurs prennent `(nom, policy?)` — la politique déclare qui a le droit
94
+ (`authenticated`, `roles`). Une action sans politique reste fermée par défaut.
95
+
96
+ Recette complète (salon, présence, historique, upload) : [`docs/cookbook-chat.md`](./docs/cookbook-chat.md).
97
+
98
+ ## Backplane
99
+
100
+ Le backplane est le fond de panier qui relie les pods entre eux. Trois drivers sont livrés avec le
101
+ module, et le registre est ouvert : une application peut brancher le sien.
102
+
103
+ | Driver | Usage |
104
+ | ------------------- | ---------------------------------------------------------------- |
105
+ | `loopback` (défaut) | mono-process — aucun transport, fan-out local |
106
+ | `cluster` | multi-worker sur une machine (IPC Node, `nodefony cluster -w N`) |
107
+ | `redis` | multi-hôte (pub/sub cross-pod, enveloppe scellée HMAC) |
108
+
109
+ ```typescript
110
+ import { registerBackplaneDriver } from "@nodefony/realtime";
111
+
112
+ registerBackplaneDriver("nats", (config) => new MyNatsBackplane(config));
113
+ ```
114
+
115
+ Détail des réglages : [`docs/configuration.md`](./docs/configuration.md).
116
+
117
+ ## API
118
+
119
+ <!-- prettier-ignore -->
120
+ | Symbole | Type | Rôle |
121
+ | --- | --- | --- |
122
+ | `RealtimeController` | class | Base abstraite des controllers WebSocket |
123
+ | `RealtimeHub` / `getRealtimeHub` | class / fn | Broker fan-out serveur |
124
+ | `RealtimeService` | class | Service injectable (authenticators, cycle de vie) |
125
+ | `ServerRealtimeSocket` / `serverSocket` | class / fn | Façade « la socket » côté serveur |
126
+ | `@RealtimeChannel` / `@RealtimeInbound` / `@RealtimeAction` | decorators | Canal sortant · canal entrant · action RPC |
127
+ | `@RealtimeBroadcast` | decorator | Déclare les préfixes propagés au cluster |
128
+ | `defineRealtimeConfig` / `realtimeConfigJsonSchema` | functions | Builder de config + JSON Schema (Studio) |
129
+ | `LoopbackBackplane` / `ClusterBackplane` / `RedisBackplane` | classes | Les 3 drivers natifs |
130
+ | `registerBackplaneDriver` / `getBackplaneDriver` / `listBackplaneDrivers` | functions | Registre ouvert de drivers |
131
+ | `IBackplane` | interface | Contrat d'un fond de panier |
132
+ | `IRealtimeAuthenticator` / `IRealtimeToken` / `IRealtimeHandshake` | interfaces | Seams sécurité du handshake |
133
+ | `ANONYMOUS_REALTIME_TOKEN` | const | Jeton anonyme gelé — fallback Zero Trust |
134
+
135
+ ## Tester un controller — `@nodefony/realtime/testing`
136
+
137
+ Un controller temps réel s'éprouve **sans serveur ni navigateur**. Le harnais le monte sur
138
+ une fausse connexion et parle son protocole : il envoie les frames JSON-RPC qu'un client
139
+ enverrait, et rend celles qui sortent.
140
+
141
+ ```ts
142
+ import { describe, it, expect, afterEach } from "vitest";
143
+ import { getRealtimeHub } from "@nodefony/realtime";
144
+ import { createRealtimeHarness } from "@nodefony/realtime/testing";
145
+ import ChatController from "../nodefony/controllers/ChatController";
146
+
147
+ describe("ChatController — socket", () => {
148
+ afterEach(() => getRealtimeHub().clear());
149
+
150
+ it("pousse sur son canal, et répond à son action", async () => {
151
+ const h = createRealtimeHarness((ctx) => new ChatController(ctx));
152
+ await h.connect(); // handshake → realtime:welcome
153
+ await h.subscribe("chat:ticker");
154
+ expect(h.messages("chat:ticker")).not.toHaveLength(0);
155
+ expect(await h.call("chat:ping")).toEqual({ pong: true });
156
+ h.dispose(); // dispose les providers + vide le hub
157
+ });
158
+ });
159
+ ```
160
+
161
+ Pour éprouver un canal ou une action **protégés**, il faut les deux : l'identité qu'un
162
+ authenticator aurait résolue, et le verrou de frame que `@nodefony/security` pose au boot.
163
+ Sans verrou, une `policy` déclarée n'est appliquée par personne — c'est vrai en test comme
164
+ en production, `@nodefony/realtime` ne connaissant pas `@nodefony/security`.
165
+
166
+ ```ts
167
+ const h = createRealtimeHarness((ctx) => new ChatController(ctx), {
168
+ identity: monToken, // IRealtimeToken
169
+ frameAuthorizer: buildFrameAuthorizer(firewall, {
170
+ // @nodefony/security
171
+ channelResolver: getRealtimeHub(),
172
+ systemRules: DEFAULT_SYSTEM_RULES,
173
+ }),
174
+ });
175
+ await h.connect();
176
+ await h.subscribe("chat:admin");
177
+ expect(h.denials().map((d) => d.channel)).toEqual(["chat:admin"]); // refusé
178
+ ```
179
+
180
+ | Membre | Rôle |
181
+ | ------------------------------------------- | --------------------------------------------------------------- |
182
+ | `connect()` | Handshake ; rend le `realtime:welcome`, jette s'il n'arrive pas |
183
+ | `subscribe(c)` / `unsubscribe(c)` | Demande / rend un canal |
184
+ | `call(m, p?)` / `notify(m, p?)` / `send(f)` | Action RPC · notification entrante · frame brute |
185
+ | `messages(c)` / `denials()` / `received` | Ce qui est sorti : par canal · refus · toutes les frames |
186
+ | `closes` / `notices` | Fermetures serveur (4003…) · avertissements de plateforme |
187
+ | `close()` / `dispose()` | Ferme la socket · ferme puis remet le hub à zéro |
188
+
189
+ `nodefony create controller --kind realtime` pose ce test à côté du controller qu'il génère.
190
+
191
+ ## Doc complète
192
+
193
+ | Page | Description |
194
+ | -------------------------------------------------- | --------------------------------------------- |
195
+ | [`docs/index.md`](./docs/index.md) | Vue d'ensemble + promesse DX |
196
+ | [`docs/vocabulaire.md`](./docs/vocabulaire.md) | 12 mots avec analogies physiques |
197
+ | [`docs/architecture.md`](./docs/architecture.md) | Pile 5 étages + trajet d'une frame en cluster |
198
+ | [`docs/protocole.md`](./docs/protocole.md) | Grammaire des frames, codes d'erreur réels |
199
+ | [`docs/actions.md`](./docs/actions.md) | RPC : appeler et savoir si ça a marché |
200
+ | [`docs/configuration.md`](./docs/configuration.md) | Les 3 backplanes + driver custom |
201
+ | [`docs/securite.md`](./docs/securite.md) | Autorisation des canaux, zones, plafonds |
202
+ | [`docs/observabilite.md`](./docs/observabilite.md) | Sonde, canaux de santé, écrans |
203
+ | [`docs/cookbook-chat.md`](./docs/cookbook-chat.md) | Exemple de chat de bout en bout |
204
+
205
+ ## Licence
206
+
207
+ CeCILL-B — Christophe CAMENSULI.
@@ -0,0 +1,9 @@
1
+ //#region \0@oxc-project+runtime@0.148.0/helpers/esm/decorate.js
2
+ function __decorate(decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
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
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ }
8
+ //#endregion
9
+ export { __decorate as default };
@@ -0,0 +1,6 @@
1
+ //#region \0@oxc-project+runtime@0.148.0/helpers/esm/decorateMetadata.js
2
+ function __decorateMetadata(k, v) {
3
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
4
+ }
5
+ //#endregion
6
+ export { __decorateMetadata as default };
package/dist/index.js ADDED
@@ -0,0 +1,218 @@
1
+ import config from "./nodefony/config/config.js";
2
+ import { defineRealtimeConfig, realtimeConfigJsonSchema } from "./nodefony/config/defineModuleConfig.js";
3
+ import resolveBackplaneOriginId from "./nodefony/src/backplane/originId.js";
4
+ import { LoopbackBackplane } from "./nodefony/src/backplane/LoopbackBackplane.js";
5
+ import { ANONYMOUS_REALTIME_TOKEN } from "./nodefony/src/server/AnonymousRealtimeToken.js";
6
+ import { RESERVED_SYSTEM_PREFIXES, RealtimeHub, SLOW_CONSUMER_BYTES, getRealtimeHub, isReservedSystemChannel } from "./nodefony/src/server/RealtimeHub.js";
7
+ import { RealtimeAction, RealtimeBroadcast, RealtimeChannel, RealtimeInbound, getDeclaredBroadcastPrefixes } from "./nodefony/decorators/realtimeDecorators.js";
8
+ import { RealtimeService } from "./nodefony/src/service/RealtimeService.js";
9
+ import { BROWSER_ORIGIN, MAX_CLIENT_SEVERITY, createSyslogUplinkHandler } from "./nodefony/src/server/syslogUplink.js";
10
+ import { WsConnectionTransport } from "./nodefony/src/transport/WsConnectionTransport.js";
11
+ import { RealtimeController } from "./nodefony/src/server/RealtimeController.js";
12
+ import { ServerRealtimeSocket, serverSocket } from "./nodefony/src/server/ServerRealtimeSocket.js";
13
+ import { REDIS_RT_CHANNEL, RedisBackplane, createRedisServiceTransport, resolveRedisChannel } from "./nodefony/src/backplane/RedisBackplane.js";
14
+ import { ClusterBackplane, processIpcTransport } from "./nodefony/src/backplane/ClusterBackplane.js";
15
+ import { getBackplaneDriver, listBackplaneDrivers, registerBackplaneDriver } from "./nodefony/src/backplane/backplaneRegistry.js";
16
+ import { ClusterProbeClient, clusterProbeHealth, clusterProbeInstance, clusterProbeRequestEnrich, mergeClusterHealth, processProbeTransport, setClusterProbeClient } from "./nodefony/src/cluster/ClusterProbeClient.js";
17
+ import { buildOwnHealth, buildRealtimeHealth, createRealtimeAdminApi } from "./nodefony/src/server/RealtimeAdminApi.js";
18
+ import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js";
19
+ import __decorate from "./_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js";
20
+ import { Kernel, Module, services, withTimeout } from "nodefony";
21
+ //#region index.ts
22
+ /**
23
+ * @nodefony/realtime — Couche realtime serveur Nodefony.
24
+ *
25
+ * Ce module porte le hub WebSocket (broker fan-out), le protocole JSON-RPC 2.0
26
+ * (peer isomorphe partagé avec le client core) et le backplane cluster. Trois
27
+ * drivers natifs : `loopback` (mono-process), `cluster` (IPC Node) et `redis`
28
+ * (multi-hôte) ; le registre `backplaneRegistry` est ouvert aux drivers écrits
29
+ * par l'application.
30
+ *
31
+ * Le wiring cluster (`onCluster` → branchement `ClusterBackplane` IPC +
32
+ * `ClusterProbeClient`) vit dans le hook du Module class ci-dessous. Le client
33
+ * isomorphe n'est PAS ici : il vit dans le cœur, subpath `nodefony/client`
34
+ * (décision figée — pas de package navigateur).
35
+ *
36
+ * Lire `docs/index.md` pour la vue d'ensemble vulgarisée.
37
+ *
38
+ * Voir aussi :
39
+ * - CLAUDE.md — décisions d'archi figées + 5 seams sécurité
40
+ * - MEMORY.md — internals IA
41
+ * - README.md — usage humain
42
+ * - docs/ — doc dev vulgarisée
43
+ */
44
+ /**
45
+ * Délai max (ms) d'attente du `backplane.start()` au boot avant de basculer en
46
+ * hub local (fail-soft). Garde anti-gel : un transport réseau qui pend ne doit
47
+ * pas bloquer la montée des serveurs. Cf `Realtime.#startWithTimeout`.
48
+ */
49
+ const BACKPLANE_START_TIMEOUT_MS = 5e3;
50
+ registerBackplaneDriver(LoopbackBackplane.driver, () => null);
51
+ registerBackplaneDriver(ClusterBackplane.driver, (ctx) => ctx.role === "WORKER" && process.env.NF_CLUSTER === "1" ? new ClusterBackplane(processIpcTransport, ctx.originId) : null);
52
+ registerBackplaneDriver(RedisBackplane.driver, (ctx) => {
53
+ const redisService = ctx.module.kernel?.container?.get("redis");
54
+ if (!redisService) {
55
+ ctx.module.log(`driver "${RedisBackplane.driver}" : module @nodefony/redis absent (non listé dans @modules) — RealtimeHub reste local`, "WARNING");
56
+ return null;
57
+ }
58
+ const publisher = redisService.getClient("publish");
59
+ const subscriber = redisService.getClient("subscribe");
60
+ if (!publisher || !subscriber) {
61
+ ctx.module.log(`driver "${RedisBackplane.driver}" : connexions Redis publish/subscribe indisponibles — RealtimeHub reste local`, "WARNING");
62
+ return null;
63
+ }
64
+ const namespace = ctx.config.backplane.namespace ?? ctx.module.kernel?.projectName;
65
+ const secret = ctx.config.backplane.secret ?? null;
66
+ if (!secret) ctx.module.log(`driver "${RedisBackplane.driver}" : bus NON AUTHENTIFIÉ — les messages ne sont pas scellés. Quiconque écrit dans ce Redis publie sur les canaux de tous les pods. Poser backplane.secret (ou NF_REALTIME_BACKPLANE_SECRET, ≥ 32 caractères, identique sur tous les pods) pour sceller le transport.`, "WARNING");
67
+ return new RedisBackplane(createRedisServiceTransport(publisher, subscriber), ctx.originId, resolveRedisChannel(namespace), secret, {
68
+ maxQueueBytes: ctx.config.backplane.maxQueueBytes,
69
+ onNotice: (message, severity) => ctx.module.log(message, severity)
70
+ });
71
+ });
72
+ let Realtime = class Realtime extends Module {
73
+ /** Module optionnel : un échec de son boot ne tue jamais le process (résilience Ph.3). */
74
+ static critical = false;
75
+ /** Rôle topologique du process, capté à `onCluster` (défaut mono-process). */
76
+ #clusterRole = "MONO";
77
+ constructor(kernel) {
78
+ super("realtime", kernel, import.meta.url, config);
79
+ kernel.once("onCluster", (role) => {
80
+ this.#clusterRole = role;
81
+ });
82
+ }
83
+ /** JSON Schema de la config realtime → data plane admin (config riche Studio). */
84
+ configSchema() {
85
+ return realtimeConfigJsonSchema();
86
+ }
87
+ /**
88
+ * Validation Zod de la config racine merge au boot (convention figée 2026-05-28,
89
+ * cf [[feedback_config_validation_zod]]) — via le builder `defineRealtimeConfig`
90
+ * (source unique `nodefony/config/config.ts`). Plante propre avec messages clairs
91
+ * si la config (defaults + `module.options`) n'est pas conforme au schéma — évite
92
+ * tous les `undefined.x` silencieux en runtime.
93
+ *
94
+ * La config validée + gelée est exposée au container sous `realtimeConfig` pour
95
+ * que le `RealtimeService` (instancié à `onPreBoot` via `@services`) la consomme
96
+ * sans dupliquer la validation.
97
+ */
98
+ async onKernelRegister() {
99
+ const validated = defineRealtimeConfig(this.options ?? {});
100
+ this.options = validated;
101
+ return this;
102
+ }
103
+ /**
104
+ * Phase `onBoot` (avant le `mountAll` de framework dans `onReady`) — enregistre
105
+ * le producteur admin de la **socket Nodefony** (`/nodefony/realtime/api/*`)
106
+ * sur le broker `framework/AdminBroker`. Auto-observabilité du `RealtimeHub`.
107
+ *
108
+ * ⚠️ Ne PAS déplacer dans `onKernelReady` : framework fait `broker.mountAll()`
109
+ * dans SON `onKernelReady` ; les modules listés APRÈS framework dans `@modules()`
110
+ * (cas de @nodefony/realtime) verraient leur `register` rejeté avec
111
+ * « AdminBroker: register("realtime") après mountAll — routes figées ».
112
+ * `onKernelBoot` court donc avant et garantit l'ordre.
113
+ */
114
+ async onKernelBoot() {
115
+ if (!this.config.enabled) {
116
+ this.log("realtime enabled:false → boot inerte (ni admin API ni backplane ni sonde)", "INFO");
117
+ return this;
118
+ }
119
+ const broker = this.kernel?.container?.get("adminBroker");
120
+ if (broker && !broker.has("realtime")) broker.register(createRealtimeAdminApi());
121
+ await this.#wireBackplane();
122
+ this.#wireClusterProbe(this.#clusterRole);
123
+ return this;
124
+ }
125
+ /**
126
+ * Sélectionne et branche le backplane via le **registre de drivers** — résout
127
+ * `config.backplane.driver` (chaîne) → fabrique, SANS connaître aucun nom de
128
+ * driver en dur (pas de chaîne de `if`). La fabrique construit l'instance (ou
129
+ * renvoie `null` = inactif dans ce contexte → hub local).
130
+ *
131
+ * Phase `onKernelBoot` : après l'`initialize` des services (`onPreBoot`, donc
132
+ * un éventuel `RedisService` a ouvert ses connexions) et avant le trafic
133
+ * (`onReady` → `mountAll`). On `await start()` AVANT `setBackplane` pour
134
+ * garantir l'abonnement effectif (le `start()` interne de `setBackplane` est
135
+ * alors un no-op idempotent) — un driver async (Redis) perdrait sinon les
136
+ * premiers messages (setBackplane appelle start en fire-and-forget).
137
+ *
138
+ * No-op si un backplane custom est déjà posé (instance config / service DI
139
+ * `realtimeBackplane`, branché par `RealtimeService.initialize`). Warn fail-soft
140
+ * si le driver déclaré est inconnu du registre.
141
+ */
142
+ async #wireBackplane() {
143
+ const config = this.config;
144
+ const hub = getRealtimeHub();
145
+ if (hub.backplane !== null) return;
146
+ const driverName = config.backplane.driver;
147
+ const factory = getBackplaneDriver(driverName);
148
+ if (!factory) {
149
+ this.log(`backplane driver "${driverName}" inconnu du registre (disponibles : ${listBackplaneDrivers().join(", ")}) — RealtimeHub reste local`, "WARNING");
150
+ return;
151
+ }
152
+ const backplane = await factory({
153
+ module: this,
154
+ originId: resolveBackplaneOriginId(),
155
+ role: this.#clusterRole,
156
+ config
157
+ });
158
+ if (!backplane) {
159
+ this.log(`realtime backplane driver=${driverName} kind=local cross-pod=no (hub local)`, "INFO");
160
+ return;
161
+ }
162
+ try {
163
+ await this.#startWithTimeout(backplane, BACKPLANE_START_TIMEOUT_MS);
164
+ } catch (e) {
165
+ this.log(`realtime backplane driver=${driverName} indisponible (${e.message}) — fallback hub LOCAL, boot poursuivi (pas de fan-out cross-pod)`, "WARNING");
166
+ return;
167
+ }
168
+ hub.setBackplane(backplane);
169
+ const info = backplane.describe();
170
+ this.log(`realtime backplane driver=${info.driver} kind=${info.kind} origin=${info.originId} cross-pod=${info.crossPod ? "yes" : "no"}` + (info.channel ? ` channel=${info.channel}` : ""), "INFO");
171
+ }
172
+ /**
173
+ * `backplane.start()` borné par un **timeout** — un driver réseau (Redis/Kafka)
174
+ * dont la connexion pend (serveur injoignable, file offline node-redis) ne doit
175
+ * jamais geler le boot.
176
+ *
177
+ * Délègue la garde au util générique du core {@link withTimeout} (timer `unref`
178
+ * + cleanup `finally` + filet anti-`unhandledRejection` si `start()` rejette
179
+ * après que le timeout a gagné la course). Spécificité realtime conservée ici :
180
+ * au timeout/échec, un `stop()` **best-effort** pour ne pas laisser une
181
+ * connexion à demi-ouverte derrière.
182
+ *
183
+ * @param backplane - driver à démarrer.
184
+ * @param ms - délai max avant de considérer le `start()` en échec.
185
+ * @throws si `start()` rejette ou dépasse `ms`.
186
+ */
187
+ async #startWithTimeout(backplane, ms) {
188
+ try {
189
+ await withTimeout(Promise.resolve(backplane.start()), ms, "realtime backplane start");
190
+ } catch (e) {
191
+ Promise.resolve(backplane.stop()).catch(() => {});
192
+ throw e;
193
+ }
194
+ }
195
+ /**
196
+ * Branche les composants cluster du process en worker de `nodefony cluster` (repéré
197
+ * par l'env `NF_CLUSTER=1` posée côté master) : le {@link ClusterBackplane}
198
+ * (fan-out realtime cross-process) ET le {@link ClusterProbeClient} (sonde agrégée
199
+ * pod, Phase 4c).
200
+ *
201
+ * No-op si rôle MASTER, mono-process, ou worker `staging`/`preprod` legacy. La sonde
202
+ * est en plus gardée par `NF_CLUSTER_PROBE` (≠ "0") → **bypass total** quand
203
+ * désactivée : pas de client, donc 0 timer / 0 listener / 0 IPC, et l'endpoint santé
204
+ * sert la vue per-instance. Le backplane (realtime) reste indépendant de la sonde.
205
+ * Idempotent.
206
+ */
207
+ #wireClusterProbe(role) {
208
+ if (role !== "WORKER" || process.env.NF_CLUSTER !== "1") return;
209
+ if (this.config.cluster.probe.enabled && process.env.NF_CLUSTER_PROBE !== "0") {
210
+ setClusterProbeClient(new ClusterProbeClient()).start(buildOwnHealth);
211
+ this.log("RealtimeHub: ClusterProbeClient branché (sonde pod)", "INFO");
212
+ }
213
+ }
214
+ };
215
+ Realtime = __decorate([services([RealtimeService]), __decorateMetadata("design:paramtypes", [typeof Kernel === "undefined" ? Object : Kernel])], Realtime);
216
+ var realtime_default = Realtime;
217
+ //#endregion
218
+ 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 };
@@ -0,0 +1,63 @@
1
+ import { z } from "zod";
2
+ //#region nodefony/config/config.ts
3
+ /**
4
+ * @nodefony/realtime — CONFIGURATION DU MODULE (schéma Zod = source unique).
5
+ *
6
+ * ⭐ TL;DR : CE SCHÉMA EST LA CONFIG. Chaque `.default(...)` = la valeur d'usine ;
7
+ * changer un défaut du module = ÉDITER ICI (et nulle part ailleurs). L'app, elle,
8
+ * surcharge via `use("@nodefony/...", { … })` dans SON `nodefony.config.ts`.
9
+ *
10
+ * RÈGLE D'OR (ADR-0006) : ce fichier porte le **schéma Zod commenté** (type +
11
+ * validation + défaut + doc) ET matérialise les défauts via `parse({})`. Aucune
12
+ * valeur n'est re-tapée ailleurs. Le builder (`defineModuleConfig.ts` →
13
+ * `defineRealtimeConfig`) importe le schéma D'ICI (nœud bas : ce fichier
14
+ * n'importe que `zod` → pas de cycle).
15
+ *
16
+ * Validé au boot du Module class (hook `onKernelRegister`) → plante propre avec
17
+ * messages clairs si la config est invalide, pas d'`undefined.x` silencieux en
18
+ * runtime. Les seams sécurité (areas WS, authenticator, origin, auditOnFrame)
19
+ * vivent dans les sous-schémas `csrf`/`limits` ci-dessous.
20
+ *
21
+ * ⚠️ NE PAS éditer les défauts matérialisés en bas de fichier : modifier les
22
+ * `.default(...)` du schéma. La fusion + validation finale est faite dans
23
+ * `index.ts` au hook `onKernelRegister` (via `defineRealtimeConfig`).
24
+ */
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
+ 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 }),
30
+ 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
+ }).describe("Driver IBackplane (fan-out cluster realtime cross-process).");
32
+ const clusterSchema = z.strictObject({ probe: probeSchema.default(() => probeSchema.parse({})) }).describe("Comportement spécifique au mode cluster (worker IPC).");
33
+ const slowConsumerSchema = z.strictObject({ bytes: z.number().int().positive().default(1 << 20).describe("Seuil de `bufferedAmount` (octets) au-dessus duquel une connexion WS est comptée comme `slowConsumer` dans la sonde RealtimeHub (métrique d'observabilité). Défaut : 1 MiB (1<<20). À augmenter pour clients lents tolérés (IoT, mobile faible bande), à diminuer pour un signalement plus précoce. N.B. : ce seuil pilote UNIQUEMENT le COMPTAGE de la sonde — l'ACTION de back-pressure (drop/close de frames) se règle sur le SERVEUR WebSocket, côté @nodefony/http : `websocket.maxBackpressure` / `.backpressurePolicy` / `.backpressureCloseAfterDrops`.") }).describe("Détection des consommateurs lents (métrique de la sonde WS).");
34
+ const limitsSchema = z.strictObject({ maxChannelsPerConnection: z.number().int().positive().nullable().default(256).describe("Plafond de canaux qu'UNE connexion peut ouvrir (subscribe). Chaque canal ouvert = 1 ticker hub + 1 provider + 1 entrée Map côté connexion → sans borne, un socket peut subscribe à N canaux jusqu'à l'OOM. Au-delà du plafond, le subscribe est REFUSÉ (le canal n'est pas ouvert) et le client reçoit `realtime:denied` (motif `limit`). GARDE anti-OOM, PAS une bride : sous le seuil le multiplexage N-canaux reste libre. `null` = illimité (opt-out explicite, à réserver aux déploiements maîtrisés). Défaut 256.") }).describe("Bornes de ressources par connexion (anti-DoS/OOM).");
35
+ const checkOriginSchema = z.strictObject({
36
+ enabled: z.boolean().default(false).describe("Active le contrôle de l'en-tête `Origin` (RFC 6455 §10.2) à l'upgrade WebSocket. Défense CSRF native (les cookies sont envoyés en cross-origin même avec `SameSite=Lax`/`Strict` selon le contexte). Défaut : false (rétro-compat). Activation recommandée dès qu'un authenticator pose un cookie de session/JWT. Si `allowList` est vide quand `enabled=true`, TOUTE origin est refusée (échec fermé)."),
37
+ allowList: z.array(z.string()).default([]).describe("Liste des origines acceptées (match EXACT, scheme+host+port, ex. `https://app.example.com`). Wildcards NON supportés (durcissement vs CORS `Access-Control-Allow-Origin: *`). Inclure les origines de dev si nécessaire (`http://localhost:5151`). Origines absentes (clients non-browser, mobile natif) → traitées selon `allowMissingOrigin`."),
38
+ allowMissingOrigin: z.boolean().default(false).describe("Accepter une upgrade sans en-tête `Origin` (clients non-browser, ex. mobile natif, tests). `false` (défaut) = refus, `true` = accepte. À `true` UNIQUEMENT si l'authenticator vérifie un credential fort (JWT signé, API key) — sans Origin ET sans authenticator = brèche CSRF.")
39
+ }).describe("Contrôle Origin RFC 6455 §10.2 (défense CSRF native à l'upgrade WS).");
40
+ const clientLogsSchema = z.strictObject({
41
+ enabled: z.boolean().default(false).describe("Accepte les journaux remontés par les navigateurs sur le canal `nodefony:syslog:uplink`, et les réinjecte dans le journal du pod (origine forcée `browser`). Permet de recouper une erreur survenue dans la page avec la requête HTTP qui l'a précédée, par le même `requestId`. Défaut : false — c'est une surface d'écriture, elle s'ouvre sur décision, jamais par héritage. Le canal reste soumis au plancher du namespace plateforme : une connexion ANONYME ne peut rien pousser, quelle que soit cette valeur."),
42
+ maxEntriesPerBatch: z.number().int().positive().default(50).describe("Entrées retenues par lot reçu. Le surplus est ignoré en silence — borner le lot évite qu'un seul message n'occupe l'event-loop."),
43
+ maxEntriesPerWindow: z.number().int().positive().default(300).describe("Entrées retenues par fenêtre et PAR CONNEXION. Au-delà, le reste du lot est jeté. Par connexion et non globalement : sinon un onglet bavard muselle tous les autres."),
44
+ windowMs: z.number().int().positive().default(1e4).describe("Durée de la fenêtre de débit, en ms."),
45
+ maxStringLength: z.number().int().positive().default(4096).describe("Longueur maximale d'une chaîne acceptée (message, pile). Le client tronque déjà ; le serveur ne le croit pas sur parole.")
46
+ }).describe("Réception des journaux du navigateur (canal montant).");
47
+ const csrfSchema = z.strictObject({ checkOrigin: checkOriginSchema.default(() => checkOriginSchema.parse({})) }).describe("Protections CSRF natives realtime (origin check upgrade WS).");
48
+ const realtimeConfigSchema = z.strictObject({
49
+ enabled: z.boolean().default(true).describe("Active le module realtime au boot. Recommandation prod : true. false = module chargé mais inerte (registry, mais aucun hub/listener actif)."),
50
+ backplane: backplaneSchema.default(() => backplaneSchema.parse({})),
51
+ cluster: clusterSchema.default(() => clusterSchema.parse({})),
52
+ slowConsumer: slowConsumerSchema.default(() => slowConsumerSchema.parse({})),
53
+ limits: limitsSchema.default(() => limitsSchema.parse({})),
54
+ csrf: csrfSchema.default(() => csrfSchema.parse({})),
55
+ clientLogs: clientLogsSchema.default(() => clientLogsSchema.parse({}))
56
+ }).describe("Configuration de @nodefony/realtime.");
57
+ /**
58
+ * Défauts du module, matérialisés depuis le schéma (source unique). Toujours
59
+ * valides par construction ; passés au `super(..., config)` du Module class.
60
+ */
61
+ const config = realtimeConfigSchema.parse({});
62
+ //#endregion
63
+ export { config as default, realtimeConfigSchema };
@@ -0,0 +1,60 @@
1
+ import { realtimeConfigSchema } from "./config.js";
2
+ import { parseModuleConfig } from "nodefony";
3
+ import { z } from "zod";
4
+ //#region nodefony/config/defineModuleConfig.ts
5
+ /**
6
+ * Builder type-safe de la configuration de `@nodefony/realtime`.
7
+ *
8
+ * ⭐ TL;DR : MACHINERIE DE BOOT — on n'édite (presque) jamais ce fichier. Même
9
+ * pattern que `nodefony.config.ts` ↔ `defineConfig()` du core : `config.ts` PORTE
10
+ * la config (schéma + défauts), `define<X>Config()` la VALIDE au boot (parse +
11
+ * env + freeze) et publie le JSON Schema Studio.
12
+ *
13
+ * Principes (alignés sur `defineSecurityConfig`) :
14
+ * - **Source unique** : `./config.ts` (Zod). Le builder VALIDE + GÈLE, ne dévie pas.
15
+ * - **Auto-documenté + introspectable** : chaque champ Zod porte `.describe()` →
16
+ * {@link realtimeConfigJsonSchema} produit un JSON Schema qu'un formulaire
17
+ * Studio (futur) consommera pour générer son UI d'édition.
18
+ * - **Backplane custom** : instance `IBackplane` userland (NATS, Pulsar…) passée
19
+ * via le 2ᵉ argument — **hors** schéma sérialisable (les classes n'ont rien à
20
+ * faire dans un JSON Schema Studio). L'instance, si fournie, prend précédence
21
+ * sur `driver` côté service ; ce dernier reste source d'introspection/UI.
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).
28
+ *
29
+ * @param config - configuration brute (sections omises = défauts sûrs).
30
+ * @param options.backplane - instance `IBackplane` custom (override le driver à
31
+ * l'init du `RealtimeService`). Non sérialisable → exclue du JSON Schema.
32
+ * @returns config gelée prête pour `RealtimeService`.
33
+ * @throws ZodError si invalide.
34
+ */
35
+ function defineRealtimeConfig(config = {}, options = {}) {
36
+ 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
+ const out = {
41
+ ...parsed,
42
+ backplane: {
43
+ ...parsed.backplane,
44
+ driver,
45
+ secret,
46
+ namespace,
47
+ instance: options.backplane
48
+ }
49
+ };
50
+ return Object.freeze(out);
51
+ }
52
+ /**
53
+ * JSON Schema introspectable de la config realtime — destiné au formulaire
54
+ * d'édition Studio (futur). N'inclut PAS `backplane.instance` (non sérialisable).
55
+ */
56
+ function realtimeConfigJsonSchema() {
57
+ return z.toJSONSchema(realtimeConfigSchema);
58
+ }
59
+ //#endregion
60
+ export { defineRealtimeConfig, realtimeConfigJsonSchema };