@nodefony/realtime 10.0.0-beta.5 → 10.0.0-beta.6

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 (78) hide show
  1. package/README.md +50 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +79 -12
  4. package/dist/nodefony/config/config.js +7 -0
  5. package/dist/nodefony/decorators/realtimeDecorators.js +125 -19
  6. package/dist/nodefony/interfaces/IRealtimeConnection.js +1 -0
  7. package/dist/nodefony/src/backplane/ClusterBackplane.js +32 -4
  8. package/dist/nodefony/src/backplane/RedisBackplane.js +143 -31
  9. package/dist/nodefony/src/backplane/controlOrder.js +57 -0
  10. package/dist/nodefony/src/backplane/envelope.js +113 -12
  11. package/dist/nodefony/src/cluster/ClusterProbeClient.js +2 -0
  12. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
  13. package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
  14. package/dist/nodefony/src/server/RealtimeConnection.js +110 -0
  15. package/dist/nodefony/src/server/RealtimeController.js +340 -81
  16. package/dist/nodefony/src/server/RealtimeHub.js +604 -61
  17. package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
  18. package/dist/nodefony/src/server/admissionBudget.js +176 -0
  19. package/dist/nodefony/src/server/channelPolicyRegistry.js +73 -0
  20. package/dist/nodefony/src/server/connectionAdmission.js +242 -0
  21. package/dist/nodefony/src/server/contractRoutes.js +85 -0
  22. package/dist/nodefony/src/server/deniedDetail.js +10 -3
  23. package/dist/nodefony/src/server/frameAdmission.js +168 -0
  24. package/dist/nodefony/src/server/subscribedAck.js +10 -1
  25. package/dist/nodefony/src/server/syslogUplink.js +3 -4
  26. package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
  27. package/dist/nodefony/src/service/RealtimeService.js +42 -1
  28. package/dist/nodefony/src/service/publicOrigin.js +40 -0
  29. package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
  30. package/dist/nodefony/testing/backplaneConformance.js +271 -0
  31. package/dist/nodefony/testing/index.js +21 -4
  32. package/dist/types/index.d.ts +34 -7
  33. package/dist/types/nodefony/config/config.d.ts +9 -0
  34. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +86 -6
  35. package/dist/types/nodefony/interfaces/IBackplane.d.ts +34 -50
  36. package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
  37. package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
  38. package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
  39. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
  40. package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
  41. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
  42. package/dist/types/nodefony/interfaces/index.d.ts +2 -1
  43. package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
  44. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +79 -16
  45. package/dist/types/nodefony/src/backplane/controlOrder.d.ts +52 -0
  46. package/dist/types/nodefony/src/backplane/envelope.d.ts +34 -0
  47. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +10 -0
  48. package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
  49. package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +17 -0
  50. package/dist/types/nodefony/src/server/RealtimeController.d.ts +91 -11
  51. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +321 -24
  52. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
  53. package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
  54. package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +34 -0
  55. package/dist/types/nodefony/src/server/connectionAdmission.d.ts +68 -0
  56. package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
  57. package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
  58. package/dist/types/nodefony/src/server/frameAdmission.d.ts +70 -0
  59. package/dist/types/nodefony/src/server/subscribedAck.d.ts +7 -0
  60. package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
  61. package/dist/types/nodefony/src/service/RealtimeService.d.ts +10 -2
  62. package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
  63. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
  64. package/dist/types/nodefony/testing/backplaneConformance.d.ts +66 -0
  65. package/dist/types/nodefony/testing/index.d.ts +12 -1
  66. package/docs/actions.md +419 -18
  67. package/docs/architecture.md +83 -8
  68. package/docs/cadence-et-contre-pression.md +87 -11
  69. package/docs/canaux-a-membres.md +267 -0
  70. package/docs/cas-d-usage.md +1702 -0
  71. package/docs/configuration.md +137 -17
  72. package/docs/cookbook-chat.md +314 -85
  73. package/docs/index.md +11 -1
  74. package/docs/observabilite.md +71 -14
  75. package/docs/protocole.md +133 -24
  76. package/docs/securite.md +284 -32
  77. package/docs/vocabulaire.md +13 -12
  78. package/package.json +14 -11
package/README.md CHANGED
@@ -95,6 +95,39 @@ Les trois décorateurs prennent `(nom, policy?)` — la politique déclare qui a
95
95
 
96
96
  Recette complète (salon, présence, historique, upload) : [`docs/cookbook-chat.md`](./docs/cookbook-chat.md).
97
97
 
98
+ ### Typer les deux bouts — un contrat
99
+
100
+ Un **contrat** déclare UNE fois ce qui circule, dans un fichier de types seuls partagé par le
101
+ navigateur et le serveur ; le compilateur vérifie alors les deux bouts — une action oubliée, une
102
+ charge de forme fausse ou un nom de politique mal orthographié ne compilent plus.
103
+
104
+ ```typescript
105
+ // shared/chat.ts
106
+ import type { IRealtimeContract } from "nodefony/client";
107
+ export interface ChatContract extends IRealtimeContract {
108
+ serverToClient: { "chat:room": { from: string; text: string } };
109
+ serverMethods: { "chat:send": { in: { text: string }; out: { id: string } } };
110
+ }
111
+
112
+ // navigateur
113
+ const socket = NodefonySocket.fromContract<ChatContract>({
114
+ url: "/chat/realtime",
115
+ });
116
+
117
+ // serveur
118
+ @RealtimePolicy({ "chat:send": { roles: ["ROLE_USER"] } })
119
+ class ChatRt extends ContractRealtimeController<ChatContract> {
120
+ protected realtimeActions() {
121
+ return {
122
+ "chat:send": (p: { text: string }) => ({ id: String(p.text.length) }),
123
+ };
124
+ }
125
+ }
126
+ ```
127
+
128
+ Ce que chaque bout reçoit (`Jsonify`), ce que le compilateur refuse, et le flux SSE typé par la
129
+ même carte : [`docs/actions.md`](./docs/actions.md#-un-contrat-pour-les-deux-bouts).
130
+
98
131
  ## Backplane
99
132
 
100
133
  Le backplane est le fond de panier qui relie les pods entre eux. Trois drivers sont livrés avec le
@@ -129,6 +162,10 @@ Détail des réglages : [`docs/configuration.md`](./docs/configuration.md).
129
162
  | `LoopbackBackplane` / `ClusterBackplane` / `RedisBackplane` | classes | Les 3 drivers natifs |
130
163
  | `registerBackplaneDriver` / `getBackplaneDriver` / `listBackplaneDrivers` | functions | Registre ouvert de drivers |
131
164
  | `IBackplane` | interface | Contrat d'un fond de panier |
165
+ | `ContractRealtimeController<C>` | classe | Base d'un contrôleur qui implémente un contrat (`realtimeActions()` complète si le contrat déclare des `serverMethods`, rien sinon) |
166
+ | `getRealtimeHub<C>()` | fonction | Le hub lu par le contrat (`IContractRealtimeHub<C>`) : `publish` d'un service vérifié ; sans paramètre, le hub libre |
167
+ | `ContractRealtimePublish<C>` | type | Le `publish` d'une fabrique de canal, typé par le contrat |
168
+ | `RealtimePolicy` | décorateur | Politiques d'accès par nom, posées sur la classe ; noms vérifiés contre le contrat |
132
169
  | `IRealtimeAuthenticator` / `IRealtimeToken` / `IRealtimeHandshake` | interfaces | Seams sécurité du handshake |
133
170
  | `ANONYMOUS_REALTIME_TOKEN` | const | Jeton anonyme gelé — fallback Zero Trust |
134
171
 
@@ -205,6 +242,19 @@ seul avec un canal ouvert à tous.
205
242
  | `closes` / `notices` | Fermetures serveur (4003…) · avertissements de plateforme |
206
243
  | `close()` / `dispose()` | Ferme la socket · ferme puis remet le hub à zéro |
207
244
 
245
+ Un canal gardé par une **classe d'admission** (`@RealtimeChannel("room:*", { allow: RoomAdmission })`)
246
+ se résout au démarrage, dans le conteneur du noyau — que le harnais n'a pas. Le test la construit
247
+ avec le MÊME service que le controller, et la passe par `admissions` ; sans elle, `connect()` jette
248
+ (code 1011), comme un démarrage qui ne la trouverait pas :
249
+
250
+ ```ts
251
+ const members = new RoomService(createTestModule());
252
+ const alice = createRealtimeHarness((ctx) => new RoomRealtime(ctx, members), {
253
+ identity: createTestIdentity({ identifier: "alice" }),
254
+ admissions: [new RoomAdmission(members)],
255
+ });
256
+ ```
257
+
208
258
  `nodefony create controller --kind realtime` pose ce test à côté du controller qu'il génère.
209
259
 
210
260
  ## Doc complète
@@ -1,4 +1,4 @@
1
- // \0@oxc-project+runtime@0.155.0/helpers/esm/decorate.js
1
+ // \0@oxc-project+runtime@0.157.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);
package/dist/index.js CHANGED
@@ -2,23 +2,28 @@ import config from "./nodefony/config/config.js";
2
2
  import { buildRealtimeConfig, defineRealtimeConfig, realtimeConfigJsonSchema } from "./nodefony/config/defineModuleConfig.js";
3
3
  import { isCallable, isRecord } from "./nodefony/src/guards.js";
4
4
  import { realtimeBackplaneDownNotice, realtimeLocalOnlyNotice } from "./nodefony/src/backplane/bootNotices.js";
5
- import resolveBackplaneOriginId from "./nodefony/src/backplane/originId.js";
6
- import { LoopbackBackplane } from "./nodefony/src/backplane/LoopbackBackplane.js";
7
- import { ANONYMOUS_REALTIME_TOKEN } from "./nodefony/src/server/AnonymousRealtimeToken.js";
8
- import { RESERVED_SYSTEM_PREFIXES, RealtimeHub, SLOW_CONSUMER_BYTES, getRealtimeHub, isReservedSystemChannel } from "./nodefony/src/server/RealtimeHub.js";
9
- import { RealtimeAction, RealtimeBroadcast, RealtimeChannel, RealtimeInbound, getDeclaredBroadcastPrefixes } from "./nodefony/decorators/realtimeDecorators.js";
10
5
  import { RealtimeService } from "./nodefony/src/service/RealtimeService.js";
11
- import { BROWSER_ORIGIN, MAX_CLIENT_SEVERITY, createSyslogUplinkHandler } from "./nodefony/src/server/syslogUplink.js";
12
- import { WsConnectionTransport } from "./nodefony/src/transport/WsConnectionTransport.js";
13
6
  import { RealtimeController } from "./nodefony/src/server/RealtimeController.js";
7
+ import { createAdmissionResolver, registerControllerPolicies } from "./nodefony/src/server/channelPolicyRegistry.js";
8
+ import { assertContractRoutes, readContractRoutes } from "./nodefony/src/server/contractRoutes.js";
14
9
  import { ServerRealtimeSocket, serverSocket } from "./nodefony/src/server/ServerRealtimeSocket.js";
15
- import { REDIS_RT_CHANNEL, RedisBackplane, createRedisServiceTransport, isRedisPublisher, isRedisSubscriber, resolveRedisChannel } from "./nodefony/src/backplane/RedisBackplane.js";
10
+ import { ChannelFrame, RESERVED_SYSTEM_PREFIXES, RealtimeHub, SLOW_CONSUMER_BYTES, getRealtimeHub, isReservedSystemChannel } from "./nodefony/src/server/RealtimeHub.js";
11
+ import { LoopbackBackplane } from "./nodefony/src/backplane/LoopbackBackplane.js";
12
+ import { REDIS_RT_CHANNEL, RedisBackplane, createRedisServiceTransport, isRedisBytesSubscriber, isRedisPublisher, isRedisSubscriber, resolveRedisBytesChannel, resolveRedisChannel } from "./nodefony/src/backplane/RedisBackplane.js";
13
+ import resolveBackplaneOriginId from "./nodefony/src/backplane/originId.js";
16
14
  import { ClusterBackplane, processIpcTransport } from "./nodefony/src/backplane/ClusterBackplane.js";
17
15
  import { getBackplaneDriver, listBackplaneDrivers, registerBackplaneDriver } from "./nodefony/src/backplane/backplaneRegistry.js";
18
16
  import { ClusterProbeClient, clusterProbeHealth, clusterProbeInstance, clusterProbeRequestEnrich, mergeClusterHealth, processProbeTransport, setClusterProbeClient } from "./nodefony/src/cluster/ClusterProbeClient.js";
17
+ import { WsConnectionTransport } from "./nodefony/src/transport/WsConnectionTransport.js";
19
18
  import { buildOwnHealth, buildRealtimeHealth, createRealtimeAdminApi } from "./nodefony/src/server/RealtimeAdminApi.js";
20
- import __decorate from "./_virtual/_@oxc-project_runtime@0.155.0/helpers/esm/decorate.js";
19
+ import __decorate from "./_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorate.js";
20
+ import { ANONYMOUS_REALTIME_TOKEN, ANONYMOUS_SUBSCRIBER_IDENTITY } from "./nodefony/src/server/AnonymousRealtimeToken.js";
21
+ import { RealtimeAction, RealtimeBroadcast, RealtimeChannel, RealtimeInbound, RealtimePolicy, getDeclaredBroadcastPrefixes } from "./nodefony/decorators/realtimeDecorators.js";
22
+ import { ContractRealtimeController, ContractRealtimeControllerBase, ContractRealtimeControllerStrict } from "./nodefony/src/server/ContractRealtimeController.js";
23
+ import { BROWSER_ORIGIN, MAX_CLIENT_SEVERITY, createSyslogUplinkHandler } from "./nodefony/src/server/syslogUplink.js";
24
+ import { openBackplaneEnvelope, sealBackplaneEnvelope } from "./nodefony/src/backplane/envelope.js";
21
25
  import { IDENTITY_CHANGED_EVENT, Module, services, withTimeout } from "nodefony";
26
+ import { getRouteSchema, isFrameworkRouter } from "@nodefony/framework";
22
27
  // index.ts
23
28
  /**
24
29
  * @nodefony/realtime — Couche realtime serveur Nodefony.
@@ -65,7 +70,7 @@ registerBackplaneDriver(RedisBackplane.driver, (ctx) => {
65
70
  const namespace = ctx.config.backplane.namespace ?? ctx.module.kernel?.projectName;
66
71
  const secret = ctx.config.backplane.secret ?? null;
67
72
  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");
68
- return new RedisBackplane(createRedisServiceTransport(publisher, subscriber), ctx.originId, resolveRedisChannel(namespace), secret, {
73
+ return new RedisBackplane(createRedisServiceTransport(publisher, subscriber, isRedisBytesSubscriber(subscriber) ? subscriber : void 0), ctx.originId, resolveRedisChannel(namespace), secret, {
69
74
  maxQueueBytes: ctx.config.backplane.maxQueueBytes,
70
75
  onNotice: (message, severity) => ctx.module.log(message, severity)
71
76
  });
@@ -127,6 +132,55 @@ let Realtime = class Realtime extends Module {
127
132
  return this;
128
133
  }
129
134
  /**
135
+ * Phase `onReady` — inscrit au hub les politiques de canal de TOUS les contrôleurs temps réel connus, et résout leurs classes d'admission, avant que le moindre serveur n'écoute.
136
+ *
137
+ * Pas à `onKernelBoot` : chaque module enregistre ses contrôleurs dans SON
138
+ * `onBoot` (`@controllers`), et ceux d'un module listé après celui-ci n'y
139
+ * seraient pas encore. `onReady` passe après tous les `onBoot` et avant
140
+ * `initServers` : aucune poignée de main ne précède l'inscription, et un
141
+ * exemplaire qui n'émet que des webhooks a ses politiques comme les autres.
142
+ *
143
+ * @throws BootConfigurationError Politique illisible, `allow` sur une
144
+ * action, classe d'admission sans singleton, sans `allow` ou dont une
145
+ * dépendance manque — la classe et le canal sont nommés.
146
+ */
147
+ async onKernelReady() {
148
+ if (!this.config.enabled) return this;
149
+ const hub = getRealtimeHub();
150
+ const container = this.kernel?.container;
151
+ const resolveAdmission = createAdmissionResolver((key) => key === null ? void 0 : container?.get(key), (message) => this.log(message, "INFO"));
152
+ const controllers = Module.controllers;
153
+ let mounted = null;
154
+ for (const key in controllers) {
155
+ const ctor = controllers[key];
156
+ if (ctor !== void 0 && ctor.prototype instanceof RealtimeController) {
157
+ registerControllerPolicies(hub, ctor, resolveAdmission);
158
+ const table = readContractRoutes(ctor);
159
+ if (table !== null) {
160
+ mounted ??= this.mountedRoutes();
161
+ assertContractRoutes(ctor.name, table, mounted);
162
+ }
163
+ }
164
+ }
165
+ return this;
166
+ }
167
+ /**
168
+ * Les routes montées par le routeur du framework, vues par le contrôle des
169
+ * routes de contrat : chemin, méthodes, schémas d'entrée.
170
+ *
171
+ * @returns la liste, vide si le routeur du framework est absent.
172
+ */
173
+ mountedRoutes() {
174
+ const router = this.kernel?.container?.get("router");
175
+ if (!isFrameworkRouter(router)) return [];
176
+ const all = router.getRoutes("");
177
+ return (Array.isArray(all) ? all : []).flatMap((route) => route.path === void 0 ? [] : [{
178
+ path: route.path,
179
+ methods: route.methodsSet,
180
+ schema: route.controller !== void 0 && route.classMethod !== void 0 ? getRouteSchema(route.controller.prototype, route.classMethod) : null
181
+ }]);
182
+ }
183
+ /**
130
184
  * Sélectionne et branche le backplane via le **registre de drivers** — résout
131
185
  * `config.backplane.driver` (chaîne) → fabrique, SANS connaître aucun nom de
132
186
  * driver en dur (pas de chaîne de `if`). La fabrique construit l'instance (ou
@@ -146,7 +200,10 @@ let Realtime = class Realtime extends Module {
146
200
  async #wireBackplane() {
147
201
  const config = this.config;
148
202
  const hub = getRealtimeHub();
149
- if (hub.backplane !== null) return;
203
+ if (hub.backplane !== null) {
204
+ this.#logBroadcastPrefixes(hub);
205
+ return;
206
+ }
150
207
  const driverName = config.backplane.driver;
151
208
  const factory = getBackplaneDriver(driverName);
152
209
  if (!factory) {
@@ -174,6 +231,16 @@ let Realtime = class Realtime extends Module {
174
231
  hub.setBackplane(backplane);
175
232
  const info = backplane.describe();
176
233
  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");
234
+ this.#logBroadcastPrefixes(hub);
235
+ }
236
+ /**
237
+ * Dit au démarrage QUELS canaux traversent le bus : les autres restent sur ce
238
+ * serveur, et un abonné d'un autre serveur n'en reçoit rien — sans erreur.
239
+ * Lu une fois, à côté de la ligne du backplane (D5, ADR-0022 §6).
240
+ */
241
+ #logBroadcastPrefixes(hub) {
242
+ const prefixes = hub.broadcastPrefixes();
243
+ this.log(`realtime diffusion entre serveurs : ${prefixes.map((p) => `${p}*`).join(", ")} — tout autre canal reste sur ce serveur (\`@RealtimeBroadcast\` pour le diffuser ; oublis comptés par la sonde, \`localOnlyTotal\`)`, "INFO");
177
244
  }
178
245
  /**
179
246
  * `backplane.start()` borné par un **timeout** — un driver réseau (Redis/Kafka)
@@ -220,4 +287,4 @@ let Realtime = class Realtime extends Module {
220
287
  };
221
288
  Realtime = __decorate([services([RealtimeService])], Realtime);
222
289
  var realtime_default = Realtime;
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 };
290
+ export { ANONYMOUS_REALTIME_TOKEN, ANONYMOUS_SUBSCRIBER_IDENTITY, BROWSER_ORIGIN, ChannelFrame, ClusterBackplane, ClusterProbeClient, ContractRealtimeController, ContractRealtimeControllerBase, ContractRealtimeControllerStrict, LoopbackBackplane, MAX_CLIENT_SEVERITY, REDIS_RT_CHANNEL, RESERVED_SYSTEM_PREFIXES, Realtime, RealtimeAction, RealtimeBroadcast, RealtimeChannel, RealtimeController, RealtimeHub, RealtimeInbound, RealtimePolicy, 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, openBackplaneEnvelope, processIpcTransport, processProbeTransport, realtimeConfigJsonSchema, registerBackplaneDriver, resolveBackplaneOriginId, resolveRedisBytesChannel, resolveRedisChannel, sealBackplaneEnvelope, serverSocket, setClusterProbeClient };
@@ -1,3 +1,4 @@
1
+ import { realtimeConnectSource } from "../src/service/publicOrigin.js";
1
2
  import { z } from "zod";
2
3
  // nodefony/config/config.ts
3
4
  /**
@@ -50,10 +51,16 @@ const clientLogsSchema = z.strictObject({
50
51
  const csrfSchema = z.strictObject({ checkOrigin: checkOriginSchema.default(() => checkOriginSchema.parse({})) }).describe("Protections CSRF natives realtime (origin check upgrade WS).");
51
52
  const realtimeConfigSchema = z.strictObject({
52
53
  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)."),
54
+ codec: z.enum(["json", "cbor"]).default("json").describe("Codec binaire des frames, accordé aux clients qui l'offrent (ADR-0022 §2). `json` (défaut) : JSON-RPC texte seul, le fil d'avant les codecs. `cbor` : le serveur charge le codec CBOR (RFC 8949) au démarrage et l'accorde à tout client qui offre `nodefony.jsonrpc.cbor.v1` ; les autres restent en texte, avec la convention d'octets `$bytes`. Les octets d'une charge voyagent alors sans base64, et toute trame binaire d'une connexion CBOR est une frame : le flux brut (`onRealtimeBinary`, `sendBinaryToClient`) n'y sert plus. Env : `NF_REALTIME_CODEC` — `NF_REALTIME_CODEC=json` remet le fil en texte pour le lire dans le navigateur, une exception de débogage : en texte, la taille des trames et la contre-pression changent.").meta({ env: "NF_REALTIME_CODEC" }),
53
55
  backplane: backplaneSchema.default(() => backplaneSchema.parse({})),
54
56
  cluster: clusterSchema.default(() => clusterSchema.parse({})),
55
57
  slowConsumer: slowConsumerSchema.default(() => slowConsumerSchema.parse({})),
56
58
  limits: limitsSchema.default(() => limitsSchema.parse({})),
59
+ authenticateTimeoutMs: z.number().int().positive().max(2147483647).default(1e4).describe("Délai (ms) de l'authentification à la poignée de main WebSocket. Dépassé, la connexion est fermée en 1013 (« try again later ») : le client recule puis revient, et une identité arrivée après coup est ignorée (`authenticateTimedOutTotal`). Jamais 4001 : un refus définitif arrêterait un client légitime à chaque panne de base. Défaut 10000 : l'authentification relit souvent une session en base ou une clé distante ; 10 s couvrent une base chargée, et au-delà retenir la connexion ne l'authentifierait pas plus vite — elle occuperait seulement une place."),
60
+ allowTimeoutMs: z.number().int().positive().max(2147483647).default(5e3).describe("Délai (ms) de la règle d'admission `allow` d'un canal, compté depuis la réception du `subscribe`, attente en file comprise. Dépassé, l'abonné reçoit `realtime:denied` motif `unavailable` et réessaie ; un verdict arrivé après coup est ignoré, jamais une admission. Le minuteur n'est armé que si `allow` rend une promesse. Défaut 5000 : une lecture en base saine répond en millisecondes, 5 s couvrent une base chargée sans laisser un écran vide au-delà de ce qu'un humain attend."),
61
+ maxPendingAllowsPerConnection: z.number().int().positive().default(8).describe("Appels de `allow` en vol pour UNE connexion. Au-delà, la demande d'abonnement ATTEND son tour (jamais refusée : le rejeu au `welcome` renvoie tous les canaux d'un coup). Défaut 8 : assez pour ouvrir plusieurs canaux en parallèle, assez peu pour qu'une connexion ne monopolise pas le pool de connexions à la base (souvent 10)."),
62
+ maxPendingAllowsTotal: z.number().int().positive().default(512).describe("Appels de `allow` en vol pour TOUT l'exemplaire. Au-delà, la demande reçoit `unavailable` sans appel (`allowSaturatedTotal`). Défaut 512 : à ce niveau la base est déjà l'étranglement ; empiler davantage d'appels n'admettrait personne plus vite et retarderait la reprise."),
63
+ publicOrigin: z.string().refine((value) => realtimeConnectSource(value) !== null, { message: "publicOrigin : une origine nue est attendue — schéma ws, wss, http ou https, hôte, port facultatif ; ni chemin, ni joker (ex. `wss://rt.example.com`)." }).optional().describe("Origine où la socket est servie quand ce n'est PAS celle de la page (autre domaine, autre port), par ex. `wss://rt.example.com`. Le module la déclare lui-même dans `connect-src` de la politique de contenu ; sans elle, le navigateur bloque la connexion. Inutile quand la socket est sur l'origine de la page (cas courant) : `connect-src 'self'` la couvre déjà. `https://` est accepté et déclaré en `wss://`. Ne dit pas qui peut OUVRIR la socket : c'est `csrf.checkOrigin.allowList`. Env : `NF_REALTIME_PUBLIC_ORIGIN` (l'origine distingue des déploiements).").meta({ env: "NF_REALTIME_PUBLIC_ORIGIN" }),
57
64
  csrf: csrfSchema.default(() => csrfSchema.parse({})),
58
65
  clientLogs: clientLogsSchema.default(() => clientLogsSchema.parse({}))
59
66
  }).describe("Configuration de @nodefony/realtime.");
@@ -1,4 +1,5 @@
1
1
  import { isCallable, isRecord } from "../src/guards.js";
2
+ import { BootConfigurationError, Injector } from "nodefony";
2
3
  import "reflect-metadata";
3
4
  // nodefony/decorators/realtimeDecorators.ts
4
5
  /** Clés de métadonnées posées sur le constructor de la classe controller. */
@@ -20,26 +21,77 @@ function readMetadataMap(key, ctor) {
20
21
  function isStringArray(value) {
21
22
  return Array.isArray(value) && value.every((v) => typeof v === "string");
22
23
  }
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));
24
+ /**
25
+ * Clés qu'une politique de canal peut porter — toute autre est refusée : une
26
+ * faute de frappe (`role`, `alow`) dans une règle de sécurité ne s'ignore pas.
27
+ */
28
+ const POLICY_KEYS = /* @__PURE__ */ new Set([
29
+ "authenticated",
30
+ "roles",
31
+ "scopes",
32
+ "allow",
33
+ "anonymous"
34
+ ]);
35
+ /**
36
+ * `allow` désigne-t-il une classe d'admission (`@injectable`) plutôt qu'une flèche ?
37
+ *
38
+ * Tranché par la métadonnée `@injectable`, jamais par le texte de la fonction
39
+ * (`class …`), qu'un minifieur réécrit (ADR-0021 §S10 bis).
40
+ *
41
+ * @param allow - valeur du membre `allow` d'une politique déjà validée.
42
+ * @returns `true` pour une classe à résoudre par l'injecteur.
43
+ */
44
+ function isAdmissionClass(allow) {
45
+ return Injector.isInjectable(allow);
28
46
  }
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;
47
+ /**
48
+ * Défaut d'une politique de canal, ou `null` si elle est recevable — en clair, pour que le refus au démarrage dise QUOI corriger.
49
+ *
50
+ * @param value - valeur lue sous la métadonnée des politiques (`unknown` :
51
+ * `Reflect.defineMetadata` est public).
52
+ * @returns la raison du refus, ou `null`.
53
+ */
54
+ function channelPolicyDefect(value) {
55
+ if (!isRecord(value)) return "la politique n'est pas un objet";
56
+ for (const key of Object.keys(value)) if (!POLICY_KEYS.has(key)) return `clé inconnue « ${key} » (clés admises : ${[...POLICY_KEYS].join(", ")})`;
57
+ const { authenticated, roles, scopes, allow, anonymous } = value;
58
+ if (authenticated !== void 0 && typeof authenticated !== "boolean") return "« authenticated » doit être un booléen";
59
+ if (roles !== void 0 && !isStringArray(roles)) return "« roles » doit être un tableau de chaînes";
60
+ if (scopes !== void 0 && !isStringArray(scopes)) return "« scopes » doit être un tableau de chaînes";
61
+ if (anonymous !== void 0 && typeof anonymous !== "boolean") return "« anonymous » doit être un booléen";
62
+ if (allow === void 0) return anonymous === void 0 ? null : "« anonymous » n'a de sens qu'avec « allow »";
63
+ if (typeof allow !== "function") return "« allow » doit être une fonction ou une classe @injectable";
64
+ if (anonymous === true && authenticated === true) return "« anonymous: true » contredit « authenticated: true »";
65
+ const proto = Reflect.get(allow, "prototype");
66
+ if (!Injector.isInjectable(allow) && isRecord(proto) && typeof proto.allow === "function") return `« allow » est une classe (${allow.name}) sans @injectable`;
67
+ return null;
68
+ }
69
+ /**
70
+ * Exige que chaque entrée du registre soit une politique recevable — sinon le démarrage s'arrête en nommant la classe, le canal et le défaut.
71
+ *
72
+ * Une politique illisible ne se laisse jamais tomber : sans elle, le canal
73
+ * s'ouvrirait à qui la politique fermait (fail-closed).
74
+ */
75
+ function assertPolicyMap(owner, map) {
76
+ for (const name in map) {
77
+ const defect = channelPolicyDefect(map[name]);
78
+ if (defect !== null) throw new BootConfigurationError(`${owner} : politique du canal temps réel « ${name} » refusée — ${defect}`);
79
+ }
33
80
  }
34
81
  /** Méthode de l'instance que désigne une entrée de registre, ou `undefined`. */
35
82
  function methodOf(instance, prop) {
36
83
  return typeof prop === "string" || typeof prop === "symbol" ? Reflect.get(instance, prop) : void 0;
37
84
  }
85
+ /** La politique porte-t-elle au moins une clé propre non `undefined` ? */
86
+ function hasDefinedKey(policy) {
87
+ for (const key of Object.keys(policy)) if (Reflect.get(policy, key) !== void 0) return true;
88
+ return false;
89
+ }
38
90
  /** Libération d'un canal dont la fabrique décorée n'en a rendu aucune. */
39
91
  const NO_DISPOSE = () => {};
40
92
  /** Enregistre (idempotent) une politique non vide sous son nom de canal/méthode. */
41
93
  function definePolicy(ctor, name, policy) {
42
- if (!policy || policy.authenticated === void 0 && policy.roles === void 0 && policy.scopes === void 0) return;
94
+ if (!policy || !hasDefinedKey(policy)) return;
43
95
  const map = { ...readMetadataMap(POLICIES_KEY, ctor) };
44
96
  map[name] = policy;
45
97
  Reflect.defineMetadata(POLICIES_KEY, map, ctor);
@@ -112,6 +164,39 @@ function RealtimeAction(method, policy) {
112
164
  };
113
165
  }
114
166
  /**
167
+ * Décorateur de **classe** — pose les politiques d'accès d'actions ou de canaux
168
+ * par leur NOM, sans déclarer de handler.
169
+ *
170
+ * C'est la voie des actions servies par la table de `realtimeActions()` : cette
171
+ * table ne sait pas porter de politique, et un `@RealtimeAction` ajouté pour la
172
+ * seule politique ferait fabriquer, à chaque connexion, un handler aussitôt
173
+ * écrasé par la table (et contournerait l'injection de ses paramètres décorés).
174
+ * Ici, rien par connexion : les politiques sont écrites UNE fois, à la
175
+ * définition de la classe, dans le même registre que celles des décorateurs de
176
+ * méthode. Une action de la table sans politique garde le défaut FERMÉ
177
+ * (connexion authentifiée).
178
+ *
179
+ * Un nom qui porte `*` est un motif (famille de canaux), comme partout ailleurs.
180
+ *
181
+ * **Sur un contrôleur de contrat** (`extends ContractRealtimeController<C>`), le
182
+ * décorateur lit le contrat sur la classe qu'il décore : chaque nom doit être
183
+ * une méthode ou un canal de `C`, sinon la classe ne compile pas, et l'erreur
184
+ * nomme le fautif (`notInContract`). Le contrat ne se répète pas. Sur un
185
+ * contrôleur sans contrat, tout nom est accepté. Vérification à la compilation
186
+ * seulement : coût d'exécution nul.
187
+ *
188
+ * @param policies - nom d'action ou de canal → politique.
189
+ *
190
+ * @example
191
+ * @RealtimePolicy({ "orders:quote": { roles: ["ROLE_SALES"] } })
192
+ * class OrdersRt extends ContractRealtimeController<OrdersContract> { … }
193
+ */
194
+ function RealtimePolicy(policies) {
195
+ return function(target) {
196
+ for (const [name, policy] of Object.entries(policies)) definePolicy(target, name, policy);
197
+ };
198
+ }
199
+ /**
115
200
  * Décorateur **méthode** — expose un **canal pub/sub** sous le nom `channel`
116
201
  * (match EXACT, sans préfixe `:<ms>` ni regex). La méthode décorée DOIT renvoyer
117
202
  * un `dispose: () => void` (la base le mémorise et l'appelle au dernier
@@ -228,19 +313,40 @@ function getRealtimeInbound(instance) {
228
313
  return out;
229
314
  }
230
315
  /**
231
- * Lecture (lazy, au handshake) des politiques d'autorisation déclarées
232
- * (`@RealtimeChannel`/`@RealtimeInbound` avec opts). `null` si aucune → le
233
- * controller n'enregistre rien au hub (0 alloc). Donnée pure (pas de `bind` :
234
- * une policy n'est pas une méthode), renvoyée telle quelle pour publication au
235
- * hub realtime, qui l'expose à `@nodefony/security`.
316
+ * La classe déclare-t-elle une ACTION (`@RealtimeAction`) sous ce nom ? Sert à refuser un `allow` posé sur une action, que rien n'appliquerait.
317
+ *
318
+ * @param ctor - constructeur du contrôleur.
319
+ * @param name - nom d'action ou de canal.
236
320
  */
237
- function getRealtimeChannelPolicies(instance) {
238
- const map = readMetadataMap(POLICIES_KEY, instance.constructor);
321
+ function declaresAction(ctor, name) {
322
+ const map = readMetadataMap(ACTIONS_KEY, ctor);
323
+ return map !== void 0 && Object.prototype.hasOwnProperty.call(map, name);
324
+ }
325
+ /**
326
+ * Politiques d'autorisation déclarées sur une CLASSE de contrôleur (`@RealtimeChannel`/`@RealtimeInbound`/`@RealtimePolicy` avec politique), validées — lues au démarrage du noyau, jamais par trame.
327
+ *
328
+ * @param ctor - constructeur du contrôleur.
329
+ * @returns nom de canal ou de méthode → politique, ou `null` si la classe n'en
330
+ * déclare aucune (0 allocation).
331
+ * @throws BootConfigurationError Une politique est illisible : la classe, le
332
+ * canal et le défaut sont nommés.
333
+ */
334
+ function getChannelPoliciesOf(ctor) {
335
+ const map = readMetadataMap(POLICIES_KEY, ctor);
239
336
  if (map === void 0) return null;
240
- if (!isPolicyMap(map)) throw new TypeError(`${instance.constructor.name} : politique de canal temps réel illisible`);
337
+ assertPolicyMap(typeof ctor === "function" ? ctor.name : "(contrôleur)", map);
241
338
  return map;
242
339
  }
243
340
  /**
341
+ * Politiques déclarées sur la classe d'une instance de contrôleur — même lecture que {@link getChannelPoliciesOf}.
342
+ *
343
+ * @param instance - contrôleur instancié.
344
+ * @returns nom → politique, ou `null` si aucune.
345
+ */
346
+ function getRealtimeChannelPolicies(instance) {
347
+ return getChannelPoliciesOf(instance.constructor);
348
+ }
349
+ /**
244
350
  * Préfixes de canaux **broadcast** déclarés statiquement, tous controllers
245
351
  * confondus. Rempli à l'IMPORT des classes (le décorateur s'exécute au
246
352
  * chargement du module), donc connu **avant** tout trafic. `Set` : la
@@ -287,4 +393,4 @@ function getDeclaredBroadcastPrefixes() {
287
393
  function applyDeclaredBroadcastPrefixes(hub) {
288
394
  for (const prefix of declaredBroadcastPrefixes) hub.markBroadcastChannel(prefix);
289
395
  }
290
- export { DEFAULT_ACTION_POLICY, RealtimeAction, RealtimeBroadcast, RealtimeChannel, RealtimeInbound, applyDeclaredBroadcastPrefixes, getDeclaredBroadcastPrefixes, getRealtimeActions, getRealtimeChannelPolicies, getRealtimeChannels, getRealtimeInbound };
396
+ export { DEFAULT_ACTION_POLICY, RealtimeAction, RealtimeBroadcast, RealtimeChannel, RealtimeInbound, RealtimePolicy, applyDeclaredBroadcastPrefixes, channelPolicyDefect, declaresAction, getChannelPoliciesOf, getDeclaredBroadcastPrefixes, getRealtimeActions, getRealtimeChannelPolicies, getRealtimeChannels, getRealtimeInbound, isAdmissionClass };
@@ -0,0 +1 @@
1
+ export {};
@@ -1,5 +1,5 @@
1
1
  import resolveBackplaneOriginId from "./originId.js";
2
- import { CLUSTER_RT_KIND } from "nodefony";
2
+ import { CLUSTER_IPC_ADVANCED, CLUSTER_IPC_SERIALIZATION_ENV, CLUSTER_RT_KIND } from "nodefony";
3
3
  // nodefony/src/backplane/ClusterBackplane.ts
4
4
  /**
5
5
  * Transport de production : `process.send` (émission) + `process.on("message")`
@@ -13,6 +13,9 @@ const processIpcTransport = {
13
13
  },
14
14
  onReceive(cb) {
15
15
  process.on("message", (msg) => cb(msg));
16
+ },
17
+ carriesBytes() {
18
+ return process.channel !== void 0 && process.env[CLUSTER_IPC_SERIALIZATION_ENV] === CLUSTER_IPC_ADVANCED;
16
19
  }
17
20
  };
18
21
  /**
@@ -51,14 +54,24 @@ var ClusterBackplane = class ClusterBackplane {
51
54
  #transport;
52
55
  #handler = null;
53
56
  #started = false;
57
+ /**
58
+ * Récepteur posé sur le transport — une fois pour la vie de l'instance : le
59
+ * transport de production (`process.on("message")`) ne sait pas le retirer, et
60
+ * le reposer à chaque redémarrage doublerait chaque message reçu.
61
+ */
62
+ #listening = false;
63
+ /** Vrai une fois {@link enableBytes} constaté que le canal porte les octets. */
64
+ #bytes = false;
54
65
  constructor(transport = processIpcTransport, originId = resolveBackplaneOriginId()) {
55
66
  this.#transport = transport;
56
67
  this.originId = originId;
57
68
  }
58
- /** Branche la réception IPC. Idempotent. */
69
+ /** Branche la réception IPC. Idempotent, et redémarrable après {@link stop}. */
59
70
  start() {
60
71
  if (this.#started) return;
61
72
  this.#started = true;
73
+ if (this.#listening) return;
74
+ this.#listening = true;
62
75
  this.#transport.onReceive((msg) => this.#ingress(msg));
63
76
  }
64
77
  /**
@@ -77,8 +90,19 @@ var ClusterBackplane = class ClusterBackplane {
77
90
  onMessage(handler) {
78
91
  this.#handler = handler;
79
92
  }
93
+ /**
94
+ * Les octets traversent l'IPC tels quels quand le canal est en clonage
95
+ * structuré : aucun codec n'est appliqué, l'argument est ignoré.
96
+ *
97
+ * @returns `true` si le transport constate un canal `advanced`.
98
+ */
99
+ enableBytes() {
100
+ this.#bytes = this.#transport.carriesBytes?.() === true;
101
+ return this.#bytes;
102
+ }
80
103
  /** Tri + anti-echo + délégation au handler. Robuste : ignore tout message non-realtime. */
81
104
  #ingress(msg) {
105
+ if (!this.#started) return;
82
106
  if (!isEnvelope(msg)) return;
83
107
  if (msg.originId === this.originId) return;
84
108
  this.#handler?.({
@@ -87,7 +111,10 @@ var ClusterBackplane = class ClusterBackplane {
87
111
  originId: msg.originId
88
112
  });
89
113
  }
90
- /** Détache le handler. Le listener du transport prod (`process`) survit au process. */
114
+ /**
115
+ * Coupe la réception et détache le handler. Le récepteur du transport survit
116
+ * (`process` ne le retire pas) mais ne livre plus rien jusqu'au prochain {@link start}.
117
+ */
91
118
  stop() {
92
119
  this.#handler = null;
93
120
  this.#started = false;
@@ -97,7 +124,8 @@ var ClusterBackplane = class ClusterBackplane {
97
124
  driver: ClusterBackplane.driver,
98
125
  kind: "ipc",
99
126
  originId: this.originId,
100
- crossPod: false
127
+ crossPod: false,
128
+ ...this.#bytes && { bytes: true }
101
129
  };
102
130
  }
103
131
  };