@nodefony/realtime 10.0.0-beta.4 → 10.0.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -10
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
- package/dist/index.js +79 -12
- package/dist/nodefony/config/config.js +7 -0
- package/dist/nodefony/decorators/realtimeDecorators.js +125 -19
- package/dist/nodefony/interfaces/IRealtimeConnection.js +1 -0
- package/dist/nodefony/src/backplane/ClusterBackplane.js +32 -4
- package/dist/nodefony/src/backplane/RedisBackplane.js +143 -31
- package/dist/nodefony/src/backplane/controlOrder.js +57 -0
- package/dist/nodefony/src/backplane/envelope.js +113 -12
- package/dist/nodefony/src/cluster/ClusterProbeClient.js +2 -0
- package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
- package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
- package/dist/nodefony/src/server/RealtimeConnection.js +110 -0
- package/dist/nodefony/src/server/RealtimeController.js +344 -85
- package/dist/nodefony/src/server/RealtimeHub.js +604 -61
- package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
- package/dist/nodefony/src/server/admissionBudget.js +176 -0
- package/dist/nodefony/src/server/channelPolicyRegistry.js +73 -0
- package/dist/nodefony/src/server/connectionAdmission.js +242 -0
- package/dist/nodefony/src/server/contractRoutes.js +85 -0
- package/dist/nodefony/src/server/deniedDetail.js +10 -3
- package/dist/nodefony/src/server/frameAdmission.js +168 -0
- package/dist/nodefony/src/server/subscribedAck.js +10 -1
- package/dist/nodefony/src/server/syslogUplink.js +3 -4
- package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
- package/dist/nodefony/src/service/RealtimeService.js +42 -1
- package/dist/nodefony/src/service/publicOrigin.js +40 -0
- package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
- package/dist/nodefony/testing/backplaneConformance.js +271 -0
- package/dist/nodefony/testing/index.js +21 -4
- package/dist/types/index.d.ts +34 -7
- package/dist/types/nodefony/config/config.d.ts +9 -0
- package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +86 -6
- package/dist/types/nodefony/interfaces/IBackplane.d.ts +34 -50
- package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
- package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
- package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
- package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
- package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
- package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
- package/dist/types/nodefony/interfaces/index.d.ts +2 -1
- package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
- package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +79 -16
- package/dist/types/nodefony/src/backplane/controlOrder.d.ts +52 -0
- package/dist/types/nodefony/src/backplane/envelope.d.ts +34 -0
- package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +10 -0
- package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
- package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +17 -0
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +93 -13
- package/dist/types/nodefony/src/server/RealtimeHub.d.ts +321 -24
- package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
- package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
- package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +34 -0
- package/dist/types/nodefony/src/server/connectionAdmission.d.ts +68 -0
- package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
- package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
- package/dist/types/nodefony/src/server/frameAdmission.d.ts +70 -0
- package/dist/types/nodefony/src/server/subscribedAck.d.ts +7 -0
- package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
- package/dist/types/nodefony/src/service/RealtimeService.d.ts +10 -2
- package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
- package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
- package/dist/types/nodefony/testing/backplaneConformance.d.ts +66 -0
- package/dist/types/nodefony/testing/index.d.ts +17 -4
- package/docs/actions.md +442 -19
- package/docs/architecture.md +83 -8
- package/docs/cadence-et-contre-pression.md +87 -11
- package/docs/canaux-a-membres.md +267 -0
- package/docs/cas-d-usage.md +1702 -0
- package/docs/configuration.md +137 -17
- package/docs/cookbook-chat.md +333 -85
- package/docs/index.md +11 -1
- package/docs/observabilite.md +71 -14
- package/docs/protocole.md +133 -24
- package/docs/securite.md +294 -33
- package/docs/vocabulaire.md +13 -12
- package/package.json +14 -11
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getRealtimeHub } from "./RealtimeHub.js";
|
|
2
|
+
import { isRealtimePayload, realtimePayloadError } from "nodefony";
|
|
2
3
|
// nodefony/src/server/ServerRealtimeSocket.ts
|
|
3
4
|
/**
|
|
4
5
|
* **L4 — façade serveur « la socket Nodefony ».** Un handle SERVEUR qui implémente
|
|
@@ -38,6 +39,7 @@ var ServerRealtimeSocket = class {
|
|
|
38
39
|
}
|
|
39
40
|
/** Émet sur un canal — fan-out aux abonnés via le hub (+ backplane si broadcast). */
|
|
40
41
|
publish(channel, payload) {
|
|
42
|
+
if (!isRealtimePayload(payload)) throw realtimePayloadError(channel, payload);
|
|
41
43
|
this.hub.publish(channel, payload);
|
|
42
44
|
}
|
|
43
45
|
/**
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// nodefony/src/server/admissionBudget.ts
|
|
2
|
+
/**
|
|
3
|
+
* Coupe-circuit des admissions (décision de l'auteur sur #622) : 50 délais
|
|
4
|
+
* dépassés en 5 s suspendent les appels d'`allow` pendant 5 s. Constantes
|
|
5
|
+
* internes : un exploitant règle le délai et les plafonds, pas la mécanique qui
|
|
6
|
+
* protège sa base quand elle ne répond plus.
|
|
7
|
+
*/
|
|
8
|
+
const BREAKER_THRESHOLD = 50;
|
|
9
|
+
const BREAKER_WINDOW_MS = 5e3;
|
|
10
|
+
const BREAKER_OPEN_MS = 5e3;
|
|
11
|
+
/** Un avertissement par cause et par fenêtre : sous une base bloquée, un journal par abonné serait un amplificateur. */
|
|
12
|
+
const WARN_INTERVAL_MS = 1e4;
|
|
13
|
+
/** Rendu par {@link AdmissionBudget.acquire} quand une place est réservée. */
|
|
14
|
+
const ADMISSION_GRANTED = -1;
|
|
15
|
+
/** Rendu par {@link AdmissionBudget.authenticateWithin} quand l'authentification n'a pas répondu à temps. */
|
|
16
|
+
const AUTHENTICATE_TIMED_OUT = Symbol("authenticate-timed-out");
|
|
17
|
+
/**
|
|
18
|
+
* Budget des admissions d'UN exemplaire — appels d'`allow` en vol, coupe-circuit et compteurs de refus (ADR-0021 §S10 bis point 3, §S9).
|
|
19
|
+
*
|
|
20
|
+
* Partagé par toutes les connexions du hub ; chaque connexion garde sa propre
|
|
21
|
+
* file ({@link ConnectionAdmission}). Aucune horloge n'est lue tant que le
|
|
22
|
+
* coupe-circuit est fermé : une règle synchrone ne paie que deux incréments.
|
|
23
|
+
*/
|
|
24
|
+
var AdmissionBudget = class {
|
|
25
|
+
/** Délai de l'authentification à la poignée de main (ADR-0021 §S11). */
|
|
26
|
+
authenticateTimeoutMs = 1e4;
|
|
27
|
+
/** Délai d'un appel d'`allow`, compté depuis la réception du `subscribe`, file comprise. */
|
|
28
|
+
timeoutMs = 5e3;
|
|
29
|
+
/** Appels d'`allow` en vol par connexion ; au-delà, la demande attend son tour. */
|
|
30
|
+
maxPerConnection = 8;
|
|
31
|
+
#maxTotal = 512;
|
|
32
|
+
#pending = 0;
|
|
33
|
+
#timedOutTotal = 0;
|
|
34
|
+
#saturatedTotal = 0;
|
|
35
|
+
#authenticateTimedOutTotal = 0;
|
|
36
|
+
#windowStart = -Infinity;
|
|
37
|
+
#windowTimeouts = 0;
|
|
38
|
+
#openUntil = 0;
|
|
39
|
+
#lastTimeoutWarn = -Infinity;
|
|
40
|
+
#lastThrowWarn = -Infinity;
|
|
41
|
+
#lastAuthenticateWarn = -Infinity;
|
|
42
|
+
/** Applique les bornes de la configuration (boot, cold path). */
|
|
43
|
+
configure(limits) {
|
|
44
|
+
this.authenticateTimeoutMs = limits.authenticateTimeoutMs;
|
|
45
|
+
this.timeoutMs = limits.allowTimeoutMs;
|
|
46
|
+
this.maxPerConnection = limits.maxPendingAllowsPerConnection;
|
|
47
|
+
this.#maxTotal = limits.maxPendingAllowsTotal;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Réserve une place pour UN appel d'`allow`.
|
|
51
|
+
*
|
|
52
|
+
* @returns {@link ADMISSION_GRANTED} si la place est prise ; sinon la demande
|
|
53
|
+
* est comptée dans `allowSaturatedTotal` et la valeur rendue est le délai à
|
|
54
|
+
* annoncer au client (`retryAfterMs`, coupe-circuit ouvert), ou `0` quand il
|
|
55
|
+
* n'est pas connu (plafond global atteint).
|
|
56
|
+
*/
|
|
57
|
+
acquire() {
|
|
58
|
+
if (this.#openUntil !== 0) {
|
|
59
|
+
const now = performance.now();
|
|
60
|
+
if (now < this.#openUntil) {
|
|
61
|
+
this.#saturatedTotal += 1;
|
|
62
|
+
return Math.ceil(this.#openUntil - now);
|
|
63
|
+
}
|
|
64
|
+
this.#openUntil = 0;
|
|
65
|
+
}
|
|
66
|
+
if (this.#pending >= this.#maxTotal) {
|
|
67
|
+
this.#saturatedTotal += 1;
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
this.#pending += 1;
|
|
71
|
+
return -1;
|
|
72
|
+
}
|
|
73
|
+
/** Rend la place d'un appel terminé, dépassé ou abandonné à la fermeture. */
|
|
74
|
+
release() {
|
|
75
|
+
if (this.#pending > 0) this.#pending -= 1;
|
|
76
|
+
}
|
|
77
|
+
/** Un appel d'`allow` n'a pas répondu dans son délai : compté, et porté au coupe-circuit. */
|
|
78
|
+
recordTimeout(now) {
|
|
79
|
+
this.#timedOutTotal += 1;
|
|
80
|
+
if (now - this.#windowStart >= BREAKER_WINDOW_MS) {
|
|
81
|
+
this.#windowStart = now;
|
|
82
|
+
this.#windowTimeouts = 0;
|
|
83
|
+
}
|
|
84
|
+
this.#windowTimeouts += 1;
|
|
85
|
+
if (this.#windowTimeouts >= BREAKER_THRESHOLD) {
|
|
86
|
+
this.#openUntil = now + BREAKER_OPEN_MS;
|
|
87
|
+
this.#windowTimeouts = 0;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Une demande a épuisé son délai dans la file, sans appel d'`allow`. */
|
|
91
|
+
recordQueueExpired() {
|
|
92
|
+
this.#saturatedTotal += 1;
|
|
93
|
+
}
|
|
94
|
+
/** Délai restant du coupe-circuit à `now`, ou `undefined` s'il est fermé. */
|
|
95
|
+
retryAfter(now) {
|
|
96
|
+
return this.#openUntil > now ? Math.ceil(this.#openUntil - now) : void 0;
|
|
97
|
+
}
|
|
98
|
+
/** Faut-il journaliser cette cause maintenant ? Vrai au plus une fois par fenêtre et par cause. */
|
|
99
|
+
shouldWarn(kind, now) {
|
|
100
|
+
if (kind === "authenticate") {
|
|
101
|
+
if (now - this.#lastAuthenticateWarn < WARN_INTERVAL_MS) return false;
|
|
102
|
+
this.#lastAuthenticateWarn = now;
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
if (kind === "timeout") {
|
|
106
|
+
if (now - this.#lastTimeoutWarn < WARN_INTERVAL_MS) return false;
|
|
107
|
+
this.#lastTimeoutWarn = now;
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
if (now - this.#lastThrowWarn < WARN_INTERVAL_MS) return false;
|
|
111
|
+
this.#lastThrowWarn = now;
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Borne l'authentification d'une poignée de main par `authenticateTimeoutMs` (ADR-0021 §S11).
|
|
116
|
+
*
|
|
117
|
+
* Une identité arrivée après le délai est IGNORÉE : la promesse rendue est
|
|
118
|
+
* déjà réglée sur {@link AUTHENTICATE_TIMED_OUT}, et un rejet tardif ne lève
|
|
119
|
+
* rien (la promesse d'origine garde son gestionnaire). Le minuteur est coupé
|
|
120
|
+
* dès la réponse et ne retient pas le processus à l'arrêt. Chaque dépassement
|
|
121
|
+
* compte une poignée de main dans `authenticateTimedOutTotal`.
|
|
122
|
+
*
|
|
123
|
+
* @param pending - la promesse rendue par `authenticate()`
|
|
124
|
+
* @returns le jeton, ou {@link AUTHENTICATE_TIMED_OUT} au dépassement
|
|
125
|
+
*/
|
|
126
|
+
authenticateWithin(pending) {
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
const timer = setTimeout(() => {
|
|
129
|
+
this.#authenticateTimedOutTotal++;
|
|
130
|
+
resolve(AUTHENTICATE_TIMED_OUT);
|
|
131
|
+
}, this.authenticateTimeoutMs);
|
|
132
|
+
timer.unref();
|
|
133
|
+
pending.then((value) => {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
resolve(value);
|
|
136
|
+
}, (error) => {
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
/** Appels d'`allow` en vol sur l'exemplaire. */
|
|
143
|
+
get pending() {
|
|
144
|
+
return this.#pending;
|
|
145
|
+
}
|
|
146
|
+
/** Cumul `allowTimedOutTotal` — unité : un appel d'`allow`. */
|
|
147
|
+
get timedOutTotal() {
|
|
148
|
+
return this.#timedOutTotal;
|
|
149
|
+
}
|
|
150
|
+
/** Cumul `allowSaturatedTotal` — unité : une demande d'abonnement. */
|
|
151
|
+
get saturatedTotal() {
|
|
152
|
+
return this.#saturatedTotal;
|
|
153
|
+
}
|
|
154
|
+
/** Cumul `authenticateTimedOutTotal` — unité : une poignée de main. */
|
|
155
|
+
get authenticateTimedOutTotal() {
|
|
156
|
+
return this.#authenticateTimedOutTotal;
|
|
157
|
+
}
|
|
158
|
+
/** Remet compteurs, coupe-circuit et bornes à leurs défauts (hub vidé). */
|
|
159
|
+
reset() {
|
|
160
|
+
this.authenticateTimeoutMs = 1e4;
|
|
161
|
+
this.#authenticateTimedOutTotal = 0;
|
|
162
|
+
this.#lastAuthenticateWarn = -Infinity;
|
|
163
|
+
this.timeoutMs = 5e3;
|
|
164
|
+
this.maxPerConnection = 8;
|
|
165
|
+
this.#maxTotal = 512;
|
|
166
|
+
this.#pending = 0;
|
|
167
|
+
this.#timedOutTotal = 0;
|
|
168
|
+
this.#saturatedTotal = 0;
|
|
169
|
+
this.#windowStart = -Infinity;
|
|
170
|
+
this.#windowTimeouts = 0;
|
|
171
|
+
this.#openUntil = 0;
|
|
172
|
+
this.#lastTimeoutWarn = -Infinity;
|
|
173
|
+
this.#lastThrowWarn = -Infinity;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
export { ADMISSION_GRANTED, AUTHENTICATE_TIMED_OUT, AdmissionBudget };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { declaresAction, getChannelPoliciesOf, isAdmissionClass } from "../../decorators/realtimeDecorators.js";
|
|
2
|
+
import { BootConfigurationError, Injector } from "nodefony";
|
|
3
|
+
// nodefony/src/server/channelPolicyRegistry.ts
|
|
4
|
+
/**
|
|
5
|
+
* Inscrit au hub les politiques déclarées par une classe de contrôleur — une fois par classe et par hub, et seulement si l'inscription entière réussit.
|
|
6
|
+
*
|
|
7
|
+
* Appelée pour chaque contrôleur connu au démarrage du noyau, puis à la
|
|
8
|
+
* poignée de main pour une classe qui y aurait échappé : celle-ci ne paie alors
|
|
9
|
+
* qu'un `has`. Une politique refusée ne marque PAS la classe — elle est refusée
|
|
10
|
+
* de nouveau à la poignée de main suivante plutôt que de laisser le canal
|
|
11
|
+
* ouvert.
|
|
12
|
+
*
|
|
13
|
+
* @param hub - le hub qui porte le registre.
|
|
14
|
+
* @param ctor - constructeur du contrôleur.
|
|
15
|
+
* @param resolveAdmission - résolution des classes d'admission ; `null` hors
|
|
16
|
+
* démarrage, et le hub refuse alors une politique qui en désigne une.
|
|
17
|
+
* @throws BootConfigurationError Politique illisible, `allow` posé sur une
|
|
18
|
+
* action, ou classe d'admission non résolue.
|
|
19
|
+
*/
|
|
20
|
+
function registerControllerPolicies(hub, ctor, resolveAdmission) {
|
|
21
|
+
if (hub.hasPolicyOwner(ctor)) return;
|
|
22
|
+
const policies = getChannelPoliciesOf(ctor);
|
|
23
|
+
if (policies !== null) for (const name in policies) {
|
|
24
|
+
const policy = policies[name];
|
|
25
|
+
if (policy === void 0) continue;
|
|
26
|
+
const allow = policy.allow;
|
|
27
|
+
let admission;
|
|
28
|
+
if (allow !== void 0) {
|
|
29
|
+
if (declaresAction(ctor, name)) throw new BootConfigurationError(`${typeof ctor === "function" ? ctor.name : "(contrôleur)"} : « allow » est posé sur l'action « ${name} » — il ne s'applique qu'aux canaux (demandé au subscribe). Une action se garde par authenticated, roles ou scopes.`);
|
|
30
|
+
if (isAdmissionClass(allow) && resolveAdmission !== null) admission = resolveAdmission(allow, name);
|
|
31
|
+
}
|
|
32
|
+
hub.registerChannelPolicy(name, policy, admission);
|
|
33
|
+
}
|
|
34
|
+
hub.addPolicyOwner(ctor);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Fabrique la résolution des classes d'admission du démarrage : portée singleton exigée, instance du conteneur réutilisée, sinon construite UNE fois par l'injecteur.
|
|
38
|
+
*
|
|
39
|
+
* @param get - l'instance déjà construite d'une classe : le démarrage la lit
|
|
40
|
+
* dans le conteneur du noyau par sa clé (`null` tant que la classe n'y a
|
|
41
|
+
* jamais été rangée), le harnais de test parmi les instances que le test lui
|
|
42
|
+
* donne. `undefined` → construite par l'injecteur.
|
|
43
|
+
* @param log - journal du module (une ligne par canal résolu).
|
|
44
|
+
* @returns le résolveur, qui mémorise l'instance de chaque classe.
|
|
45
|
+
*/
|
|
46
|
+
function createAdmissionResolver(get, log) {
|
|
47
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
48
|
+
return (admissionClass, channel) => {
|
|
49
|
+
let admission = resolved.get(admissionClass);
|
|
50
|
+
if (admission === void 0) {
|
|
51
|
+
admission = resolveAdmissionInstance(admissionClass, channel, get);
|
|
52
|
+
resolved.set(admissionClass, admission);
|
|
53
|
+
}
|
|
54
|
+
log(`canal ${channel}, admission ${admissionClass.name}, résolue`);
|
|
55
|
+
return admission;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Résout l'instance d'une classe d'admission ou refuse le démarrage en nommant la classe ET le canal. */
|
|
59
|
+
function resolveAdmissionInstance(admissionClass, channel, get) {
|
|
60
|
+
const where = `canal temps réel « ${channel} » : la classe d'admission ${admissionClass.name}`;
|
|
61
|
+
if (!Injector.isInjectable(admissionClass)) throw new BootConfigurationError(`${where} ne porte pas @injectable`);
|
|
62
|
+
const scope = Injector.scopeOf(admissionClass);
|
|
63
|
+
if (scope !== "singleton") throw new BootConfigurationError(`${where} est de portée « ${scope} » : une seule instance sert tous les abonnés en même temps, elle doit être un singleton (et ne garder aucun état par abonné).`);
|
|
64
|
+
let instance = get(Injector.containerKeyOf(admissionClass), admissionClass);
|
|
65
|
+
if (!(instance instanceof admissionClass)) try {
|
|
66
|
+
instance = Injector.instantiate(admissionClass);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
throw new BootConfigurationError(`${where} ne se résout pas — ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
69
|
+
}
|
|
70
|
+
if (!(instance instanceof admissionClass) || typeof instance.allow !== "function") throw new BootConfigurationError(`${where} n'a pas de méthode allow`);
|
|
71
|
+
return instance;
|
|
72
|
+
}
|
|
73
|
+
export { createAdmissionResolver, registerControllerPolicies };
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import "./admissionBudget.js";
|
|
2
|
+
import resolveBackplaneOriginId from "../backplane/originId.js";
|
|
3
|
+
import { isPromise } from "nodefony";
|
|
4
|
+
// nodefony/src/server/connectionAdmission.ts
|
|
5
|
+
const QUEUED = 0;
|
|
6
|
+
const IN_FLIGHT = 1;
|
|
7
|
+
const DONE = 2;
|
|
8
|
+
/**
|
|
9
|
+
* Admission des abonnements d'UNE connexion aux canaux qui déclarent `allow` — appels bornés, file d'attente, délai, verdict (ADR-0021 §S10 et §S10 bis).
|
|
10
|
+
*
|
|
11
|
+
* Coûts, par construction :
|
|
12
|
+
* - une règle qui répond `true`/`false` sans promesse ne crée ni minuteur, ni
|
|
13
|
+
* promesse, ni entrée d'attente ;
|
|
14
|
+
* - au-delà de `maxPendingAllowsPerConnection` appels en vol, la demande ATTEND
|
|
15
|
+
* son tour (jamais `limit` : le rejeu au `welcome` renvoie tous les canaux
|
|
16
|
+
* d'un coup) ; la file reste `null` tant que personne n'attend ;
|
|
17
|
+
* - un seul minuteur par connexion, armé sur l'échéance de tête : les demandes
|
|
18
|
+
* arrivent dans l'ordre de leurs échéances, la plus ancienne non tranchée est
|
|
19
|
+
* donc toujours la prochaine à expirer.
|
|
20
|
+
*
|
|
21
|
+
* Seul `=== true` admet ; une exception ou un rejet valent `forbidden` ; un
|
|
22
|
+
* délai dépassé ou une saturation valent `unavailable`, et un verdict arrivé
|
|
23
|
+
* après coup est ignoré.
|
|
24
|
+
*/
|
|
25
|
+
var ConnectionAdmission = class {
|
|
26
|
+
#host;
|
|
27
|
+
#budget;
|
|
28
|
+
#who;
|
|
29
|
+
#explain;
|
|
30
|
+
#pending = null;
|
|
31
|
+
#inFlight = 0;
|
|
32
|
+
#timer = null;
|
|
33
|
+
#onTimer = null;
|
|
34
|
+
/**
|
|
35
|
+
* @param host - la connexion qui ouvre ou refuse.
|
|
36
|
+
* @param budget - le budget de l'exemplaire (plafond global, coupe-circuit).
|
|
37
|
+
* @param who - identité d'admission gelée de la connexion.
|
|
38
|
+
* @param explain - rédiger le détail des refus (hors production).
|
|
39
|
+
*/
|
|
40
|
+
constructor(host, budget, who, explain) {
|
|
41
|
+
this.#host = host;
|
|
42
|
+
this.#budget = budget;
|
|
43
|
+
this.#who = who;
|
|
44
|
+
this.#explain = explain;
|
|
45
|
+
}
|
|
46
|
+
/** Demandes en attente de verdict — comptées dans le plafond de canaux de la connexion. */
|
|
47
|
+
get unsettled() {
|
|
48
|
+
return this.#pending === null ? 0 : this.#pending.size;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Un abonnement à ce canal attend-il déjà son verdict ? S'il avait été annulé par un `unsubscribe`, il est ranimé : le verdict à venir répondra.
|
|
52
|
+
*
|
|
53
|
+
* @returns `true` si la demande est déjà en attente (rien d'autre à faire).
|
|
54
|
+
*/
|
|
55
|
+
revive(channel) {
|
|
56
|
+
const record = this.#pending?.get(channel);
|
|
57
|
+
if (record === void 0) return false;
|
|
58
|
+
record.cancelled = false;
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
/** `unsubscribe` pendant l'attente : le verdict à venir sera jeté sans réponse. */
|
|
62
|
+
cancel(channel) {
|
|
63
|
+
const record = this.#pending?.get(channel);
|
|
64
|
+
if (record !== void 0) record.cancelled = true;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Demande l'admission sur `channel` selon sa règle — l'anonyme est refusé d'office sauf `anonymous: true`.
|
|
68
|
+
*
|
|
69
|
+
* @param channel - nom exact du canal demandé.
|
|
70
|
+
* @param rule - règle résolue par le hub pour ce canal.
|
|
71
|
+
*/
|
|
72
|
+
request(channel, rule) {
|
|
73
|
+
if (!this.#who.authenticated && !rule.anonymous) {
|
|
74
|
+
this.#host.deny(channel, "forbidden", this.#explain ? `le canal « ${channel} » déclare une règle d'admission (allow) : une connexion anonyme n'y entre pas sans \`anonymous: true\` dans sa politique` : void 0);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (this.#inFlight >= this.#budget.maxPerConnection) {
|
|
78
|
+
this.#track(channel, rule, QUEUED);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
this.#call(channel, rule, null);
|
|
82
|
+
}
|
|
83
|
+
/** Fermeture de la connexion : minuteur coupé, places rendues, verdicts à venir jetés. */
|
|
84
|
+
close() {
|
|
85
|
+
if (this.#timer !== null) {
|
|
86
|
+
clearTimeout(this.#timer);
|
|
87
|
+
this.#timer = null;
|
|
88
|
+
}
|
|
89
|
+
const pending = this.#pending;
|
|
90
|
+
if (pending !== null) {
|
|
91
|
+
for (const record of pending.values()) {
|
|
92
|
+
if (record.status === IN_FLIGHT) this.#budget.release();
|
|
93
|
+
record.status = DONE;
|
|
94
|
+
}
|
|
95
|
+
this.#pending = null;
|
|
96
|
+
}
|
|
97
|
+
this.#inFlight = 0;
|
|
98
|
+
}
|
|
99
|
+
/** Appelle `allow` ; `record` est `null` pour une demande qui n'a pas attendu. */
|
|
100
|
+
#call(channel, rule, record) {
|
|
101
|
+
const granted = this.#budget.acquire();
|
|
102
|
+
if (granted !== -1) {
|
|
103
|
+
if (record !== null) this.#finish(record);
|
|
104
|
+
this.#host.deny(channel, "unavailable", !this.#explain ? void 0 : granted > 0 ? `admissions suspendues pendant ${granted} ms après une série de règles allow sans réponse sur ce serveur — réessaie après ce délai` : "trop d'admissions en cours sur ce serveur (maxPendingAllowsTotal) — réessaie", granted > 0 ? granted : void 0);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
let verdict;
|
|
108
|
+
try {
|
|
109
|
+
verdict = rule.allow(channel, this.#who);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
this.#budget.release();
|
|
112
|
+
if (record !== null) this.#finish(record);
|
|
113
|
+
this.#refuseOnError(channel, error);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (!isPromise(verdict)) {
|
|
117
|
+
this.#budget.release();
|
|
118
|
+
if (record !== null) this.#finish(record);
|
|
119
|
+
this.#conclude(channel, verdict);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const tracked = record ?? this.#track(channel, rule, IN_FLIGHT);
|
|
123
|
+
tracked.status = IN_FLIGHT;
|
|
124
|
+
this.#inFlight += 1;
|
|
125
|
+
verdict.then((value) => {
|
|
126
|
+
if (this.#settle(tracked)) this.#conclude(tracked.channel, value);
|
|
127
|
+
this.#pump();
|
|
128
|
+
}, (error) => {
|
|
129
|
+
if (this.#settle(tracked)) this.#refuseOnError(tracked.channel, error);
|
|
130
|
+
this.#pump();
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
/** Verdict d'une règle : seul `true` admet. */
|
|
134
|
+
#conclude(channel, verdict) {
|
|
135
|
+
if (verdict === true) {
|
|
136
|
+
this.#open(channel);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
this.#host.deny(channel, "forbidden", !this.#explain ? void 0 : verdict === false ? `la règle d'admission (allow) du canal « ${channel} » a refusé cette identité` : `la règle d'admission (allow) du canal « ${channel} » a rendu une valeur de type ${typeof verdict} : seul \`true\` admet`);
|
|
140
|
+
}
|
|
141
|
+
/** Une règle qui lève ou rejette refuse (fail-closed) ; le message ne part au client qu'hors production. */
|
|
142
|
+
#refuseOnError(channel, error) {
|
|
143
|
+
const warn = this.#budget.shouldWarn("throw", performance.now());
|
|
144
|
+
const message = warn || this.#explain ? error instanceof Error ? error.message : String(error) : "";
|
|
145
|
+
if (warn) this.#host.warn(`règle d'admission (allow) du canal « ${channel} » en échec : ${message}`);
|
|
146
|
+
this.#host.deny(channel, "forbidden", this.#explain ? `la règle d'admission (allow) du canal « ${channel} » a levé : ${message}` : void 0);
|
|
147
|
+
}
|
|
148
|
+
/** Ouvre le canal admis ; un échec d'ouverture est journalisé, jamais propagé (rappel de promesse ou de minuteur). */
|
|
149
|
+
#open(channel) {
|
|
150
|
+
try {
|
|
151
|
+
this.#host.open(channel);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
this.#host.warn(`canal « ${channel} » admis mais non ouvert : ` + (error instanceof Error ? error.message : String(error)));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Termine un appel en vol dont le verdict arrive.
|
|
158
|
+
*
|
|
159
|
+
* @returns `false` si la demande est déjà tranchée (délai dépassé, connexion
|
|
160
|
+
* fermée) ou annulée : le verdict est alors jeté.
|
|
161
|
+
*/
|
|
162
|
+
#settle(record) {
|
|
163
|
+
if (record.status === DONE) return false;
|
|
164
|
+
this.#inFlight -= 1;
|
|
165
|
+
this.#budget.release();
|
|
166
|
+
this.#finish(record);
|
|
167
|
+
return !record.cancelled;
|
|
168
|
+
}
|
|
169
|
+
/** Inscrit une demande qui attend ; arme le minuteur s'il ne l'est pas déjà sur une échéance plus proche. */
|
|
170
|
+
#track(channel, rule, status) {
|
|
171
|
+
const deadline = performance.now() + this.#budget.timeoutMs;
|
|
172
|
+
const record = {
|
|
173
|
+
channel,
|
|
174
|
+
rule,
|
|
175
|
+
deadline,
|
|
176
|
+
status,
|
|
177
|
+
cancelled: false
|
|
178
|
+
};
|
|
179
|
+
(this.#pending ??= /* @__PURE__ */ new Map()).set(channel, record);
|
|
180
|
+
if (this.#timer === null) this.#arm(deadline);
|
|
181
|
+
return record;
|
|
182
|
+
}
|
|
183
|
+
/** Marque une demande tranchée et la retire ; plus rien en attente → minuteur coupé. */
|
|
184
|
+
#finish(record) {
|
|
185
|
+
record.status = DONE;
|
|
186
|
+
const pending = this.#pending;
|
|
187
|
+
if (pending === null) return;
|
|
188
|
+
pending.delete(record.channel);
|
|
189
|
+
if (pending.size === 0 && this.#timer !== null) {
|
|
190
|
+
clearTimeout(this.#timer);
|
|
191
|
+
this.#timer = null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/** Lance les demandes en attente, dans l'ordre, tant que des places se libèrent. */
|
|
195
|
+
#pump() {
|
|
196
|
+
while (this.unsettled > this.#inFlight && this.#inFlight < this.#budget.maxPerConnection) {
|
|
197
|
+
const record = this.#nextQueued();
|
|
198
|
+
if (record === null) return;
|
|
199
|
+
if (record.cancelled) {
|
|
200
|
+
this.#finish(record);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
this.#call(record.channel, record.rule, record);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/** Première demande qui n'a pas encore appelé `allow` — les appels en vol, au plus `maxPerConnection`, la précèdent. */
|
|
207
|
+
#nextQueued() {
|
|
208
|
+
if (this.#pending === null) return null;
|
|
209
|
+
for (const record of this.#pending.values()) if (record.status === QUEUED) return record;
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
#arm(deadline) {
|
|
213
|
+
this.#timer = setTimeout(this.#onTimer ??= () => this.#expire(), Math.max(1, Math.ceil(deadline - performance.now())));
|
|
214
|
+
this.#timer.unref();
|
|
215
|
+
}
|
|
216
|
+
/** Échéance de tête atteinte : tranche en `unavailable` toutes les demandes échues, puis réarme sur la suivante. */
|
|
217
|
+
#expire() {
|
|
218
|
+
this.#timer = null;
|
|
219
|
+
const pending = this.#pending;
|
|
220
|
+
if (pending === null) return;
|
|
221
|
+
const now = performance.now();
|
|
222
|
+
let timedOut = null;
|
|
223
|
+
for (const record of pending.values()) {
|
|
224
|
+
if (record.deadline > now) {
|
|
225
|
+
this.#arm(record.deadline);
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
const called = record.status === IN_FLIGHT;
|
|
229
|
+
if (called) {
|
|
230
|
+
this.#inFlight -= 1;
|
|
231
|
+
this.#budget.release();
|
|
232
|
+
this.#budget.recordTimeout(now);
|
|
233
|
+
timedOut = record.channel;
|
|
234
|
+
} else this.#budget.recordQueueExpired();
|
|
235
|
+
this.#finish(record);
|
|
236
|
+
if (!record.cancelled) this.#host.deny(record.channel, "unavailable", !this.#explain ? void 0 : called ? `la règle d'admission (allow) du canal « ${record.channel} » n'a pas répondu en ${this.#budget.timeoutMs} ms (allowTimeoutMs) — réessaie` : `demande d'abonnement à « ${record.channel} » restée en attente ${this.#budget.timeoutMs} ms derrière les admissions de cette connexion (allowTimeoutMs) — réessaie`, this.#budget.retryAfter(now));
|
|
237
|
+
}
|
|
238
|
+
if (timedOut !== null && this.#budget.shouldWarn("timeout", now)) this.#host.warn(`règle d'admission (allow) du canal « ${timedOut} » sans réponse en ${this.#budget.timeoutMs} ms (exemplaire ${resolveBackplaneOriginId()})`);
|
|
239
|
+
this.#pump();
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
export { ConnectionAdmission };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { isRecord } from "../guards.js";
|
|
2
|
+
import { BootConfigurationError, isSimpleRouteTemplate } from "nodefony";
|
|
3
|
+
// nodefony/src/server/contractRoutes.ts
|
|
4
|
+
const BRIDGE_VERBS = /* @__PURE__ */ new Set([
|
|
5
|
+
"GET",
|
|
6
|
+
"POST",
|
|
7
|
+
"PUT",
|
|
8
|
+
"PATCH",
|
|
9
|
+
"DELETE"
|
|
10
|
+
]);
|
|
11
|
+
const ROUTE_PARTS = [
|
|
12
|
+
"body",
|
|
13
|
+
"query",
|
|
14
|
+
"params"
|
|
15
|
+
];
|
|
16
|
+
/** Chemin comparable : barres finales retirées, la racine reste `/`. */
|
|
17
|
+
function normalizePath(path) {
|
|
18
|
+
let end = path.length;
|
|
19
|
+
while (end > 1 && path.charCodeAt(end - 1) === 47) end--;
|
|
20
|
+
return end === path.length ? path : path.slice(0, end);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* La route sert-elle CE verbe par le pont ? Le pont résout sur le transport
|
|
24
|
+
* `WEBSOCKET` ; une écriture exige EN PLUS sa méthode logique
|
|
25
|
+
* (`Router.resolve`, `methodOverride`).
|
|
26
|
+
*/
|
|
27
|
+
function servesByBridge(methods, verb) {
|
|
28
|
+
if (methods === void 0) return true;
|
|
29
|
+
if (!methods.has("WEBSOCKET")) return false;
|
|
30
|
+
return verb === "GET" || methods.has(verb);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Lit la table `serverRoutes` qu'une classe de contrôleur déclare en statique —
|
|
34
|
+
* `null` quand elle n'en déclare pas (aucun contrôle).
|
|
35
|
+
*
|
|
36
|
+
* @param ctor - la classe du contrôleur.
|
|
37
|
+
* @returns la table, ou `null`.
|
|
38
|
+
* @throws BootConfigurationError si la table n'est pas un objet.
|
|
39
|
+
*/
|
|
40
|
+
function readContractRoutes(ctor) {
|
|
41
|
+
const table = Reflect.get(ctor, "serverRoutes");
|
|
42
|
+
if (table === void 0) return null;
|
|
43
|
+
if (!isRecord(table)) throw new BootConfigurationError(`${typeof ctor === "function" ? ctor.name : "(contrôleur)"} : « serverRoutes » doit être un objet { "<VERBE> <chemin>": { body?, query? } }.`);
|
|
44
|
+
return table;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Vérifie, au démarrage, que chaque route du contrat est SERVIE par le pont :
|
|
48
|
+
* une route montée au même chemin, qui accepte le verbe par la socket, et dont
|
|
49
|
+
* les schémas sont LES MÊMES OBJETS que ceux que la table déclare. Coût nul par
|
|
50
|
+
* requête : la vérification n'a lieu qu'une fois.
|
|
51
|
+
*
|
|
52
|
+
* Sans elle, un contrat qui type `chat.post("/chat/send", …)` compilerait
|
|
53
|
+
* contre une route absente, servie en HTTP seulement, ou validant un autre
|
|
54
|
+
* schéma — et l'erreur n'apparaîtrait qu'au premier appel.
|
|
55
|
+
*
|
|
56
|
+
* @param owner - nom de la classe qui déclare la table (pour le message).
|
|
57
|
+
* @param table - la table `serverRoutes` (`"<VERBE> <chemin>"` → `{ body?, query? }`).
|
|
58
|
+
* @param mounted - les routes montées.
|
|
59
|
+
* @throws BootConfigurationError à la première entrée qui n'est pas servie ;
|
|
60
|
+
* le message nomme la classe, l'entrée et la cause.
|
|
61
|
+
*/
|
|
62
|
+
function assertContractRoutes(owner, table, mounted) {
|
|
63
|
+
for (const entry in table) {
|
|
64
|
+
const where = `${owner}.serverRoutes « ${entry} »`;
|
|
65
|
+
const space = entry.indexOf(" ");
|
|
66
|
+
const verb = space === -1 ? "" : entry.slice(0, space);
|
|
67
|
+
const rawPath = space === -1 ? "" : entry.slice(space + 1);
|
|
68
|
+
if (!BRIDGE_VERBS.has(verb) || rawPath.charCodeAt(0) !== 47) throw new BootConfigurationError(`${where} : la clé s'écrit "<VERBE> <chemin>", VERBE parmi ${[...BRIDGE_VERBS].join(", ")}, chemin commençant par « / ».`);
|
|
69
|
+
const declared = table[entry];
|
|
70
|
+
if (!isRecord(declared)) throw new BootConfigurationError(`${where} : la valeur s'écrit { body?, query?, params? } (les schémas de la route).`);
|
|
71
|
+
for (const key in declared) if (key !== "body" && key !== "query" && key !== "params") throw new BootConfigurationError(`${where} : clé « ${key} » inconnue — seuls body, query et params se déclarent.`);
|
|
72
|
+
if (!isSimpleRouteTemplate(rawPath)) throw new BootConfigurationError(`${where} : seuls des segments « {nom} » entiers se déclarent dans un contrat — pas de contrainte en ligne « {id}(\\d+) », de segment facultatif « {slug}? » ni de variable collée « .{format} ». Contraindre la variable par « requirements » sur la route.`);
|
|
73
|
+
const path = normalizePath(rawPath);
|
|
74
|
+
const atPath = mounted.filter((r) => normalizePath(r.path) === path);
|
|
75
|
+
if (atPath.length === 0) throw new BootConfigurationError(`${where} : aucune route montée au chemin ${path}.`);
|
|
76
|
+
const route = atPath.find((r) => servesByBridge(r.methods, verb));
|
|
77
|
+
if (route === void 0) throw new BootConfigurationError(`${where} : la route ${path} ne sert pas ${verb} par la socket — ses méthodes doivent inclure ${verb === "GET" ? "" : `"${verb}" et `}"WEBSOCKET".`);
|
|
78
|
+
for (const part of ROUTE_PARTS) {
|
|
79
|
+
const expected = declared[part];
|
|
80
|
+
const actual = route.schema?.[part];
|
|
81
|
+
if (expected !== actual) throw new BootConfigurationError(`${where} : le schéma « ${part} » ${expected === void 0 ? "n'est pas déclaré, mais la route en valide un" : actual === void 0 ? "est déclaré, mais la route n'en valide aucun" : "n'est pas celui que la route valide (même objet attendu)"}.`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export { assertContractRoutes, readContractRoutes };
|
|
@@ -22,7 +22,14 @@
|
|
|
22
22
|
* sinon un objet vide.
|
|
23
23
|
*/
|
|
24
24
|
function deniedDetail(env, detail) {
|
|
25
|
-
|
|
26
|
-
return { detail };
|
|
25
|
+
return explainsDenials(env) ? { detail } : {};
|
|
27
26
|
}
|
|
28
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Le détail d'un refus part-il au client dans ce mode ? Hors production seulement — la règle de {@link deniedDetail}, lue AVANT de rédiger la phrase : en production, aucune chaîne n'est composée pour être jetée.
|
|
29
|
+
*
|
|
30
|
+
* @param env - le mode du kernel serveur, s'il est connu ; une absence vaut production.
|
|
31
|
+
*/
|
|
32
|
+
function explainsDenials(env) {
|
|
33
|
+
return !!env && env !== "production";
|
|
34
|
+
}
|
|
35
|
+
export { deniedDetail, explainsDenials };
|