@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
@@ -0,0 +1,168 @@
1
+ import { getRealtimeHub } from "./RealtimeHub.js";
2
+ //#region nodefony/src/server/ServerRealtimeSocket.ts
3
+ /**
4
+ * **L4 — façade serveur « la socket Nodefony ».** Un handle SERVEUR qui implémente
5
+ * le contrat ISOMORPHE {@link IRealtimeSocket} au-dessus du {@link RealtimeHub} : un
6
+ * service back code **comme une page front** (`publish` / `subscribe` / `on` /
7
+ * `channel`) — il tient UN handle, ignore le fan-out et le backplane.
8
+ *
9
+ * Tient la promesse North Star « un consommateur — front OU back — tient UN handle ».
10
+ *
11
+ * ── Sémantique côté serveur ──
12
+ * - `publish(channel, payload)` : **fan-out** aux abonnés via le hub (+ backplane si
13
+ * le canal est broadcast). C'est le cas d'usage principal (un service pousse des
14
+ * events que les clients reçoivent).
15
+ * - `subscribe`/`on` : le service ÉCOUTE un canal (sink interne) — **écoute passive**
16
+ * (`RealtimeHub.listen`). Écouter n'ouvre PAS le canal : s'il n'a pas de provider,
17
+ * l'état créé est passif, et c'est la fabrique du controller qui tranchera quand
18
+ * une connexion cliente le demandera. Un service n'invente donc jamais un canal
19
+ * pour les autres.
20
+ * - `request` : **non supporté** — un handle au-dessus du hub n'a PAS de pair unique
21
+ * (le hub est multi-clients). Pour un RPC serveur→client 1-1, utiliser
22
+ * `RealtimeController.requestClient` (par connexion, L1).
23
+ *
24
+ * Lazy : aucune structure allouée tant que le service n'`on`/`subscribe`/`publish` pas.
25
+ *
26
+ * @typeParam Emit — canaux pub/sub SORTANTS (`publish`) = ServerToClient.
27
+ * @typeParam Listen — canaux pub/sub RÉCEPTIONNÉS (`subscribe`/`on`) = ClientToServer.
28
+ */
29
+ var ServerRealtimeSocket = class {
30
+ hub;
31
+ #handlers = null;
32
+ #sinks = null;
33
+ #subs = null;
34
+ #stats = null;
35
+ constructor(hub = getRealtimeHub()) {
36
+ this.hub = hub;
37
+ }
38
+ /** Émet sur un canal — fan-out aux abonnés via le hub (+ backplane si broadcast). */
39
+ publish(channel, payload) {
40
+ this.hub.publish(channel, payload);
41
+ }
42
+ /**
43
+ * S'abonne (ref-compté) à un canal pour le RECEVOIR. Au 1ᵉʳ consommateur, pose un
44
+ * sink d'**écoute passive** auprès du hub : le service reçoit ce qui passe, sans
45
+ * ouvrir le canal à qui que ce soit d'autre.
46
+ */
47
+ subscribe(channel) {
48
+ const c = channel;
49
+ const subs = this.#subs ??= /* @__PURE__ */ new Map();
50
+ const n = (subs.get(c) ?? 0) + 1;
51
+ subs.set(c, n);
52
+ if (n !== 1) return;
53
+ const sink = (payload) => this.#receive(c, payload);
54
+ (this.#sinks ??= /* @__PURE__ */ new Map()).set(c, sink);
55
+ this.hub.listen(c, sink);
56
+ }
57
+ /** Désabonne (ref-compté) : coupe le sink du hub au DERNIER consommateur. */
58
+ unsubscribe(channel) {
59
+ const c = channel;
60
+ const cur = this.#subs?.get(c);
61
+ if (!cur) return;
62
+ if (cur > 1) {
63
+ this.#subs.set(c, cur - 1);
64
+ return;
65
+ }
66
+ this.#subs.delete(c);
67
+ const sink = this.#sinks?.get(c);
68
+ if (sink) {
69
+ this.hub.unsubscribe(c, sink);
70
+ this.#sinks.delete(c);
71
+ }
72
+ }
73
+ /** Branche un handler de réception sur un canal. Renvoie un `dispose`. */
74
+ on(channel, handler) {
75
+ const handlers = this.#handlers ??= /* @__PURE__ */ new Map();
76
+ let set = handlers.get(channel);
77
+ if (!set) {
78
+ set = /* @__PURE__ */ new Set();
79
+ handlers.set(channel, set);
80
+ }
81
+ set.add(handler);
82
+ return () => this.off(channel, handler);
83
+ }
84
+ /** Retire un handler d'un canal. */
85
+ off(channel, handler) {
86
+ this.#handlers?.get(channel)?.delete(handler);
87
+ }
88
+ /**
89
+ * **Non supporté.** Un handle au-dessus du hub n'a pas de pair unique
90
+ * (multi-clients). Pour un RPC serveur→client 1-1, utiliser
91
+ * `RealtimeController.requestClient` (par connexion, L1).
92
+ */
93
+ request(_method, _params, _timeoutMs) {
94
+ return Promise.reject(/* @__PURE__ */ new Error("ServerRealtimeSocket.request: pas de pair unique côté hub (multi-clients). Pour un RPC serveur→client 1-1, utiliser RealtimeController.requestClient."));
95
+ }
96
+ /** Vue par-canal ({@link IRealtimeChannel}) — fine liaison sur les primitives. */
97
+ channel(name) {
98
+ const hub = this;
99
+ const disposers = /* @__PURE__ */ new Set();
100
+ return {
101
+ name,
102
+ on(handler) {
103
+ const dispose = hub.on(name, handler);
104
+ disposers.add(dispose);
105
+ return () => {
106
+ dispose();
107
+ disposers.delete(dispose);
108
+ };
109
+ },
110
+ send(payload) {
111
+ hub.publish(name, payload);
112
+ },
113
+ open() {
114
+ hub.subscribe(name);
115
+ },
116
+ close() {
117
+ for (const d of disposers) d();
118
+ disposers.clear();
119
+ hub.unsubscribe(name);
120
+ }
121
+ };
122
+ }
123
+ /** Snapshot des compteurs par canal (refs internes — à LIRE). */
124
+ getStats() {
125
+ return this.#stats ? Array.from(this.#stats.values()) : [];
126
+ }
127
+ /** Compteurs d'un canal précis ou `undefined`. */
128
+ getChannelStats(channel) {
129
+ return this.#stats?.get(channel);
130
+ }
131
+ /** Canaux actuellement abonnés (≥ 1 consommateur). */
132
+ get subscribedChannels() {
133
+ return this.#subs ? Array.from(this.#subs.keys()) : [];
134
+ }
135
+ /** Dispatch d'une charge fan-outée par le hub : stats + handlers locaux + wildcard. */
136
+ #receive(channel, payload) {
137
+ const stats = this.#stats ??= /* @__PURE__ */ new Map();
138
+ let st = stats.get(channel);
139
+ if (!st) {
140
+ st = {
141
+ method: channel,
142
+ msgCount: 0,
143
+ lastMessage: null,
144
+ rate: 0,
145
+ series: []
146
+ };
147
+ stats.set(channel, st);
148
+ }
149
+ st.msgCount++;
150
+ st.lastMessage = Date.now();
151
+ this.#handlers?.get(channel)?.forEach((h) => {
152
+ try {
153
+ h(payload);
154
+ } catch {}
155
+ });
156
+ this.#handlers?.get("*")?.forEach((h) => {
157
+ try {
158
+ h(channel, payload);
159
+ } catch {}
160
+ });
161
+ }
162
+ };
163
+ /** Crée un handle serveur ({@link ServerRealtimeSocket}) sur le hub courant. */
164
+ function serverSocket() {
165
+ return new ServerRealtimeSocket(getRealtimeHub());
166
+ }
167
+ //#endregion
168
+ export { ServerRealtimeSocket, ServerRealtimeSocket as default, serverSocket };
@@ -0,0 +1,29 @@
1
+ //#region nodefony/src/server/deniedDetail.ts
2
+ /**
3
+ * Le détail d'un `realtime:denied` — dit au développeur, tu à l'utilisateur.
4
+ *
5
+ * `RealtimeDeniedReason` est fermé et générique par construction : `forbidden`
6
+ * ne dit pas si c'est un rôle qui manque ou le plancher de plateforme qui est
7
+ * clos, et c'est ce qui l'empêche d'être un oracle. Le prix de cette prudence
8
+ * est payé par le développeur : il reçoit un refus qu'il ne sait pas lire, et
9
+ * il lui reste à deviner ce qu'il faut regarder.
10
+ *
11
+ * Cette fonction pose donc la phrase utile — **et seulement hors production**,
12
+ * où la même phrase serait exactement l'oracle que `reason` refuse d'être.
13
+ *
14
+ * Isolée pour la même raison que {@link welcomeEnv}, dont elle copie la règle :
15
+ * noyée dans la composition du refus, son inversion ne casserait aucun test et
16
+ * livrerait le détail d'une politique aux visiteurs d'un serveur publié. Une
17
+ * absence vaut production, jamais l'inverse.
18
+ *
19
+ * @param env - le mode du kernel serveur (`kernel.environment`), s'il est connu.
20
+ * @param detail - la phrase à dire, déjà rédigée par l'appelant.
21
+ * @returns le fragment à étaler dans le refus : `{ detail }` hors production,
22
+ * sinon un objet vide.
23
+ */
24
+ function deniedDetail(env, detail) {
25
+ if (!env || env === "production") return {};
26
+ return { detail };
27
+ }
28
+ //#endregion
29
+ export { deniedDetail };
@@ -0,0 +1,105 @@
1
+ import { BROWSER_ORIGIN, Pdu } from "nodefony";
2
+ //#region nodefony/src/server/syslogUplink.ts
3
+ /**
4
+ * Réception des journaux **remontés par un navigateur** : le pendant serveur du
5
+ * transport client (`nodefony/client` → `installSyslogUplink`).
6
+ *
7
+ * Un canal entrant qui écrit dans le journal du pod est une **surface d'écriture**, et
8
+ * c'est la seule chose qui compte pour sa conception. Trois bornes, non négociables :
9
+ *
10
+ * 1. **L'origine est FORCÉE.** Le `moduleName` que le client prétend n'est jamais repris :
11
+ * le `Pdu` réinjecté porte {@link BROWSER_ORIGIN}. Sans quoi une page pourrait se faire
12
+ * passer pour le noyau et fabriquer des pistes de diagnostic.
13
+ * 2. **La taille et le débit sont bornés**, par connexion. Un onglet ouvert ne doit pas
14
+ * pouvoir noyer le journal d'exploitation — ni par un lot géant, ni par la répétition.
15
+ * 3. **La sévérité est plafonnée.** Un client ne déclenche pas une alerte d'exploitation :
16
+ * tout ce qu'il envoie de plus grave que {@link MAX_CLIENT_SEVERITY} y est ramené.
17
+ *
18
+ * Ce que ces bornes ne couvrent PAS, et qu'il faut savoir en lisant le journal : le
19
+ * `requestId` d'une entrée est **déclaré par le client**. On le valide en forme, jamais en
20
+ * fond — un client peut donc rattacher ses lignes à une requête qui n'est pas la sienne.
21
+ * L'impact est une piste de diagnostic polluée, jamais une élévation de droit ; et le
22
+ * `moduleName` forcé dit au lecteur que la ligne vient d'un navigateur, donc qu'elle est
23
+ * déclarative. C'est le prix d'une corrélation front↔back, et il s'énonce.
24
+ */
25
+ /**
26
+ * Sévérité la plus grave qu'un client puisse obtenir (3 = ERROR). Au-dessus vivent
27
+ * CRITIC, ALERT et EMERGENCY — des niveaux d'exploitation qu'un onglet ne décide pas.
28
+ */
29
+ const MAX_CLIENT_SEVERITY = 3;
30
+ /** Chaîne bornée, ou `undefined` si ce n'en est pas une. */
31
+ function str(v, max) {
32
+ if (typeof v !== "string") return void 0;
33
+ return v.length <= max ? v : v.slice(0, max);
34
+ }
35
+ /**
36
+ * `requestId` accepté seulement s'il ressemble à un identifiant : imprimable, sans
37
+ * espace, borné. On ne vérifie pas qu'il EXISTE (ce serait une lecture par entrée sur le
38
+ * chemin chaud) — seulement qu'il ne peut pas servir à injecter autre chose.
39
+ */
40
+ function safeRequestId(v, max) {
41
+ if (typeof v !== "string" || v.length === 0 || v.length > max) return void 0;
42
+ for (let i = 0; i < v.length; i += 1) {
43
+ const c = v.charCodeAt(i);
44
+ if (c <= 32 || c >= 127) return void 0;
45
+ }
46
+ return v;
47
+ }
48
+ /**
49
+ * Sévérité ramenée dans les bornes : un entier RFC 5424 valide, jamais plus grave que
50
+ * {@link MAX_CLIENT_SEVERITY}. Tout ce qui n'est pas un nombre exploitable devient
51
+ * `ERROR` — une entrée arrivée jusqu'ici a été jugée digne d'être envoyée.
52
+ */
53
+ function clampSeverity(v) {
54
+ if (typeof v !== "number" || !Number.isInteger(v)) return 3;
55
+ if (v <= 3) return 3;
56
+ if (v === 4) return 4;
57
+ if (v === 5) return 5;
58
+ if (v === 6) return 6;
59
+ return 7;
60
+ }
61
+ /**
62
+ * Fabrique le handler du canal montant des journaux.
63
+ *
64
+ * **Un handler par connexion** : `RealtimeController.realtimeInbound()` est invoqué au
65
+ * handshake, donc le compteur de débit qu'il capture est per-connexion — ce qui est le
66
+ * seul découpage utile (borner globalement laisserait un onglet bavard museler tous les
67
+ * autres).
68
+ *
69
+ * @param opts - journal cible et bornes.
70
+ * @returns le handler à déclarer dans `realtimeInbound()`.
71
+ */
72
+ function createSyslogUplinkHandler(opts) {
73
+ const { syslog, maxEntriesPerBatch = 50, maxEntriesPerWindow = 300, windowMs = 1e4, maxStringLength = 4096, maxRequestIdLength = 128 } = opts;
74
+ let windowStart = 0;
75
+ let windowCount = 0;
76
+ return function onSyslogUplink(params) {
77
+ if (params === null || typeof params !== "object") return;
78
+ const batch = params;
79
+ const entries = batch.entries;
80
+ if (!Array.isArray(entries) || entries.length === 0) return;
81
+ const pageId = safeRequestId(batch.pageId, maxRequestIdLength);
82
+ if (pageId === void 0) return;
83
+ const now = Date.now();
84
+ if (now - windowStart > windowMs) {
85
+ windowStart = now;
86
+ windowCount = 0;
87
+ }
88
+ const limit = Math.min(entries.length, maxEntriesPerBatch);
89
+ for (let i = 0; i < limit; i += 1) {
90
+ if (windowCount >= maxEntriesPerWindow) return;
91
+ windowCount += 1;
92
+ const raw = entries[i];
93
+ if (raw === null || typeof raw !== "object") continue;
94
+ const severity = clampSeverity(raw.severity);
95
+ const msgid = str(raw.msgid, 64) ?? "";
96
+ const msg = str(raw.msg, maxStringLength) ?? "";
97
+ const timeStamp = typeof raw.timeStamp === "number" && Number.isFinite(raw.timeStamp) ? raw.timeStamp : now;
98
+ const pdu = new Pdu(raw.payload, severity, BROWSER_ORIGIN, msgid, msg, timeStamp);
99
+ pdu.requestId = safeRequestId(raw.requestId, maxRequestIdLength) ?? pageId;
100
+ syslog.log(pdu);
101
+ }
102
+ };
103
+ }
104
+ //#endregion
105
+ export { BROWSER_ORIGIN, MAX_CLIENT_SEVERITY, createSyslogUplinkHandler };
@@ -0,0 +1,25 @@
1
+ //#region nodefony/src/server/welcomeEnv.ts
2
+ /**
3
+ * Le mode d'exécution annoncé — ou non — dans `realtime:welcome`.
4
+ *
5
+ * Le client ne dispose sinon que d'`import.meta.env.DEV`, qui dit le mode du
6
+ * BUNDLE : une application bâtie pour la production mais servie par un serveur
7
+ * de développement (banc, cluster local, image essayée sur le poste) restait
8
+ * muette dans la console alors qu'on avait tout intérêt à la faire parler.
9
+ *
10
+ * ⚠️ **En production, le champ est ABSENT** — et c'est la raison d'être de cette
11
+ * fonction, isolée pour être éprouvée dans les deux sens. Ce n'est pas un
12
+ * réglage que le client lit, c'est une permission de parler dans la console de
13
+ * quelqu'un : on ne l'accorde pas par défaut, et une absence vaut production,
14
+ * jamais l'inverse.
15
+ *
16
+ * @param env - le mode du kernel serveur (`kernel.environment`), s'il est connu.
17
+ * @returns le fragment à étaler dans le welcome : `{ env }` hors production,
18
+ * sinon un objet vide.
19
+ */
20
+ function welcomeEnv(env) {
21
+ if (!env || env === "production") return {};
22
+ return { env };
23
+ }
24
+ //#endregion
25
+ export { welcomeEnv };
@@ -0,0 +1,240 @@
1
+ import { RESERVED_SYSTEM_PREFIXES, getRealtimeHub } from "../server/RealtimeHub.js";
2
+ import { applyDeclaredBroadcastPrefixes } from "../../decorators/realtimeDecorators.js";
3
+ import { Service } from "nodefony";
4
+ //#region nodefony/src/service/RealtimeService.ts
5
+ const serviceName = "realtimeService";
6
+ /**
7
+ * Façade DI publique de la **socket Nodefony** côté serveur.
8
+ *
9
+ * Wrapper mince autour du singleton {@link RealtimeHub} : expose
10
+ * `publish`/`subscribe`/`probe` à travers le container DI, applique au boot le
11
+ * backplane custom éventuel (config `backplane.instance` OU service DI nommé
12
+ * `realtimeBackplane`), expose la config gelée pour Studio / introspection.
13
+ *
14
+ * Ce service ne **remplace pas** le hub singleton (consommé par les
15
+ * controllers, l'admin API et les WS handlers via `getRealtimeHub()`) ; il en
16
+ * est l'API stable consommable côté userland. La Module class garde la main
17
+ * sur le wiring `ClusterBackplane` (lié au lifecycle `onCluster` kernel,
18
+ * pré-`onPreBoot`) — orthogonal au backplane custom userland.
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * class MyService extends Service {
23
+ * constructor(public module: Module) {
24
+ * super(...);
25
+ * this.realtime = this.get<RealtimeService>("realtimeService");
26
+ * }
27
+ * notify(payload: unknown) {
28
+ * this.realtime?.publish("my-app:event", payload);
29
+ * }
30
+ * }
31
+ * ```
32
+ */
33
+ var RealtimeService = class extends Service {
34
+ module;
35
+ #config = null;
36
+ #hub = null;
37
+ constructor(module) {
38
+ super(serviceName, module.container, module.notificationsCenter, module.options);
39
+ this.module = module;
40
+ }
41
+ /**
42
+ * Initialise le service au moment où le Module l'enregistre via `addService`
43
+ * (phase `onPreBoot`). Pose la config validée + applique l'éventuel backplane
44
+ * custom userland. Idempotent.
45
+ *
46
+ * Source du backplane custom (priorité décroissante) :
47
+ * 1. `realtimeConfig.backplane.instance` posée par la Module class via le
48
+ * container (mode "config typée" : `defineRealtimeConfig({}, { backplane })`)
49
+ * 2. Service DI nommé `realtimeBackplane` (mode "userland enregistre une
50
+ * instance dans son module via container.set")
51
+ *
52
+ * Si aucun n'est fourni → le hub reste avec son backplane par défaut (null =
53
+ * Loopback implicite, ou ClusterBackplane si le worker IPC a déjà été câblé
54
+ * par la Module class). Aucun warn — c'est le cas commun.
55
+ */
56
+ async init(_module) {
57
+ this.#config = this.module.config;
58
+ if (!this.#config) throw new Error(`${serviceName}: realtimeConfig absente (this.module.config vide) — la Module class doit la valider à onKernelRegister`);
59
+ if (!this.#config.enabled) {
60
+ this.log("realtime enabled:false → service inerte (hub non câblé)", "INFO");
61
+ return this;
62
+ }
63
+ const hub = this.getHub();
64
+ const custom = this.#config.backplane.instance ?? this.get("realtimeBackplane");
65
+ if (custom && hub.backplane === null) {
66
+ hub.setBackplane(custom);
67
+ this.log(`Backplane custom branché (driver déclaré=${this.#config.backplane.driver})`, "INFO");
68
+ }
69
+ hub.setOriginGuard(buildOriginGuard(this.#config));
70
+ hub.setMaxChannelsPerConnection(this.#config.limits.maxChannelsPerConnection);
71
+ hub.setSlowConsumerBytes(this.#config.slowConsumer.bytes);
72
+ hub.setClientLogsLimits(this.#config.clientLogs.enabled ? {
73
+ maxEntriesPerBatch: this.#config.clientLogs.maxEntriesPerBatch,
74
+ maxEntriesPerWindow: this.#config.clientLogs.maxEntriesPerWindow,
75
+ windowMs: this.#config.clientLogs.windowMs,
76
+ maxStringLength: this.#config.clientLogs.maxStringLength
77
+ } : null);
78
+ applyDeclaredBroadcastPrefixes(hub);
79
+ return this;
80
+ }
81
+ /** Configuration realtime gelée (lue par Studio / introspection). */
82
+ getConfig() {
83
+ if (!this.#config) throw new Error(`${serviceName}: not initialized`);
84
+ return this.#config;
85
+ }
86
+ /** Hub singleton (broker fan-out canaux PARTAGÉS du process courant). */
87
+ getHub() {
88
+ return this.#hub ??= getRealtimeHub();
89
+ }
90
+ /** Backplane actuellement branché sur le hub (ou null = Loopback implicite). */
91
+ getBackplane() {
92
+ return this.getHub().backplane;
93
+ }
94
+ /**
95
+ * Publie une charge sur un canal — fan-out local + propagation backplane si
96
+ * canal broadcast. Délégué à `RealtimeHub.publish`.
97
+ */
98
+ publish(channel, payload) {
99
+ this.getHub().publish(channel, payload);
100
+ }
101
+ /**
102
+ * Abonne un sink à un canal partagé — factory invoquée au 1ᵉʳ abonné, dispose
103
+ * appelé au dernier désabonné. Délégué à `RealtimeHub.subscribe`.
104
+ *
105
+ * @returns `true` si abonné, `false` si la factory a refusé (canal inconnu).
106
+ */
107
+ subscribe(channel, sink, factory) {
108
+ return this.getHub().subscribe(channel, sink, factory);
109
+ }
110
+ /**
111
+ * Désabonne un sink — appelle `dispose` du provider partagé au dernier abonné.
112
+ * Délégué à `RealtimeHub.unsubscribe`. No-op si non abonné.
113
+ */
114
+ unsubscribe(channel, sink) {
115
+ this.getHub().unsubscribe(channel, sink);
116
+ }
117
+ /**
118
+ * Enregistre la factory d'un **canal système** (plateforme) — délégué à
119
+ * {@link RealtimeHub.registerSystemChannel}. Un module bas niveau
120
+ * (`@nodefony/security` → `nodefony:audit`) déclare au boot comment produire le
121
+ * provider, sans qu'aucun `RealtimeController` ne le connaisse ; tout endpoint
122
+ * le sert alors (lazy : créé au 1ᵉʳ abonné, disposé au dernier).
123
+ */
124
+ registerSystemChannel(channel, factory) {
125
+ this.getHub().registerSystemChannel(channel, factory);
126
+ }
127
+ /**
128
+ * Namespaces de canaux **réservés à la plateforme** — la liste que le hub ferme
129
+ * lui-même en l'absence de module de sécurité.
130
+ *
131
+ * Exposée pour qu'un module de sécurité y attache ses politiques **sans
132
+ * redéclarer la liste** : deux inventaires auraient divergé au premier namespace
133
+ * ajouté, et la divergence serait passée inaperçue (un namespace neuf resterait
134
+ * simplement sans politique). Ici, le hub possède la liste, la sécurité possède
135
+ * les rôles.
136
+ */
137
+ reservedSystemPrefixes() {
138
+ return RESERVED_SYSTEM_PREFIXES;
139
+ }
140
+ /** Snapshot d'observabilité du hub local (consommé par `/nodefony/realtime/api/health`). */
141
+ probe() {
142
+ return this.getHub().probe();
143
+ }
144
+ /**
145
+ * Marque un préfixe de canal comme **broadcast** (cross-process via le
146
+ * backplane). Sans appel, tout canal reste instance-local. Délégué à
147
+ * `RealtimeHub.markBroadcastChannel`.
148
+ */
149
+ markBroadcastChannel(prefix) {
150
+ this.getHub().markBroadcastChannel(prefix);
151
+ }
152
+ /**
153
+ * **Seam #2/#3 (P13 → P6)** — enregistre un authenticator pour les
154
+ * handshakes WS dont l'URL matche `matcher`. Ordre d'enregistrement
155
+ * préservé (1ʳᵉ match capture). Cold path (boot via P6/userland).
156
+ *
157
+ * @example
158
+ * ```ts
159
+ * // À brancher par @nodefony/security (P6) au boot, depuis les areas
160
+ * // de `defineSecurityConfig()` filtrées sur `realtime: true`.
161
+ * realtimeService.useAuthenticator(
162
+ * { pattern: "/admin/", host: "admin.example.com" },
163
+ * jwtRealtimeAuthenticator,
164
+ * );
165
+ * ```
166
+ */
167
+ useAuthenticator(matcher, authenticator) {
168
+ this.getHub().useAuthenticator(matcher, authenticator);
169
+ }
170
+ /**
171
+ * **Seam #1 (P13 → P6)** — pose le verrou de frame (hot-path `beforeDispatch`).
172
+ * `null` retire la politique. À brancher 1× au boot par `@nodefony/security`
173
+ * depuis les zones `realtime: true`. L'authorizer lit le token déjà résolu au
174
+ * handshake (0 lecture base par frame). Délégué à `RealtimeHub.setFrameAuthorizer`.
175
+ *
176
+ * `options.silentProbe` porte la MÊME décision sans rapporter de refus : elle
177
+ * répond « ce visiteur pourrait-il s'abonner ici ? » pour l'annonce des canaux
178
+ * du `realtime:welcome`, qui interroge N canaux par connexion sans que
179
+ * personne n'ait rien demandé. Elle se pose par CE MÊME appel pour qu'aucun
180
+ * câblage ne puisse poser l'un sans l'autre.
181
+ *
182
+ * @example
183
+ * ```ts
184
+ * // @nodefony/security au boot :
185
+ * realtimeService.setFrameAuthorizer((frame, token) => {
186
+ * // api.request {path} ≤ GET {path} : zone protégée → exige authentifié
187
+ * return isFrameAllowed(frame, token);
188
+ * });
189
+ * ```
190
+ */
191
+ setFrameAuthorizer(authorizer, options) {
192
+ this.getHub().setFrameAuthorizer(authorizer, options);
193
+ }
194
+ /**
195
+ * **Seam #1b** — politique d'autorisation déclarée pour un canal/méthode
196
+ * (`@RealtimeChannel`/`@RealtimeInbound` avec opts), agrégée par le hub au
197
+ * handshake. Lu par `@nodefony/security` au moment de la frame `subscribe`/
198
+ * inbound pour appliquer rôles/scopes. `null` = aucune politique métier (le
199
+ * canal est alors soumis à la seule politique plateforme de security).
200
+ *
201
+ * @param channel - nom EXACT du canal ou de la méthode inbound.
202
+ * @returns la politique déclarée, ou `null`.
203
+ */
204
+ resolveChannelPolicy(channel) {
205
+ return this.getHub().resolveChannelPolicy(channel);
206
+ }
207
+ /**
208
+ * Renvoie le token associé à un peer (posé au handshake par le hub). Jamais
209
+ * `null` (Zero Trust : `ANONYMOUS_REALTIME_TOKEN` si aucun authenticator n'a
210
+ * capturé la connexion). Lookup O(1) via WeakMap interne. Branchement P6 :
211
+ * voters / `@IsGranted` realtime lisent l'identité par cette méthode.
212
+ */
213
+ getTokenForPeer(peer) {
214
+ return this.getHub().getTokenForPeer(peer);
215
+ }
216
+ };
217
+ /**
218
+ * Construit la fonction de garde Origin (RFC 6455 §10.2) depuis la config CSRF.
219
+ * `null` quand le check est désactivé → hub bypass total (rétrocompat). Cold
220
+ * path (1× par boot).
221
+ *
222
+ * Politique fail-closed :
223
+ * - `enabled: false` → `null` (toutes origines acceptées).
224
+ * - `enabled: true`, Origin absent → accepté si `allowMissingOrigin: true`,
225
+ * sinon refusé.
226
+ * - `enabled: true`, Origin présent → accepté **uniquement** si exact dans
227
+ * `allowList` (pas de wildcard).
228
+ */
229
+ function buildOriginGuard(config) {
230
+ const c = config.csrf?.checkOrigin;
231
+ if (!c || c.enabled !== true) return null;
232
+ const allowSet = new Set(c.allowList ?? []);
233
+ const allowMissing = c.allowMissingOrigin === true;
234
+ return (origin) => {
235
+ if (origin === void 0 || origin === "") return allowMissing;
236
+ return allowSet.has(origin);
237
+ };
238
+ }
239
+ //#endregion
240
+ export { RealtimeService, RealtimeService as default };
@@ -0,0 +1,94 @@
1
+ import { TransportState } from "nodefony";
2
+ import { decideSend } from "@nodefony/http";
3
+ //#region nodefony/src/transport/WsConnectionTransport.ts
4
+ /**
5
+ * Contre-pression WS — **la règle vit dans `@nodefony/http`**, ce transport ne
6
+ * fait que l'appliquer (`decideSend`). Deux implémentations de la même
7
+ * protection avaient déjà divergé en silence : 4 MiB côté `http`, 1 MiB ici.
8
+ *
9
+ * Réglage = config du serveur WebSocket (`websocket.maxBackpressure`,
10
+ * `.backpressurePolicy`, `.backpressureCloseAfterDrops`) — une seule source pour
11
+ * `send`/`broadcast` HTTP **et** pour le realtime.
12
+ *
13
+ * ⚠️ NE PAS confondre avec `slowConsumer.bytes` (config realtime) : celle-ci ne
14
+ * règle QUE le seuil de COMPTAGE de la sonde, jamais l'action.
15
+ */
16
+ /**
17
+ * WsConnectionTransport — transport {@link IRealtimeTransport} CÔTÉ SERVEUR :
18
+ * wrap d'une connexion `ws` (1 par client connecté). Pendant serveur du
19
+ * `BrowserWsTransport` → la connexion serveur compose le MÊME `JsonRpcPeer` que
20
+ * le client (symétrie isomorphe).
21
+ *
22
+ * Particularité serveur : l'inbound n'est PAS piloté par un event socket mais par
23
+ * le **pipeline framework** (la route WS appelle `feed(raw)` à chaque message) ;
24
+ * la fermeture est signalée par `fireClose()` (hook `onFinish` du Context). Le
25
+ * `connect`/`onOpen` sont des no-op (la connexion est déjà ouverte au handshake).
26
+ */
27
+ var WsConnectionTransport = class {
28
+ conn;
29
+ _onMessage = null;
30
+ _onClose = null;
31
+ _bytesSent = 0;
32
+ _messagesSent = 0;
33
+ _dropped = 0;
34
+ _max;
35
+ _policy;
36
+ _closeAfterDrops;
37
+ constructor(conn, limits) {
38
+ this.conn = conn;
39
+ this._max = limits?.max ?? 0;
40
+ this._policy = limits?.policy ?? "drop";
41
+ this._closeAfterDrops = limits?.closeAfterDrops ?? 0;
42
+ }
43
+ connect() {}
44
+ send(raw) {
45
+ if (this.conn.readyState !== TransportState.OPEN) return;
46
+ if (decideSend(this.conn, this._max, this._policy, this._closeAfterDrops) !== "send") {
47
+ this._dropped += 1;
48
+ return;
49
+ }
50
+ this._bytesSent += raw.length;
51
+ this._messagesSent += 1;
52
+ this.conn.send(raw, () => {});
53
+ }
54
+ close(code, reason) {
55
+ this.conn.close(code, reason);
56
+ }
57
+ get readyState() {
58
+ return this.conn.readyState;
59
+ }
60
+ /** Octets en file d'envoi non drainés (`ws.bufferedAmount`) — risque #1. `0` si mock. */
61
+ get bufferedAmount() {
62
+ return this.conn.bufferedAmount ?? 0;
63
+ }
64
+ /** Cumul d'octets envoyés sur cette connexion (monotone). */
65
+ get bytesSent() {
66
+ return this._bytesSent;
67
+ }
68
+ /** Cumul de frames envoyées sur cette connexion (monotone). */
69
+ get messagesSent() {
70
+ return this._messagesSent;
71
+ }
72
+ /** Cumul de frames jetées par back-pressure (drop + close slow-consumer) — monotone. */
73
+ get dropped() {
74
+ return this._dropped;
75
+ }
76
+ onOpen() {}
77
+ onMessage(cb) {
78
+ this._onMessage = cb;
79
+ }
80
+ onClose(cb) {
81
+ this._onClose = cb;
82
+ }
83
+ onError() {}
84
+ /** Pump entrant : le pipeline framework pousse ici chaque message reçu. */
85
+ feed(raw) {
86
+ this._onMessage?.(raw);
87
+ }
88
+ /** Fermeture : appelée par le owner sur `onFinish` (close WS). */
89
+ fireClose(code = 1e3, reason = "") {
90
+ this._onClose?.(code, reason);
91
+ }
92
+ };
93
+ //#endregion
94
+ export { WsConnectionTransport, WsConnectionTransport as default };