@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.
- package/LICENSE +544 -0
- package/README.md +207 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/index.js +218 -0
- package/dist/nodefony/config/config.js +63 -0
- package/dist/nodefony/config/defineModuleConfig.js +60 -0
- package/dist/nodefony/decorators/realtimeDecorators.js +240 -0
- package/dist/nodefony/interfaces/IBackplane.js +1 -0
- package/dist/nodefony/interfaces/IChannelPolicy.js +1 -0
- package/dist/nodefony/interfaces/IClientLogsLimits.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeAuthenticator.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeAuthenticatorMatcher.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeController.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeHandshake.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeProbe.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeToken.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/src/backplane/ClusterBackplane.js +105 -0
- package/dist/nodefony/src/backplane/LoopbackBackplane.js +40 -0
- package/dist/nodefony/src/backplane/RedisBackplane.js +176 -0
- package/dist/nodefony/src/backplane/backplaneRegistry.js +20 -0
- package/dist/nodefony/src/backplane/envelope.js +109 -0
- package/dist/nodefony/src/backplane/originId.js +35 -0
- package/dist/nodefony/src/backplane/publishQueue.js +96 -0
- package/dist/nodefony/src/cluster/ClusterProbeClient.js +284 -0
- package/dist/nodefony/src/server/AnonymousRealtimeToken.js +35 -0
- package/dist/nodefony/src/server/RealtimeAdminApi.js +87 -0
- package/dist/nodefony/src/server/RealtimeController.js +601 -0
- package/dist/nodefony/src/server/RealtimeHub.js +926 -0
- package/dist/nodefony/src/server/ServerRealtimeSocket.js +168 -0
- package/dist/nodefony/src/server/deniedDetail.js +29 -0
- package/dist/nodefony/src/server/syslogUplink.js +105 -0
- package/dist/nodefony/src/server/welcomeEnv.js +25 -0
- package/dist/nodefony/src/service/RealtimeService.js +240 -0
- package/dist/nodefony/src/transport/WsConnectionTransport.js +94 -0
- package/dist/nodefony/testing/index.js +163 -0
- package/dist/types/index.d.ts +99 -0
- package/dist/types/nodefony/config/config.d.ts +43 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +49 -0
- package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +190 -0
- package/dist/types/nodefony/interfaces/IBackplane.d.ts +128 -0
- package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +28 -0
- package/dist/types/nodefony/interfaces/IClientLogsLimits.d.ts +22 -0
- package/dist/types/nodefony/interfaces/IRealtimeAuthenticator.d.ts +53 -0
- package/dist/types/nodefony/interfaces/IRealtimeAuthenticatorMatcher.d.ts +46 -0
- package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +36 -0
- package/dist/types/nodefony/interfaces/IRealtimeHandshake.d.ts +27 -0
- package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +206 -0
- package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +48 -0
- package/dist/types/nodefony/interfaces/index.d.ts +18 -0
- package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +76 -0
- package/dist/types/nodefony/src/backplane/LoopbackBackplane.d.ts +29 -0
- package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +164 -0
- package/dist/types/nodefony/src/backplane/backplaneRegistry.d.ts +51 -0
- package/dist/types/nodefony/src/backplane/envelope.d.ts +27 -0
- package/dist/types/nodefony/src/backplane/originId.d.ts +22 -0
- package/dist/types/nodefony/src/backplane/publishQueue.d.ts +71 -0
- package/dist/types/nodefony/src/cluster/ClusterProbeClient.d.ts +90 -0
- package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +3 -0
- package/dist/types/nodefony/src/server/RealtimeAdminApi.d.ts +18 -0
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +165 -0
- package/dist/types/nodefony/src/server/RealtimeHub.d.ts +516 -0
- package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +63 -0
- package/dist/types/nodefony/src/server/deniedDetail.d.ts +25 -0
- package/dist/types/nodefony/src/server/syslogUplink.d.ts +58 -0
- package/dist/types/nodefony/src/server/welcomeEnv.d.ts +21 -0
- package/dist/types/nodefony/src/service/RealtimeService.d.ts +167 -0
- package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +78 -0
- package/dist/types/nodefony/testing/index.d.ts +169 -0
- package/docs/actions.md +573 -0
- package/docs/architecture.md +889 -0
- package/docs/configuration.md +803 -0
- package/docs/cookbook-chat.md +1619 -0
- package/docs/couverture-bancs.md +139 -0
- package/docs/index.md +372 -0
- package/docs/observabilite.md +592 -0
- package/docs/protocole.md +518 -0
- package/docs/securite.md +845 -0
- package/docs/vocabulaire.md +845 -0
- 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 };
|