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

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 (96) hide show
  1. package/README.md +55 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +91 -12
  4. package/dist/nodefony/config/config.js +7 -0
  5. package/dist/nodefony/decorators/realtimeDecorators.js +341 -43
  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 +146 -41
  9. package/dist/nodefony/src/backplane/controlOrder.js +86 -0
  10. package/dist/nodefony/src/backplane/envelope.js +505 -27
  11. package/dist/nodefony/src/backplane/ingress.js +93 -0
  12. package/dist/nodefony/src/backplane/presenceMessage.js +205 -0
  13. package/dist/nodefony/src/backplane/publishQueue.js +97 -3
  14. package/dist/nodefony/src/cluster/ClusterProbeClient.js +2 -0
  15. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
  16. package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
  17. package/dist/nodefony/src/server/RealtimeConnection.js +135 -0
  18. package/dist/nodefony/src/server/RealtimeController.js +409 -107
  19. package/dist/nodefony/src/server/RealtimeHub.js +1158 -111
  20. package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
  21. package/dist/nodefony/src/server/admissionBudget.js +176 -0
  22. package/dist/nodefony/src/server/channelPolicyRegistry.js +95 -0
  23. package/dist/nodefony/src/server/channelPolicyResolution.js +191 -0
  24. package/dist/nodefony/src/server/channelPresence.js +488 -0
  25. package/dist/nodefony/src/server/connectionAdmission.js +242 -0
  26. package/dist/nodefony/src/server/contractRoutes.js +75 -0
  27. package/dist/nodefony/src/server/deniedDetail.js +10 -3
  28. package/dist/nodefony/src/server/frameAdmission.js +160 -0
  29. package/dist/nodefony/src/server/presenceBus.js +486 -0
  30. package/dist/nodefony/src/server/realtimeShutdown.js +31 -0
  31. package/dist/nodefony/src/server/remotePresence.js +541 -0
  32. package/dist/nodefony/src/server/subscribedAck.js +13 -1
  33. package/dist/nodefony/src/server/syslogUplink.js +3 -4
  34. package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
  35. package/dist/nodefony/src/service/RealtimeService.js +51 -1
  36. package/dist/nodefony/src/service/publicOrigin.js +40 -0
  37. package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
  38. package/dist/nodefony/testing/backplaneConformance.js +347 -0
  39. package/dist/nodefony/testing/index.js +43 -12
  40. package/dist/nodefony/testing/presenceClient.js +54 -0
  41. package/dist/types/index.d.ts +39 -7
  42. package/dist/types/nodefony/config/config.d.ts +9 -0
  43. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +198 -17
  44. package/dist/types/nodefony/interfaces/IBackplane.d.ts +46 -51
  45. package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
  46. package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
  47. package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
  48. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
  49. package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
  50. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
  51. package/dist/types/nodefony/interfaces/index.d.ts +2 -1
  52. package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
  53. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +89 -18
  54. package/dist/types/nodefony/src/backplane/controlOrder.d.ts +75 -0
  55. package/dist/types/nodefony/src/backplane/envelope.d.ts +77 -0
  56. package/dist/types/nodefony/src/backplane/ingress.d.ts +48 -0
  57. package/dist/types/nodefony/src/backplane/presenceMessage.d.ts +150 -0
  58. package/dist/types/nodefony/src/backplane/publishQueue.d.ts +25 -1
  59. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +10 -0
  60. package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
  61. package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +20 -0
  62. package/dist/types/nodefony/src/server/RealtimeController.d.ts +110 -12
  63. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +448 -42
  64. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
  65. package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
  66. package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +41 -0
  67. package/dist/types/nodefony/src/server/channelPolicyResolution.d.ts +112 -0
  68. package/dist/types/nodefony/src/server/channelPresence.d.ts +155 -0
  69. package/dist/types/nodefony/src/server/connectionAdmission.d.ts +68 -0
  70. package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
  71. package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
  72. package/dist/types/nodefony/src/server/frameAdmission.d.ts +66 -0
  73. package/dist/types/nodefony/src/server/presenceBus.d.ts +112 -0
  74. package/dist/types/nodefony/src/server/realtimeShutdown.d.ts +21 -0
  75. package/dist/types/nodefony/src/server/remotePresence.d.ts +151 -0
  76. package/dist/types/nodefony/src/server/subscribedAck.d.ts +10 -0
  77. package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
  78. package/dist/types/nodefony/src/service/RealtimeService.d.ts +17 -2
  79. package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
  80. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
  81. package/dist/types/nodefony/testing/backplaneConformance.d.ts +78 -0
  82. package/dist/types/nodefony/testing/index.d.ts +13 -2
  83. package/dist/types/nodefony/testing/presenceClient.d.ts +20 -0
  84. package/docs/actions.md +420 -18
  85. package/docs/architecture.md +101 -11
  86. package/docs/cadence-et-contre-pression.md +87 -11
  87. package/docs/canaux-a-membres.md +277 -0
  88. package/docs/cas-d-usage.md +1703 -0
  89. package/docs/configuration.md +175 -19
  90. package/docs/cookbook-chat.md +701 -224
  91. package/docs/index.md +11 -1
  92. package/docs/observabilite.md +97 -21
  93. package/docs/protocole.md +142 -33
  94. package/docs/securite.md +347 -41
  95. package/docs/vocabulaire.md +13 -12
  96. package/package.json +16 -13
@@ -1,4 +1,6 @@
1
1
  import { isCallable, isRecord } from "../src/guards.js";
2
+ import { presenceDefect, samePresence } from "../src/server/channelPresence.js";
3
+ import { BootConfigurationError, Injector, channelPatternWeight, compileChannelPattern } from "nodefony";
2
4
  import "reflect-metadata";
3
5
  // nodefony/decorators/realtimeDecorators.ts
4
6
  /** Clés de métadonnées posées sur le constructor de la classe controller. */
@@ -6,6 +8,10 @@ const ACTIONS_KEY = "realtime:actions";
6
8
  const CHANNELS_KEY = "realtime:channels";
7
9
  const INBOUND_KEY = "realtime:inbound";
8
10
  const POLICIES_KEY = "realtime:policies";
11
+ const WRITE_POLICIES_KEY = "realtime:write-policies";
12
+ const OWN_READ_KEY = "realtime:policies:own-read";
13
+ const OWN_WRITE_KEY = "realtime:policies:own-write";
14
+ const CONFLICTS_KEY = "realtime:policies:conflicts";
9
15
  /**
10
16
  * Lit une métadonnée de classe posée par ces décorateurs : un objet nom → valeur,
11
17
  * ou `undefined` quand la classe n'en porte pas. Les valeurs restent `unknown` :
@@ -16,33 +22,138 @@ function readMetadataMap(key, ctor) {
16
22
  const raw = Reflect.getMetadata(key, ctor);
17
23
  return isRecord(raw) ? raw : void 0;
18
24
  }
25
+ /** Comme {@link readMetadataMap}, sans remonter la chaîne de prototypes : ce que la classe a posé ELLE-MÊME. */
26
+ function readOwnMetadataMap(key, ctor) {
27
+ const raw = Reflect.getOwnMetadata(key, ctor);
28
+ return isRecord(raw) ? raw : void 0;
29
+ }
19
30
  /** Vrai si la valeur est un tableau de chaînes. */
20
31
  function isStringArray(value) {
21
32
  return Array.isArray(value) && value.every((v) => typeof v === "string");
22
33
  }
23
- /** Vrai si la valeur a la forme d'une {@link IChannelPolicy}. */
34
+ /**
35
+ * Clés qu'une politique de canal peut porter — toute autre est refusée : une
36
+ * faute de frappe (`role`, `alow`) dans une règle de sécurité ne s'ignore pas.
37
+ */
38
+ const POLICY_KEYS = /* @__PURE__ */ new Set([
39
+ "authenticated",
40
+ "roles",
41
+ "scopes",
42
+ "allow",
43
+ "anonymous",
44
+ "presence"
45
+ ]);
46
+ /**
47
+ * `allow` désigne-t-il une classe d'admission (`@injectable`) plutôt qu'une flèche ?
48
+ *
49
+ * Tranché par la métadonnée `@injectable`, jamais par le texte de la fonction
50
+ * (`class …`), qu'un minifieur réécrit (ADR-0021 §S10 bis).
51
+ *
52
+ * @param allow - valeur du membre `allow` d'une politique déjà validée.
53
+ * @returns `true` pour une classe à résoudre par l'injecteur.
54
+ */
55
+ function isAdmissionClass(allow) {
56
+ return Injector.isInjectable(allow);
57
+ }
58
+ /**
59
+ * 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.
60
+ *
61
+ * @param value - valeur lue sous la métadonnée des politiques (`unknown` :
62
+ * `Reflect.defineMetadata` est public).
63
+ * @returns la raison du refus, ou `null`.
64
+ */
65
+ function channelPolicyDefect(value) {
66
+ if (!isRecord(value)) return "la politique n'est pas un objet";
67
+ for (const key of Object.keys(value)) if (!POLICY_KEYS.has(key)) return `clé inconnue « ${key} » (clés admises : ${[...POLICY_KEYS].join(", ")})`;
68
+ const { authenticated, roles, scopes, allow, anonymous, presence } = value;
69
+ const presenceIssue = presenceDefect(presence);
70
+ if (presenceIssue !== null) return presenceIssue;
71
+ if (authenticated !== void 0 && typeof authenticated !== "boolean") return "« authenticated » doit être un booléen";
72
+ if (roles !== void 0 && !isStringArray(roles)) return "« roles » doit être un tableau de chaînes";
73
+ if (scopes !== void 0 && !isStringArray(scopes)) return "« scopes » doit être un tableau de chaînes";
74
+ if (anonymous !== void 0 && typeof anonymous !== "boolean") return "« anonymous » doit être un booléen";
75
+ if (allow === void 0) return anonymous === void 0 ? null : "« anonymous » n'a de sens qu'avec « allow »";
76
+ if (typeof allow !== "function") return "« allow » doit être une fonction ou une classe @injectable";
77
+ if (anonymous === true && authenticated === true) return "« anonymous: true » contredit « authenticated: true »";
78
+ const proto = Reflect.get(allow, "prototype");
79
+ if (!Injector.isInjectable(allow) && isRecord(proto) && typeof proto.allow === "function") return `« allow » est une classe (${allow.name}) sans @injectable`;
80
+ return null;
81
+ }
82
+ /** Une valeur lue sous la métadonnée des politiques est-elle une politique recevable ? */
24
83
  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));
84
+ return channelPolicyDefect(value) === null;
28
85
  }
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;
86
+ const NO_NAMES = [];
87
+ /** Deux listes de rôles ou de scopes désignent-elles le même ENSEMBLE — ordre et doublons indifférents, absente = vide ? */
88
+ function sameNames(a, b) {
89
+ const x = a ?? NO_NAMES;
90
+ const y = b ?? NO_NAMES;
91
+ for (const name of x) if (!y.includes(name)) return false;
92
+ for (const name of y) if (!x.includes(name)) return false;
32
93
  return true;
33
94
  }
95
+ /**
96
+ * Deux politiques exigent-elles exactement la même chose ? Structurel : deux littéraux identiques écrits dans deux classes le sont ; `allow` se compare par référence.
97
+ *
98
+ * Sert à refuser deux déclarations CONTRADICTOIRES d'un même nom (une seule
99
+ * s'appliquerait) sans refuser deux déclarations ÉQUIVALENTES, et à ne juger
100
+ * l'écriture que si elle diffère de la lecture. Démarrage seulement.
101
+ *
102
+ * @param a - une politique recevable.
103
+ * @param b - une politique recevable.
104
+ * @returns `true` si aucun pair ne peut satisfaire l'une sans l'autre.
105
+ */
106
+ function samePolicy(a, b) {
107
+ if (a === b) return true;
108
+ return a.authenticated === true === (b.authenticated === true) && sameNames(a.roles, b.roles) && sameNames(a.scopes, b.scopes) && a.allow === b.allow && a.anonymous === true === (b.anonymous === true);
109
+ }
110
+ /**
111
+ * 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.
112
+ *
113
+ * Une politique illisible ne se laisse jamais tomber : sans elle, le canal
114
+ * s'ouvrirait à qui la politique fermait (fail-closed).
115
+ */
116
+ function assertPolicyMap(owner, map) {
117
+ for (const name in map) {
118
+ const defect = channelPolicyDefect(map[name]);
119
+ if (defect !== null) throw new BootConfigurationError(`${owner} : politique du canal temps réel « ${name} » refusée — ${defect}`);
120
+ }
121
+ }
34
122
  /** Méthode de l'instance que désigne une entrée de registre, ou `undefined`. */
35
123
  function methodOf(instance, prop) {
36
124
  return typeof prop === "string" || typeof prop === "symbol" ? Reflect.get(instance, prop) : void 0;
37
125
  }
126
+ /** La politique porte-t-elle au moins une clé propre non `undefined` ? */
127
+ function hasDefinedKey(policy) {
128
+ for (const key of Object.keys(policy)) if (Reflect.get(policy, key) !== void 0) return true;
129
+ return false;
130
+ }
38
131
  /** Libération d'un canal dont la fabrique décorée n'en a rendu aucune. */
39
132
  const NO_DISPOSE = () => {};
40
- /** Enregistre (idempotent) une politique non vide sous son nom de canal/méthode. */
41
- function definePolicy(ctor, name, policy) {
42
- if (!policy || policy.authenticated === void 0 && policy.roles === void 0 && policy.scopes === void 0) return;
43
- const map = { ...readMetadataMap(POLICIES_KEY, ctor) };
44
- map[name] = policy;
45
- Reflect.defineMetadata(POLICIES_KEY, map, ctor);
133
+ /**
134
+ * Déclare une politique non vide d'une sorte sous son nom — sans jamais écraser une déclaration contradictoire de la même classe : celle-ci est retenue pour refuser le démarrage.
135
+ */
136
+ function declarePolicy(ctor, name, kind, policy) {
137
+ if (!policy || !hasDefinedKey(policy)) return;
138
+ const key = kind === "read" ? POLICIES_KEY : WRITE_POLICIES_KEY;
139
+ const ownKey = kind === "read" ? OWN_READ_KEY : OWN_WRITE_KEY;
140
+ const own = readOwnMetadataMap(ownKey, ctor);
141
+ const current = readMetadataMap(key, ctor)?.[name];
142
+ if (own?.[name] === true && current !== void 0 && !(isChannelPolicy(current) && isChannelPolicy(policy) && samePolicy(current, policy) && samePresence(current, policy))) {
143
+ Reflect.defineMetadata(CONFLICTS_KEY, {
144
+ ...readOwnMetadataMap(CONFLICTS_KEY, ctor),
145
+ [name]: kind
146
+ }, ctor);
147
+ return;
148
+ }
149
+ Reflect.defineMetadata(key, {
150
+ ...readMetadataMap(key, ctor),
151
+ [name]: policy
152
+ }, ctor);
153
+ Reflect.defineMetadata(ownKey, {
154
+ ...own,
155
+ [name]: true
156
+ }, ctor);
46
157
  }
47
158
  /**
48
159
  * Politique appliquée à une action RPC dont l'auteur n'a rien déclaré :
@@ -51,11 +162,6 @@ function definePolicy(ctor, name, policy) {
51
162
  * Pour une action délibérément publique, l'écrire : `{ authenticated: false }`.
52
163
  */
53
164
  const DEFAULT_ACTION_POLICY = Object.freeze({ authenticated: true });
54
- /** Une politique est-elle DÉJÀ déclarée sous ce nom ? */
55
- function hasPolicy(ctor, name) {
56
- const map = readMetadataMap(POLICIES_KEY, ctor);
57
- return map ? Object.prototype.hasOwnProperty.call(map, name) : false;
58
- }
59
165
  /**
60
166
  * Décorateur **méthode** — expose une **action RPC** request→response du
61
167
  * controller realtime sous le nom `method` (ex. `"nodefony:kernel:ping"`, `"chat:send"`).
@@ -75,9 +181,12 @@ function hasPolicy(ctor, name) {
75
181
  * défaut, toute action applicative serait publique sans que rien ne le dise.
76
182
  * Pour une action délibérément ouverte, l'écrire : `{ authenticated: false }`.
77
183
  *
78
- * Si une politique est **déjà déclarée sous ce nom** (canal homonyme via
79
- * `@RealtimeChannel`/`@RealtimeInbound`), elle est conservée : le défaut ne
80
- * rétrograde jamais une politique existante, souvent plus stricte.
184
+ * Une politique d'action est une politique d'**écriture** : l'appel exige aussi
185
+ * la lecture déclarée sous le même nom. Sans politique, l'action **hérite** de la
186
+ * lecture d'un canal homonyme (`@RealtimeChannel`) si elle existe — le défaut ne
187
+ * rétrograde jamais une politique déclarée —, et sinon du défaut fermé, quel que
188
+ * soit l'ordre des méthodes. Deux politiques explicites différentes sur le même
189
+ * nom (action et `@RealtimeInbound`) refusent le démarrage.
81
190
  *
82
191
  * ⚠️ Le NOM peut primer sur ta politique : le plancher système de
83
192
  * `@nodefony/security` réserve les préfixes plateforme (`kernel:`, `syslog:`…)
@@ -107,21 +216,62 @@ function RealtimeAction(method, policy) {
107
216
  const map = { ...readMetadataMap(ACTIONS_KEY, ctor) };
108
217
  map[method] = propertyKey;
109
218
  Reflect.defineMetadata(ACTIONS_KEY, map, ctor);
110
- if (policy) definePolicy(ctor, method, policy);
111
- else if (!hasPolicy(ctor, method)) definePolicy(ctor, method, DEFAULT_ACTION_POLICY);
219
+ declarePolicy(ctor, method, "write", policy);
220
+ };
221
+ }
222
+ /**
223
+ * Décorateur de **classe** — pose les politiques d'accès d'actions ou de canaux
224
+ * par leur NOM, sans déclarer de handler.
225
+ *
226
+ * C'est la voie des actions servies par la table de `realtimeActions()` : cette
227
+ * table ne sait pas porter de politique, et un `@RealtimeAction` ajouté pour la
228
+ * seule politique ferait fabriquer, à chaque connexion, un handler aussitôt
229
+ * écrasé par la table (et contournerait l'injection de ses paramètres décorés).
230
+ * Ici, rien par connexion : les politiques sont écrites UNE fois, à la
231
+ * définition de la classe, dans le même registre que celles des décorateurs de
232
+ * méthode. Une action de la table sans politique garde le défaut FERMÉ
233
+ * (connexion authentifiée).
234
+ *
235
+ * Un nom qui porte `*` est un motif (famille de canaux), comme partout ailleurs.
236
+ *
237
+ * La politique vaut pour la LECTURE et l'ÉCRITURE du nom (le même objet, jugé
238
+ * une fois). Elle prime sur le défaut fermé d'une action ; contredire une
239
+ * politique explicite d'un décorateur de méthode refuse le démarrage.
240
+ *
241
+ * **Sur un contrôleur de contrat** (`extends ContractRealtimeController<C>`), le
242
+ * décorateur lit le contrat sur la classe qu'il décore : chaque nom doit être
243
+ * une méthode ou un canal de `C`, sinon la classe ne compile pas, et l'erreur
244
+ * nomme le fautif (`notInContract`). Le contrat ne se répète pas. Sur un
245
+ * contrôleur sans contrat, tout nom est accepté. Vérification à la compilation
246
+ * seulement : coût d'exécution nul.
247
+ *
248
+ * @param policies - nom d'action ou de canal → politique.
249
+ *
250
+ * @example
251
+ * @RealtimePolicy({ "orders:quote": { roles: ["ROLE_SALES"] } })
252
+ * class OrdersRt extends ContractRealtimeController<OrdersContract> { … }
253
+ */
254
+ function RealtimePolicy(policies) {
255
+ return function(target) {
256
+ for (const [name, policy] of Object.entries(policies)) {
257
+ declarePolicy(target, name, "read", policy);
258
+ declarePolicy(target, name, "write", policy);
259
+ }
112
260
  };
113
261
  }
114
262
  /**
115
- * Décorateur **méthode** — expose un **canal pub/sub** sous le nom `channel`
116
- * (match EXACT, sans préfixe `:<ms>` ni regex). La méthode décorée DOIT renvoyer
117
- * un `dispose: () => void` (la base le mémorise et l'appelle au dernier
263
+ * Décorateur **méthode** — expose un **canal pub/sub** sous le nom `channel`, ou
264
+ * une FAMILLE de canaux sous un motif (`chat:room:*`). La méthode décorée DOIT
265
+ * renvoyer un `dispose: () => void` (la base le mémorise et l'appelle au dernier
118
266
  * désabonné, comme avec l'override classique).
119
267
  *
120
- * v1 — match EXACT uniquement. Les canaux à pattern (`/^nodefony:orm:rich@(\d+)/`) ou à
121
- * suffixe de granularité (`nodefony:dashboard:1000`) restent dans l'override
122
- * `createRealtimeChannel()` (la base donne PRIORITÉ aux décorateurs, puis tombe
123
- * sur l'override en fallback — coexistence sans casse). Un mode `pattern: RegExp`
124
- * arrivera plus tard sans rompre cette API.
268
+ * Un motif (`*` = toute suite de caractères, la grammaire des politiques) sert
269
+ * chaque nom qu'il couvre : `@RealtimeChannel("chat:room:*", { allow })` déclare
270
+ * la règle ET la fabrique de tous les salons, et la méthode reçoit le nom réel
271
+ * (`chat:room:42`) en premier argument. Ordre de résolution au `subscribe` : le
272
+ * nom exact décoré, puis l'override `createRealtimeChannel()` (canaux à suffixe
273
+ * de granularité, regex — un contrôleur qui en a un garde son comportement),
274
+ * puis les motifs décorés, du plus spécifique au plus général.
125
275
  *
126
276
  * Le `this` est lié à l'instance ; le factory peut donc lire `this.kernel`, etc.
127
277
  * Mais ATTENTION : un provider est **partagé** entre toutes les connexions au
@@ -129,8 +279,20 @@ function RealtimeAction(method, policy) {
129
279
  * créé. Capturer des **valeurs long-lived** (broker, syslog) dans la closure,
130
280
  * jamais `this.context` (lié à la connexion créatrice qui peut fermer).
131
281
  *
282
+ * La `policy` est celle de **LECTURE** : exigée au `subscribe`, et en plus de
283
+ * l'écriture sur toute trame entrante du même nom. Absente, la lecture hérite de
284
+ * l'écriture (`@RealtimeInbound`) si elle est déclarée — une application qui ne
285
+ * garde que l'écriture reste protégée en lecture. Pour un canal que tout le
286
+ * monde lit et que seuls certains écrivent, l'écrire : `{ authenticated: false }`.
287
+ *
132
288
  * @example
133
289
  * class ChatController extends RealtimeController {
290
+ * // Tous les salons : la règle et la fabrique, une seule fois.
291
+ * @RealtimeChannel("chat:room:*", { allow: RoomAdmission })
292
+ * room(_channel: string): () => void {
293
+ * return () => {};
294
+ * }
295
+ *
134
296
  * @RealtimeChannel("chat:room42")
135
297
  * room42(_channel: string, publish: RealtimePublish): () => void {
136
298
  * const timer = setInterval(() => publish("chat:room42", { ts: Date.now() }), 1000);
@@ -144,7 +306,7 @@ function RealtimeChannel(channel, policy) {
144
306
  const map = { ...readMetadataMap(CHANNELS_KEY, ctor) };
145
307
  map[channel] = propertyKey;
146
308
  Reflect.defineMetadata(CHANNELS_KEY, map, ctor);
147
- definePolicy(ctor, channel, policy);
309
+ declarePolicy(ctor, channel, "read", policy);
148
310
  };
149
311
  }
150
312
  /**
@@ -159,6 +321,20 @@ function RealtimeChannel(channel, policy) {
159
321
  * pousser tant qu'aucun handler n'est enregistré ici). Cf seam P13.8a
160
322
  * (`beforeDispatch`) pour la gate sécurité par voter P6.
161
323
  *
324
+ * La `policy` est celle d'**ÉCRITURE** : une trame entrante exige la LECTURE du
325
+ * même nom (`@RealtimeChannel`) ET elle — l'écriture n'est jamais moins stricte
326
+ * que la lecture. Sans lecture déclarée, la lecture en hérite.
327
+ *
328
+ * @example
329
+ * class ChatController extends RealtimeController {
330
+ * @RealtimeChannel("chat:typing", { authenticated: false }) // tout le monde lit
331
+ * typing(_channel: string, _publish: RealtimePublish): () => void {
332
+ * return () => {};
333
+ * }
334
+ * @RealtimeInbound("chat:typing", { authenticated: true }) // seuls les connectés écrivent
335
+ * onTyping(): void {}
336
+ * }
337
+ *
162
338
  * @example
163
339
  * class ChatController extends RealtimeController {
164
340
  * @RealtimeInbound("chat:send")
@@ -175,7 +351,7 @@ function RealtimeInbound(method, policy) {
175
351
  const map = { ...readMetadataMap(INBOUND_KEY, ctor) };
176
352
  map[method] = propertyKey;
177
353
  Reflect.defineMetadata(INBOUND_KEY, map, ctor);
178
- definePolicy(ctor, method, policy);
354
+ declarePolicy(ctor, method, "write", policy);
179
355
  };
180
356
  }
181
357
  /**
@@ -216,6 +392,47 @@ function getRealtimeChannels(instance) {
216
392
  }
217
393
  return out;
218
394
  }
395
+ /**
396
+ * Motifs décorés de chaque classe, compilés au premier besoin : du plus
397
+ * spécifique au plus général. Par CLASSE et non par instance — un contrôleur
398
+ * temps réel est instancié par connexion, ses motifs ne changent pas.
399
+ */
400
+ const decoratedPatterns = /* @__PURE__ */ new WeakMap();
401
+ /**
402
+ * La fabrique décorée sous un MOTIF qui couvre `channel` — `@RealtimeChannel("chat:room:*")` sert `chat:room:42` —, ou `null` si aucun motif ne le couvre.
403
+ *
404
+ * Même grammaire que les politiques (`compileChannelPattern`) et même
405
+ * départage (`channelPatternWeight`, le plus spécifique d'abord) : un nom
406
+ * reçoit la fabrique du motif dont il reçoit la règle. Un motif refusé par la
407
+ * grammaire (trop d'étoiles) ne sert rien — le hub le dit déjà au démarrage.
408
+ * Cold path : appelé au premier abonné d'un canal qu'aucun nom exact ni
409
+ * `createRealtimeChannel` ne produit.
410
+ *
411
+ * @param instance - le contrôleur ; ses motifs se lisent sur sa classe.
412
+ * @param channels - le registre rendu par {@link getRealtimeChannels}.
413
+ * @param channel - le nom demandé.
414
+ */
415
+ function patternChannelFactory(instance, channels, channel) {
416
+ if (channels === null) return null;
417
+ let patterns = decoratedPatterns.get(instance.constructor);
418
+ if (patterns === void 0) {
419
+ const compiled = [];
420
+ for (const source of Object.keys(channels)) {
421
+ if (!source.includes("*")) continue;
422
+ const re = compileChannelPattern(source);
423
+ if (re !== null) compiled.push({
424
+ source,
425
+ re,
426
+ weight: channelPatternWeight(source)
427
+ });
428
+ }
429
+ compiled.sort((a, b) => b.weight - a.weight);
430
+ patterns = compiled;
431
+ decoratedPatterns.set(instance.constructor, patterns);
432
+ }
433
+ for (const pattern of patterns) if (pattern.re.test(channel)) return channels[pattern.source] ?? null;
434
+ return null;
435
+ }
219
436
  /** Lecture (lazy, au handshake) du registre des canaux inbound décorés. */
220
437
  function getRealtimeInbound(instance) {
221
438
  const map = readMetadataMap(INBOUND_KEY, instance.constructor);
@@ -228,17 +445,98 @@ function getRealtimeInbound(instance) {
228
445
  return out;
229
446
  }
230
447
  /**
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`.
448
+ * La classe déclare-t-elle une ACTION (`@RealtimeAction`) sous ce nom ? Sert à refuser un `allow` posé sur une action, que rien n'appliquerait.
449
+ *
450
+ * @param ctor - constructeur du contrôleur.
451
+ * @param name - nom d'action ou de canal.
452
+ */
453
+ function declaresAction(ctor, name) {
454
+ const map = readMetadataMap(ACTIONS_KEY, ctor);
455
+ return map !== void 0 && Object.prototype.hasOwnProperty.call(map, name);
456
+ }
457
+ /**
458
+ * Politiques déclarées sur une CLASSE de contrôleur, validées et résolues par sorte — lues au démarrage du noyau, jamais par trame.
459
+ *
460
+ * Résolution indépendante de l'ordre des méthodes :
461
+ * - l'écriture d'un nom est celle déclarée (`@RealtimeInbound`, `@RealtimeAction`
462
+ * avec politique, `@RealtimePolicy`) ; pour une action SANS politique, le
463
+ * défaut fermé, sauf si une lecture est déclarée — l'action en hérite alors ;
464
+ * - la lecture est celle déclarée (`@RealtimeChannel`, `@RealtimePolicy`),
465
+ * sinon l'écriture : une application qui ne garde que l'écriture reste
466
+ * protégée en lecture ;
467
+ * - `kinds` garde ce que la classe déclare, SANS héritage : le hub le fusionne
468
+ * avec les motifs de toutes les classes, sorte par sorte.
469
+ *
470
+ * @param ctor - constructeur du contrôleur.
471
+ * @returns les deux tables, ou `null` si la classe ne déclare aucune politique.
472
+ * @throws BootConfigurationError Une politique est illisible, ou deux
473
+ * déclarations d'une même sorte se contredisent : la classe, le canal et le
474
+ * défaut sont nommés.
475
+ */
476
+ function getDeclaredChannelPolicies(ctor) {
477
+ const readMap = readMetadataMap(POLICIES_KEY, ctor);
478
+ const writeMap = readMetadataMap(WRITE_POLICIES_KEY, ctor);
479
+ const actions = readMetadataMap(ACTIONS_KEY, ctor);
480
+ if (readMap === void 0 && writeMap === void 0 && actions === void 0) return null;
481
+ const owner = typeof ctor === "function" ? ctor.name : "(contrôleur)";
482
+ const conflicts = readMetadataMap(CONFLICTS_KEY, ctor);
483
+ if (conflicts !== void 0) for (const [name, kind] of Object.entries(conflicts)) throw new BootConfigurationError(`${owner} : le canal temps réel « ${name} » déclare deux politiques de ${kind === "read" ? "LECTURE" : "ÉCRITURE"} différentes — une seule s'appliquerait, l'autre disparaîtrait sans un mot. Garder une seule déclaration ; pour garder la lecture et l'écriture séparément : @RealtimeChannel(nom, lecture) et @RealtimeInbound(nom, écriture).`);
484
+ if (readMap !== void 0) assertPolicyMap(owner, readMap);
485
+ if (writeMap !== void 0) assertPolicyMap(owner, writeMap);
486
+ const read = {};
487
+ const kinds = {};
488
+ let any = false;
489
+ if (readMap !== void 0) for (const [name, policy] of Object.entries(readMap)) {
490
+ read[name] = policy;
491
+ kinds[name] = {
492
+ read: policy,
493
+ write: null
494
+ };
495
+ any = true;
496
+ }
497
+ if (writeMap !== void 0) for (const [name, policy] of Object.entries(writeMap)) {
498
+ kinds[name] = {
499
+ read: kinds[name]?.read ?? null,
500
+ write: policy
501
+ };
502
+ read[name] ??= policy;
503
+ any = true;
504
+ }
505
+ const actionNames = actions === void 0 ? [] : Object.keys(actions);
506
+ for (const name of actionNames) {
507
+ if (Object.hasOwn(read, name)) continue;
508
+ read[name] = DEFAULT_ACTION_POLICY;
509
+ any = true;
510
+ }
511
+ return any ? {
512
+ read,
513
+ kinds,
514
+ actions: actionNames
515
+ } : null;
516
+ }
517
+ /**
518
+ * Politiques de LECTURE déclarées sur une CLASSE de contrôleur (`@RealtimeChannel`/`@RealtimeInbound`/`@RealtimeAction`/`@RealtimePolicy`), validées — lues au démarrage du noyau, jamais par trame.
519
+ *
520
+ * La lecture d'un nom est celle exigée au `subscribe` ; une écriture distincte
521
+ * se lit par {@link getDeclaredChannelPolicies}.
522
+ *
523
+ * @param ctor - constructeur du contrôleur.
524
+ * @returns nom de canal ou de méthode → politique de lecture, ou `null` si la
525
+ * classe n'en déclare aucune.
526
+ * @throws BootConfigurationError Une politique est illisible ou contredite :
527
+ * la classe, le canal et le défaut sont nommés.
528
+ */
529
+ function getChannelPoliciesOf(ctor) {
530
+ return getDeclaredChannelPolicies(ctor)?.read ?? null;
531
+ }
532
+ /**
533
+ * Politiques déclarées sur la classe d'une instance de contrôleur — même lecture que {@link getChannelPoliciesOf}.
534
+ *
535
+ * @param instance - contrôleur instancié.
536
+ * @returns nom → politique, ou `null` si aucune.
236
537
  */
237
538
  function getRealtimeChannelPolicies(instance) {
238
- const map = readMetadataMap(POLICIES_KEY, instance.constructor);
239
- if (map === void 0) return null;
240
- if (!isPolicyMap(map)) throw new TypeError(`${instance.constructor.name} : politique de canal temps réel illisible`);
241
- return map;
539
+ return getChannelPoliciesOf(instance.constructor);
242
540
  }
243
541
  /**
244
542
  * Préfixes de canaux **broadcast** déclarés statiquement, tous controllers
@@ -287,4 +585,4 @@ function getDeclaredBroadcastPrefixes() {
287
585
  function applyDeclaredBroadcastPrefixes(hub) {
288
586
  for (const prefix of declaredBroadcastPrefixes) hub.markBroadcastChannel(prefix);
289
587
  }
290
- export { DEFAULT_ACTION_POLICY, RealtimeAction, RealtimeBroadcast, RealtimeChannel, RealtimeInbound, applyDeclaredBroadcastPrefixes, getDeclaredBroadcastPrefixes, getRealtimeActions, getRealtimeChannelPolicies, getRealtimeChannels, getRealtimeInbound };
588
+ export { DEFAULT_ACTION_POLICY, RealtimeAction, RealtimeBroadcast, RealtimeChannel, RealtimeInbound, RealtimePolicy, applyDeclaredBroadcastPrefixes, channelPolicyDefect, declaresAction, getChannelPoliciesOf, getDeclaredBroadcastPrefixes, getDeclaredChannelPolicies, getRealtimeActions, getRealtimeChannelPolicies, getRealtimeChannels, getRealtimeInbound, isAdmissionClass, patternChannelFactory, samePolicy };
@@ -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
  };