@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.
- package/README.md +55 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
- package/dist/index.js +91 -12
- package/dist/nodefony/config/config.js +7 -0
- package/dist/nodefony/decorators/realtimeDecorators.js +341 -43
- package/dist/nodefony/interfaces/IRealtimeConnection.js +1 -0
- package/dist/nodefony/src/backplane/ClusterBackplane.js +32 -4
- package/dist/nodefony/src/backplane/RedisBackplane.js +146 -41
- package/dist/nodefony/src/backplane/controlOrder.js +86 -0
- package/dist/nodefony/src/backplane/envelope.js +505 -27
- package/dist/nodefony/src/backplane/ingress.js +93 -0
- package/dist/nodefony/src/backplane/presenceMessage.js +205 -0
- package/dist/nodefony/src/backplane/publishQueue.js +97 -3
- 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 +135 -0
- package/dist/nodefony/src/server/RealtimeController.js +409 -107
- package/dist/nodefony/src/server/RealtimeHub.js +1158 -111
- 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 +95 -0
- package/dist/nodefony/src/server/channelPolicyResolution.js +191 -0
- package/dist/nodefony/src/server/channelPresence.js +488 -0
- package/dist/nodefony/src/server/connectionAdmission.js +242 -0
- package/dist/nodefony/src/server/contractRoutes.js +75 -0
- package/dist/nodefony/src/server/deniedDetail.js +10 -3
- package/dist/nodefony/src/server/frameAdmission.js +160 -0
- package/dist/nodefony/src/server/presenceBus.js +486 -0
- package/dist/nodefony/src/server/realtimeShutdown.js +31 -0
- package/dist/nodefony/src/server/remotePresence.js +541 -0
- package/dist/nodefony/src/server/subscribedAck.js +13 -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 +51 -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 +347 -0
- package/dist/nodefony/testing/index.js +43 -12
- package/dist/nodefony/testing/presenceClient.js +54 -0
- package/dist/types/index.d.ts +39 -7
- package/dist/types/nodefony/config/config.d.ts +9 -0
- package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +198 -17
- package/dist/types/nodefony/interfaces/IBackplane.d.ts +46 -51
- 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 +89 -18
- package/dist/types/nodefony/src/backplane/controlOrder.d.ts +75 -0
- package/dist/types/nodefony/src/backplane/envelope.d.ts +77 -0
- package/dist/types/nodefony/src/backplane/ingress.d.ts +48 -0
- package/dist/types/nodefony/src/backplane/presenceMessage.d.ts +150 -0
- package/dist/types/nodefony/src/backplane/publishQueue.d.ts +25 -1
- 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 +20 -0
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +110 -12
- package/dist/types/nodefony/src/server/RealtimeHub.d.ts +448 -42
- 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 +41 -0
- package/dist/types/nodefony/src/server/channelPolicyResolution.d.ts +112 -0
- package/dist/types/nodefony/src/server/channelPresence.d.ts +155 -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 +66 -0
- package/dist/types/nodefony/src/server/presenceBus.d.ts +112 -0
- package/dist/types/nodefony/src/server/realtimeShutdown.d.ts +21 -0
- package/dist/types/nodefony/src/server/remotePresence.d.ts +151 -0
- package/dist/types/nodefony/src/server/subscribedAck.d.ts +10 -0
- package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
- package/dist/types/nodefony/src/service/RealtimeService.d.ts +17 -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 +78 -0
- package/dist/types/nodefony/testing/index.d.ts +13 -2
- package/dist/types/nodefony/testing/presenceClient.d.ts +20 -0
- package/docs/actions.md +420 -18
- package/docs/architecture.md +101 -11
- package/docs/cadence-et-contre-pression.md +87 -11
- package/docs/canaux-a-membres.md +277 -0
- package/docs/cas-d-usage.md +1703 -0
- package/docs/configuration.md +175 -19
- package/docs/cookbook-chat.md +701 -224
- package/docs/index.md +11 -1
- package/docs/observabilite.md +97 -21
- package/docs/protocole.md +142 -33
- package/docs/securite.md +347 -41
- package/docs/vocabulaire.md +13 -12
- package/package.json +16 -13
|
@@ -1,14 +1,20 @@
|
|
|
1
|
-
import { isRecord } from "../guards.js";
|
|
2
|
-
import { ANONYMOUS_REALTIME_TOKEN } from "./AnonymousRealtimeToken.js";
|
|
3
|
-
import { getRealtimeHub } from "./RealtimeHub.js";
|
|
4
|
-
import { DEFAULT_ACTION_POLICY, getRealtimeActions, getRealtimeChannelPolicies, getRealtimeChannels, getRealtimeInbound } from "../../decorators/realtimeDecorators.js";
|
|
5
1
|
import { createSyslogUplinkHandler } from "./syslogUplink.js";
|
|
2
|
+
import { AUTHENTICATE_TIMED_OUT } from "./admissionBudget.js";
|
|
6
3
|
import { WsConnectionTransport } from "../transport/WsConnectionTransport.js";
|
|
4
|
+
import { getRealtimeHub } from "./RealtimeHub.js";
|
|
5
|
+
import { ANONYMOUS_REALTIME_TOKEN } from "./AnonymousRealtimeToken.js";
|
|
6
|
+
import { getRealtimeActions, getRealtimeChannels, getRealtimeInbound, patternChannelFactory } from "../../decorators/realtimeDecorators.js";
|
|
7
7
|
import { welcomeEnv } from "./welcomeEnv.js";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
8
|
+
import { registerControllerPolicies } from "./channelPolicyRegistry.js";
|
|
9
|
+
import { welcomeIdentityOf } from "./welcomeIdentity.js";
|
|
10
|
+
import { isRecord } from "../guards.js";
|
|
11
|
+
import { imposedSubscribedAckFrame, subscribedAckFrame } from "./subscribedAck.js";
|
|
12
|
+
import { RealtimeConnection } from "./RealtimeConnection.js";
|
|
13
|
+
import { deniedDetail, explainsDenials } from "./deniedDetail.js";
|
|
14
|
+
import { ConnectionAdmission } from "./connectionAdmission.js";
|
|
15
|
+
import { gateFrame, releaseRuleAdmission, revokeAdmission } from "./frameAdmission.js";
|
|
16
|
+
import { BootConfigurationError, IDENTITY_DEAD_CLOSE_CODE, JsonRpcError, JsonRpcPeer, JsonRpcServerError, ORIGIN_REFUSED_CLOSE_CODE, PLATFORM_INBOUND, RequestContext, RpcEnvelope, RpcError, Scope, TRY_AGAIN_LATER_CLOSE_CODE, TransportState, identityHint, isPromise, negotiateFrameCodec, stripTrailingSlashes } from "nodefony";
|
|
17
|
+
import { isWebsocketContext, parseRequestTarget, readBackpressureOptions, toValidationFields } from "@nodefony/http";
|
|
12
18
|
import { Controller, buildContextParamArgs, getParamArgsMeta, isFrameworkRouter } from "@nodefony/framework";
|
|
13
19
|
// nodefony/src/server/RealtimeController.ts
|
|
14
20
|
/**
|
|
@@ -17,6 +23,12 @@ import { Controller, buildContextParamArgs, getParamArgsMeta, isFrameworkRouter
|
|
|
17
23
|
*/
|
|
18
24
|
const NO_ROUTE_PARAMS = Object.freeze({});
|
|
19
25
|
/**
|
|
26
|
+
* Préfixe de la ligne `ERROR` écrite quand la poignée de main lève (socket fermée
|
|
27
|
+
* en 1011). Le harnais de test la relit pour citer l'exception : le client, lui,
|
|
28
|
+
* ne reçoit que « internal error » — rien d'interne ne sort par la socket.
|
|
29
|
+
*/
|
|
30
|
+
const HANDSHAKE_FAILED_LOG = "WS realtime handshake failed";
|
|
31
|
+
/**
|
|
20
32
|
* Statut HTTP-équivalent d'une erreur survenue pendant une invocation du pont.
|
|
21
33
|
*
|
|
22
34
|
* UNE seule lecture pour les trois sources possibles — la `RpcError` déjà mappée
|
|
@@ -38,6 +50,24 @@ function httpStatusOfFrameError(e) {
|
|
|
38
50
|
return typeof code === "number" && code >= 400 && code <= 599 ? code : null;
|
|
39
51
|
}
|
|
40
52
|
/**
|
|
53
|
+
* L'attente d'un `Retry-After` (RFC 9110 §10.2.3), en secondes : un délai
|
|
54
|
+
* (`120`) tel quel, une date HTTP ramenée à l'écart avec `now` (jamais
|
|
55
|
+
* négatif). Une valeur illisible, ou absente, ne dit rien : `null`.
|
|
56
|
+
*
|
|
57
|
+
* @param value - l'en-tête posé par l'action, s'il l'a été
|
|
58
|
+
* @param now - l'instant de référence d'une date HTTP, en ms (défaut : l'horloge,
|
|
59
|
+
* lue seulement pour une date)
|
|
60
|
+
* @returns les secondes à attendre, ou `null`
|
|
61
|
+
*/
|
|
62
|
+
function retryAfterSeconds(value, now) {
|
|
63
|
+
if (value === void 0) return null;
|
|
64
|
+
const text = value.trim();
|
|
65
|
+
if (/^\d+$/u.test(text)) return Number(text);
|
|
66
|
+
if (!/[A-Za-z]/u.test(text)) return null;
|
|
67
|
+
const at = Date.parse(text);
|
|
68
|
+
return Number.isNaN(at) ? null : Math.max(0, Math.ceil((at - (now ?? Date.now())) / 1e3));
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
41
71
|
* Erreur renvoyée au pair pour une invocation en échec.
|
|
42
72
|
*
|
|
43
73
|
* Un refus est une RÉPONSE (statut exposé, symétrie d'un `fetch`) ; une panne
|
|
@@ -45,18 +75,18 @@ function httpStatusOfFrameError(e) {
|
|
|
45
75
|
* du profil est joint aux refus : le client peut alors radiographier son propre
|
|
46
76
|
* refus (le 403 `@IsGranted` devient pédagogique au lieu d'être un mur).
|
|
47
77
|
*/
|
|
48
|
-
function toFrameRpcError(e, status, requestId) {
|
|
78
|
+
function toFrameRpcError(e, status, requestId, retryAfter = null) {
|
|
49
79
|
if (e instanceof RpcError) {
|
|
50
|
-
if (!requestId) return e;
|
|
51
|
-
const data = isRecord(e.data) ? {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
} : { requestId };
|
|
80
|
+
if (!requestId && retryAfter === null) return e;
|
|
81
|
+
const data = isRecord(e.data) ? { ...e.data } : {};
|
|
82
|
+
if (requestId) data.requestId = requestId;
|
|
83
|
+
if (retryAfter !== null) data.retryAfter = retryAfter;
|
|
55
84
|
return new RpcError(e.message, e.code, data);
|
|
56
85
|
}
|
|
57
86
|
if (status !== null) {
|
|
58
87
|
const data = { status };
|
|
59
88
|
if (requestId) data.requestId = requestId;
|
|
89
|
+
if (retryAfter !== null) data.retryAfter = retryAfter;
|
|
60
90
|
if (status === 422 && e instanceof Error) {
|
|
61
91
|
const fields = toValidationFields(e);
|
|
62
92
|
if (fields !== null) data.fields = fields;
|
|
@@ -79,6 +109,9 @@ function webSocketServerOf(ctx) {
|
|
|
79
109
|
return server && "options" in server ? server : null;
|
|
80
110
|
}
|
|
81
111
|
let warnedUnenforcedPolicies = false;
|
|
112
|
+
/** Connexions déjà prévenues du flux brut refusé sous codec binaire — alloué au premier refus. */
|
|
113
|
+
let rawBinaryNoticed = null;
|
|
114
|
+
let warnedNoIdentity = false;
|
|
82
115
|
/**
|
|
83
116
|
* RealtimeController — base d'un endpoint WebSocket temps réel SERVEUR (JSON-RPC 2.0).
|
|
84
117
|
*
|
|
@@ -124,6 +157,11 @@ var RealtimeController = class extends Controller {
|
|
|
124
157
|
if (this.scope === "singleton") throw new BootConfigurationError(`Contrôleur temps réel « ${this.name} » déclaré @Scope("singleton") : un RealtimeController vit PAR CONNEXION — son pair JSON-RPC et notifyClient/requestClient désignent la connexion par l'instance. Retirer @Scope("singleton") : la portée d'un RealtimeController est request (une instance par connexion).`);
|
|
125
158
|
}
|
|
126
159
|
/**
|
|
160
|
+
* Le codec négocié au handshake (ADR-0022 §2), `null` hors WebSocket. Choisi par
|
|
161
|
+
* le constructeur : c'est la dernière fenêtre avant le `101`.
|
|
162
|
+
*/
|
|
163
|
+
#codec;
|
|
164
|
+
/**
|
|
127
165
|
* @param name - nom du service (celui de la classe, par convention).
|
|
128
166
|
* @param context - le contexte de la connexion.
|
|
129
167
|
* @throws BootConfigurationError si la classe se déclare singleton.
|
|
@@ -132,6 +170,28 @@ var RealtimeController = class extends Controller {
|
|
|
132
170
|
new.target.assertScope();
|
|
133
171
|
super(name, context);
|
|
134
172
|
this.#connection = isWebsocketContext(context) ? context : null;
|
|
173
|
+
this.#codec = this.#connection !== null ? this.#negotiateCodec(this.#connection) : null;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Choisit le codec de la connexion d'après l'offre du handshake (ADR-0022 §2)
|
|
177
|
+
* et le confie à l'admission, qui le renverra dans le `101` (RFC 6455 §4.2.2 :
|
|
178
|
+
* le sous-protocole CHOISI, jamais le premier offert). Le contrôleur WS est
|
|
179
|
+
* construit AVANT l'acceptation (`HttpKernel.handleWebsocket`) : c'est la
|
|
180
|
+
* dernière fenêtre où ce choix atteint encore le client. Le client, lui, lit
|
|
181
|
+
* le codec dans `realtime:welcome`, jamais dans cet écho.
|
|
182
|
+
*
|
|
183
|
+
* Rien n'est retenu pour un client qui n'offre aucun sous-protocole — le cas
|
|
184
|
+
* de toute connexion d'avant les codecs. Un `forward` reconstruit une
|
|
185
|
+
* instance après l'acceptation : son choix, identique, est alors ignoré.
|
|
186
|
+
*
|
|
187
|
+
* @param ctx - le contexte de la connexion.
|
|
188
|
+
* @returns le codec à annoncer dans l'accueil, puis à employer.
|
|
189
|
+
*/
|
|
190
|
+
#negotiateCodec(ctx) {
|
|
191
|
+
const offered = offeredSubprotocols(ctx.request?.headers["sec-websocket-protocol"]);
|
|
192
|
+
const codec = negotiateFrameCodec(getRealtimeHub().frameCodecs, offered);
|
|
193
|
+
if (offered.length > 0) ctx.admission?.selectSubprotocol(offered.includes(codec.protocol) ? codec.protocol : false);
|
|
194
|
+
return codec;
|
|
135
195
|
}
|
|
136
196
|
/**
|
|
137
197
|
* La connexion WebSocket que sert CETTE instance — rétrécie UNE fois, ici,
|
|
@@ -188,6 +248,9 @@ var RealtimeController = class extends Controller {
|
|
|
188
248
|
* surcharger ; défaut : **aucun** (sûr — un client ne peut rien pousser au serveur
|
|
189
249
|
* tant qu'un canal n'est pas explicitement déclaré ici). Seam des backings entrants
|
|
190
250
|
* (SIP, bridge). Cf {@link RealtimeInboundHandler} (params NON FIABLES).
|
|
251
|
+
*
|
|
252
|
+
* Typée par `Listen` (ce que le client émet, 3ᵉ générique) : un canal hors de
|
|
253
|
+
* la carte ne compile pas, et chaque handler reçoit la forme de SON canal.
|
|
191
254
|
*/
|
|
192
255
|
realtimeInbound() {
|
|
193
256
|
return {};
|
|
@@ -196,7 +259,7 @@ var RealtimeController = class extends Controller {
|
|
|
196
259
|
* Opt-in du **pont API** (une action, deux transports) : `true` → la
|
|
197
260
|
* connexion expose la méthode RPC `api.request {path}` qui re-route le path
|
|
198
261
|
* porté par le message vers l'action controller correspondante — la MÊME
|
|
199
|
-
* action que le GET REST (`socket.
|
|
262
|
+
* action que le GET REST (`socket.get("/nodefony/kernel/api/modules")`
|
|
200
263
|
* côté client). Le pont n'atteint QUE les routes déclarant le transport
|
|
201
264
|
* `WEBSOCKET` dans leurs `methods` (zéro bypass : une action dit à quels
|
|
202
265
|
* transports elle répond). À surcharger ; défaut : `false` — aucune surface
|
|
@@ -229,7 +292,7 @@ var RealtimeController = class extends Controller {
|
|
|
229
292
|
if (ctx === null) throw this.outsideWebsocketError();
|
|
230
293
|
if (message == null) {
|
|
231
294
|
this.onHandshake(ctx).catch((e) => {
|
|
232
|
-
this.log(
|
|
295
|
+
this.log(`${HANDSHAKE_FAILED_LOG}: ${e instanceof Error ? e.message : String(e)}`, "ERROR");
|
|
233
296
|
ctx.connection?.close(1011, "internal error");
|
|
234
297
|
});
|
|
235
298
|
return;
|
|
@@ -239,7 +302,12 @@ var RealtimeController = class extends Controller {
|
|
|
239
302
|
state?.transport.feed(message);
|
|
240
303
|
return;
|
|
241
304
|
}
|
|
242
|
-
if (state
|
|
305
|
+
if (state === void 0) return;
|
|
306
|
+
if (state.binaryCodec !== null) {
|
|
307
|
+
state.transport.feed(message);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
this.receiveBinary(state.transport, message);
|
|
243
311
|
}
|
|
244
312
|
/**
|
|
245
313
|
* Faute de câblage : `handleRealtime` reçu par une instance qui ne sert pas
|
|
@@ -292,11 +360,32 @@ var RealtimeController = class extends Controller {
|
|
|
292
360
|
* client. Même contre-pression que les frames JSON-RPC.
|
|
293
361
|
*
|
|
294
362
|
* @param data - les octets à émettre (`Uint8Array`, donc un `Buffer` aussi).
|
|
295
|
-
*
|
|
296
|
-
*
|
|
363
|
+
* Sur une connexion qui a négocié un codec BINAIRE, refusé (ADR-0022 §2) : le
|
|
364
|
+
* client y lirait ces octets comme une frame du codec. Des octets passent
|
|
365
|
+
* alors par un canal, avec ses portes.
|
|
366
|
+
*
|
|
367
|
+
* @returns `false` si le handshake n'est pas terminé, la connexion fermée, la
|
|
368
|
+
* trame jetée par la contre-pression, ou la connexion en codec binaire.
|
|
297
369
|
*/
|
|
298
370
|
sendBinaryToClient(data) {
|
|
299
|
-
|
|
371
|
+
const state = this.coldConnState();
|
|
372
|
+
if (state === void 0) return false;
|
|
373
|
+
if (state.binaryCodec !== null) {
|
|
374
|
+
this.noticeRawBinaryUnderCodec(state);
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
return state.transport.sendBinary(data);
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Journalise, une fois par connexion, le flux brut refusé sous codec binaire.
|
|
381
|
+
*
|
|
382
|
+
* @param state - l'état de la connexion.
|
|
383
|
+
*/
|
|
384
|
+
noticeRawBinaryUnderCodec(state) {
|
|
385
|
+
rawBinaryNoticed ??= /* @__PURE__ */ new WeakSet();
|
|
386
|
+
if (rawBinaryNoticed.has(state)) return;
|
|
387
|
+
rawBinaryNoticed.add(state);
|
|
388
|
+
this.log(`WS realtime : sendBinaryToClient refusé — la connexion parle ${state.binaryCodec?.protocol ?? "un codec binaire"}, ses trames binaires sont des frames du codec ; publier les octets sur un canal`, "WARNING");
|
|
300
389
|
}
|
|
301
390
|
/**
|
|
302
391
|
* État realtime de CETTE connexion, `undefined` avant le welcome — pour les
|
|
@@ -320,6 +409,8 @@ var RealtimeController = class extends Controller {
|
|
|
320
409
|
* Échecs (close WebSocket, codes plage applicative RFC 6455 §7.4.2) :
|
|
321
410
|
* - Origin refusée → code 4003 (`forbidden`).
|
|
322
411
|
* - `authenticate` throw → code 4001 (`unauthorized`).
|
|
412
|
+
* - `authenticate` sans réponse dans `authenticateTimeoutMs` → code 1013
|
|
413
|
+
* (`try again later`, ADR-0021 §S11) : le client recule puis revient.
|
|
323
414
|
*/
|
|
324
415
|
async onHandshake(ctx) {
|
|
325
416
|
if (ctx.__nfRealtime?.welcomed) return;
|
|
@@ -329,13 +420,19 @@ var RealtimeController = class extends Controller {
|
|
|
329
420
|
const handshake = buildHandshakeFromContext(ctx);
|
|
330
421
|
if (!hub.checkOrigin(handshake.origin)) {
|
|
331
422
|
this.log(`WS realtime upgrade refused: Origin "${handshake.origin ?? "(missing)"}" not allowed`, "WARNING");
|
|
332
|
-
conn.close(
|
|
423
|
+
conn.close(ORIGIN_REFUSED_CLOSE_CODE, "origin not allowed");
|
|
333
424
|
return;
|
|
334
425
|
}
|
|
335
426
|
const authenticator = hub.resolveAuthenticator(handshake);
|
|
336
427
|
let token;
|
|
337
428
|
if (authenticator?.supports(handshake)) try {
|
|
338
|
-
|
|
429
|
+
const outcome = await hub.admissionBudget.authenticateWithin(authenticator.authenticate(handshake));
|
|
430
|
+
if (outcome === AUTHENTICATE_TIMED_OUT) {
|
|
431
|
+
if (hub.admissionBudget.shouldWarn("authenticate", performance.now())) this.log(`WS realtime auth (${authenticator.name}) sans réponse en ${hub.admissionBudget.authenticateTimeoutMs} ms (authenticateTimeoutMs) — connexion fermée en 1013, le client reviendra`, "WARNING");
|
|
432
|
+
conn.close(TRY_AGAIN_LATER_CLOSE_CODE, "try again later");
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
token = outcome;
|
|
339
436
|
authenticator.onSuccess?.(handshake, token);
|
|
340
437
|
} catch (e) {
|
|
341
438
|
const err = e instanceof Error ? e : new Error(String(e));
|
|
@@ -343,41 +440,44 @@ var RealtimeController = class extends Controller {
|
|
|
343
440
|
authenticator.onFailure?.(handshake, err);
|
|
344
441
|
} catch {}
|
|
345
442
|
this.log(`WS realtime auth failed (${authenticator.name}): ${err.message}`, "WARNING");
|
|
346
|
-
conn.close(
|
|
443
|
+
conn.close(IDENTITY_DEAD_CLOSE_CODE, "unauthorized");
|
|
347
444
|
return;
|
|
348
445
|
}
|
|
349
446
|
else token = ANONYMOUS_REALTIME_TOKEN;
|
|
447
|
+
if (conn.readyState !== TransportState.OPEN) return;
|
|
350
448
|
const transport = new WsConnectionTransport(conn, readBackpressureOptions(webSocketServerOf(ctx)));
|
|
449
|
+
const codecs = hub.frameCodecs;
|
|
450
|
+
const negotiated = this.#codec ?? codecs.text;
|
|
451
|
+
const binaryCodec = negotiated === codecs.binary ? negotiated : null;
|
|
452
|
+
const outbound = ctx.outboundTracer();
|
|
351
453
|
const peerOptions = {
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
} catch (e) {
|
|
357
|
-
this.log(`WS realtime send: payload non sérialisable — ${e instanceof Error ? e.message : String(e)}`, "ERROR");
|
|
358
|
-
const id = isRecord(frame) ? frame.id : void 0;
|
|
359
|
-
if (!isJsonRpcId(id)) return;
|
|
360
|
-
json = JSON.stringify(jsonRpcFailure(id, JsonRpcError.INTERNAL_ERROR, "internal error: non-serializable payload"));
|
|
361
|
-
}
|
|
362
|
-
transport.send(json);
|
|
454
|
+
codec: codecs.text,
|
|
455
|
+
write: outbound === null ? (data) => transport.send(data) : (data, frame) => {
|
|
456
|
+
transport.send(data);
|
|
457
|
+
if (carriesId(frame)) outbound(data);
|
|
363
458
|
},
|
|
364
459
|
onNotification: (method, params) => this.onRealtimeNotification(ctx, method, params),
|
|
365
460
|
onError: (context, err) => this.log(`${context}: ${err instanceof Error ? err.message : String(err)}`, "ERROR")
|
|
366
461
|
};
|
|
367
|
-
peerOptions.beforeDispatch = (frame) =>
|
|
462
|
+
peerOptions.beforeDispatch = (frame) => {
|
|
463
|
+
if (state.admitted?.[frame.method] === true) return true;
|
|
464
|
+
return gateFrame(state, frame, hub);
|
|
465
|
+
};
|
|
368
466
|
peerOptions.onFrameAudit = (reason, frame, auditedPeer) => {
|
|
369
467
|
if (reason !== "denied") return;
|
|
370
|
-
|
|
468
|
+
const explained = hub.explainDenial(frame, state.peer);
|
|
469
|
+
this.log(explained === null ? "WS realtime frame refused by authorizer" : `WS realtime frame refused by authorizer: ${explained}`, "WARNING");
|
|
371
470
|
if (!isRecord(frame)) return;
|
|
372
471
|
const f = frame;
|
|
373
472
|
if (f.id !== void 0) return;
|
|
374
473
|
const channel = f.method === "subscribe" ? channelParam(f.params) : typeof f.method === "string" ? f.method : void 0;
|
|
375
474
|
if (channel !== void 0) {
|
|
376
|
-
const
|
|
475
|
+
const unadmitted = f.method === channel && state.inbound !== null && Object.hasOwn(state.inbound, channel) && hub.resolveChannelAdmission(channel) !== null;
|
|
476
|
+
const hint = unadmitted || explained !== null ? null : identityHint(this.kernel);
|
|
377
477
|
const denied = {
|
|
378
478
|
channel,
|
|
379
479
|
reason: "forbidden",
|
|
380
|
-
...deniedDetail(this.kernel?.environment, `le verrou de frame a refusé l'accès à « ${channel} » pour cette identité : vérifie les rôles ou scopes exigés par le canal (décorateur \`@RealtimeChannel(nom, { roles })\` ou clé \`realtimeChannels\` de la configuration de sécurité), et ceux que porte le jeton — le \`realtime:welcome\` te les rend` + (hint === null ? "" : `. ${hint}`))
|
|
480
|
+
...deniedDetail(this.kernel?.environment, explained ?? (unadmitted ? `« ${channel} » n'accepte d'entrée que des connexions que sa règle \`allow\` a admises : abonne-toi d'abord au canal (\`subscribe\`), puis pousse une fois l'abonnement acquitté` : `le verrou de frame a refusé l'accès à « ${channel} » pour cette identité : vérifie les rôles ou scopes exigés par le canal (décorateur \`@RealtimeChannel(nom, { roles })\` ou clé \`realtimeChannels\` de la configuration de sécurité), et ceux que porte le jeton — le \`realtime:welcome\` te les rend` + (hint === null ? "" : `. ${hint}`)))
|
|
381
481
|
};
|
|
382
482
|
auditedPeer.notify("realtime:denied", denied);
|
|
383
483
|
}
|
|
@@ -387,9 +487,9 @@ var RealtimeController = class extends Controller {
|
|
|
387
487
|
transport.onMessage((raw) => {
|
|
388
488
|
let parsed;
|
|
389
489
|
try {
|
|
390
|
-
parsed =
|
|
490
|
+
parsed = peer.decode(raw);
|
|
391
491
|
} catch {
|
|
392
|
-
if (transport.discard("malformed") === 1) this.log("WS realtime : trame texte illisible (pas du JSON) reçue et écartée"
|
|
492
|
+
if (transport.discard("malformed") === 1) this.log(typeof raw === "string" ? "WS realtime : trame texte illisible (pas du JSON) reçue et écartée" : `WS realtime : trame binaire illisible pour ${peer.codec.protocol} reçue et écartée`, "WARNING");
|
|
393
493
|
return;
|
|
394
494
|
}
|
|
395
495
|
peer.receive(parsed);
|
|
@@ -400,32 +500,21 @@ var RealtimeController = class extends Controller {
|
|
|
400
500
|
};
|
|
401
501
|
for (const [name, handler] of Object.entries(allActions)) {
|
|
402
502
|
peer.register(name, handler);
|
|
403
|
-
|
|
503
|
+
hub.declareActionDefault(name);
|
|
404
504
|
}
|
|
405
|
-
if (this.realtimeApiRequest()) peer.register("api.request", (params) => this.invokeApiRequest(ctx, params,
|
|
505
|
+
if (this.realtimeApiRequest()) peer.register("api.request", (params) => this.invokeApiRequest(ctx, params, token));
|
|
406
506
|
this._decoratedChannels = getRealtimeChannels(this);
|
|
407
|
-
|
|
408
|
-
if (
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
warnedUnenforcedPolicies = true;
|
|
412
|
-
this.log("Realtime channel policies declared but NO frame authorizer is wired — these policies are NOT enforced (a protected channel is currently open). Load @nodefony/security with a realtime zone to enforce them.", "WARNING");
|
|
413
|
-
}
|
|
507
|
+
registerControllerPolicies(hub, this.constructor, null);
|
|
508
|
+
if (!warnedUnenforcedPolicies && hub.hasUnenforcedChannelPolicies()) {
|
|
509
|
+
warnedUnenforcedPolicies = true;
|
|
510
|
+
this.log("Realtime channel policies declared but NO frame authorizer is wired — these policies are NOT enforced (a protected channel is currently open). Load @nodefony/security with a realtime zone to enforce them.", "WARNING");
|
|
414
511
|
}
|
|
415
512
|
const noticeSyslog = this.syslog;
|
|
416
513
|
const noticeName = this.name;
|
|
417
514
|
hub.onPlatformNotice((message, severity) => {
|
|
418
515
|
noticeSyslog?.log(message, severity, noticeName);
|
|
419
516
|
});
|
|
420
|
-
hub.registerConnection(transport);
|
|
421
517
|
let revocable = null;
|
|
422
|
-
if (typeof token.isValid === "function") {
|
|
423
|
-
revocable = {
|
|
424
|
-
token,
|
|
425
|
-
close: (code, reason) => conn.close(code, reason)
|
|
426
|
-
};
|
|
427
|
-
hub.registerRevocable(revocable);
|
|
428
|
-
}
|
|
429
518
|
const broadcast = this.realtimeBroadcastChannels();
|
|
430
519
|
for (const channel of broadcast) hub.markBroadcastChannel(channel);
|
|
431
520
|
const decoratedInbound = getRealtimeInbound(this);
|
|
@@ -447,20 +536,39 @@ var RealtimeController = class extends Controller {
|
|
|
447
536
|
welcomed: true,
|
|
448
537
|
peer,
|
|
449
538
|
transport,
|
|
539
|
+
binaryCodec,
|
|
450
540
|
channels: /* @__PURE__ */ new Map(),
|
|
451
|
-
inbound
|
|
541
|
+
inbound,
|
|
542
|
+
admission: null,
|
|
543
|
+
admitted: null,
|
|
544
|
+
retained: null,
|
|
545
|
+
connection: null
|
|
452
546
|
};
|
|
453
547
|
ctx.__nfRealtime = state;
|
|
454
548
|
ctx.once("onFinish", () => {
|
|
455
549
|
const liveHub = getRealtimeHub();
|
|
456
550
|
for (const [channel, sink] of state.channels) liveHub.unsubscribe(channel, sink);
|
|
457
551
|
state.channels.clear();
|
|
552
|
+
state.connection?.close();
|
|
553
|
+
if (state.admission !== null) {
|
|
554
|
+
state.admission.close();
|
|
555
|
+
state.admission = null;
|
|
556
|
+
}
|
|
557
|
+
hub.untrackRetainedVerdicts(state);
|
|
458
558
|
liveHub.unregisterConnection(transport);
|
|
459
559
|
if (revocable) liveHub.unregisterRevocable(revocable);
|
|
460
560
|
transport.fireClose();
|
|
461
561
|
peer.dispose("ws closed");
|
|
462
562
|
this.log("WS realtime client disconnected — cleanup done", "INFO");
|
|
463
563
|
});
|
|
564
|
+
hub.registerConnection(transport);
|
|
565
|
+
if (typeof token.isValid === "function") {
|
|
566
|
+
revocable = {
|
|
567
|
+
token,
|
|
568
|
+
close: (code, reason) => conn.close(code, reason)
|
|
569
|
+
};
|
|
570
|
+
hub.registerRevocable(revocable);
|
|
571
|
+
}
|
|
464
572
|
const announcedChannels = [...this._decoratedChannels ? Object.keys(this._decoratedChannels) : [], ...this.realtimeChannels()].filter((channel) => hub.probeChannel(channel, peer));
|
|
465
573
|
const env = this.kernel?.environment;
|
|
466
574
|
const welcome = {
|
|
@@ -470,15 +578,15 @@ var RealtimeController = class extends Controller {
|
|
|
470
578
|
methods: peer.methods,
|
|
471
579
|
subscriptionAcks: true,
|
|
472
580
|
...welcomeEnv(env),
|
|
473
|
-
identity:
|
|
474
|
-
type: token.type,
|
|
475
|
-
authenticated: token.isAuthenticated(),
|
|
476
|
-
userIdentifier: token.getUserIdentifier(),
|
|
477
|
-
roles: token.getRoles(),
|
|
478
|
-
scopes: token.getScopes()
|
|
479
|
-
}
|
|
581
|
+
identity: welcomeIdentityOf(token)
|
|
480
582
|
};
|
|
583
|
+
if (binaryCodec !== null) welcome.codec = binaryCodec.protocol;
|
|
584
|
+
else if (peer.codec.bytesMarker !== void 0) welcome.bytes = peer.codec.bytesMarker;
|
|
481
585
|
peer.notify("realtime:welcome", welcome);
|
|
586
|
+
if (binaryCodec !== null) {
|
|
587
|
+
peer.codec = binaryCodec;
|
|
588
|
+
ctx.frameCodec = binaryCodec;
|
|
589
|
+
}
|
|
482
590
|
this.log("WS realtime client connected", "INFO");
|
|
483
591
|
}
|
|
484
592
|
/**
|
|
@@ -498,7 +606,7 @@ var RealtimeController = class extends Controller {
|
|
|
498
606
|
const handler = state?.inbound?.[method];
|
|
499
607
|
if (handler) {
|
|
500
608
|
getRealtimeHub().recordInbound();
|
|
501
|
-
handler(params, (payload) => state.peer.notify(method, payload));
|
|
609
|
+
handler(params, (payload) => state.peer.notify(method, payload), state.connection ?? this.connectionOf(ctx, state));
|
|
502
610
|
}
|
|
503
611
|
}
|
|
504
612
|
/**
|
|
@@ -509,9 +617,117 @@ var RealtimeController = class extends Controller {
|
|
|
509
617
|
* par connexion).
|
|
510
618
|
*/
|
|
511
619
|
acknowledgeSubscription(state, channel) {
|
|
512
|
-
state.
|
|
620
|
+
const connection = state.connection;
|
|
621
|
+
const imposed = connection?.isImposing(channel) === true;
|
|
622
|
+
if (state.binaryCodec !== null) state.peer.notify("realtime:subscribed", imposed ? {
|
|
623
|
+
channel,
|
|
624
|
+
imposed: true
|
|
625
|
+
} : { channel });
|
|
626
|
+
else state.transport.send(imposed ? imposedSubscribedAckFrame(channel) : subscribedAckFrame(channel));
|
|
627
|
+
if (imposed) connection.settleImposed(channel, true);
|
|
628
|
+
if (connection !== null) getRealtimeHub().presenceSubscribed(channel, connection);
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Poignée publique de la connexion, créée au premier besoin (abonnement, trame entrante) puis gardée sur son état.
|
|
632
|
+
*
|
|
633
|
+
* Cold path : une fois par connexion. Les deux fermetures la relient à CE
|
|
634
|
+
* contrôleur, dont les fabriques servent ses canaux.
|
|
635
|
+
*/
|
|
636
|
+
connectionOf(ctx, state) {
|
|
637
|
+
let connection = state.connection;
|
|
638
|
+
if (connection === null) {
|
|
639
|
+
const peer = state.peer;
|
|
640
|
+
connection = new RealtimeConnection({
|
|
641
|
+
impose: (channel) => this.imposeChannel(ctx, channel),
|
|
642
|
+
end: (channel, reason) => this.endChannel(ctx, channel, reason),
|
|
643
|
+
send: (frame) => {
|
|
644
|
+
const data = frame.encodeFor(peer.codec);
|
|
645
|
+
if (data !== void 0) state.transport.send(data);
|
|
646
|
+
else if (frame.claimFailure(peer.codec)) this.log("trame partagée non encodable : non envoyée", "ERROR");
|
|
647
|
+
}
|
|
648
|
+
}, () => getRealtimeHub().getTokenForPeer(peer).getIdentity?.() ?? null);
|
|
649
|
+
state.connection = connection;
|
|
650
|
+
}
|
|
651
|
+
return connection;
|
|
513
652
|
}
|
|
514
|
-
/**
|
|
653
|
+
/**
|
|
654
|
+
* Abonnement imposé par le serveur (`RealtimeHub.subscribeConnection`) — la politique fixe par le verrou des trames, puis le chemin d'un `subscribe` du client, `allow` compris (ADR-0022 §6 décision 2).
|
|
655
|
+
*
|
|
656
|
+
* Le verdict est rendu à l'appelant par `RealtimeConnection.settleImposed` :
|
|
657
|
+
* à l'acquittement ({@link acknowledgeSubscription}) ou au refus
|
|
658
|
+
* ({@link refuseChannel}) — qui n'envoie alors rien au client, qui n'a rien
|
|
659
|
+
* demandé.
|
|
660
|
+
*/
|
|
661
|
+
imposeChannel(ctx, channel) {
|
|
662
|
+
const state = ctx.__nfRealtime;
|
|
663
|
+
const connection = state?.connection;
|
|
664
|
+
if (!state || !connection) return;
|
|
665
|
+
if (state.channels.has(channel)) {
|
|
666
|
+
connection.settleImposed(channel, true);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
const frame = {
|
|
670
|
+
method: "subscribe",
|
|
671
|
+
params: { channel }
|
|
672
|
+
};
|
|
673
|
+
if (!getRealtimeHub().runAuthorizer(frame, state.peer)) {
|
|
674
|
+
connection.settleImposed(channel, false);
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
this.startChannel(ctx, channel);
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Met fin à l'abonnement de la connexion à un canal, sur décision du serveur, et le lui notifie (`realtime:closed`).
|
|
681
|
+
*
|
|
682
|
+
* Même chemin que l'`unsubscribe` du client (hub ET état de la connexion),
|
|
683
|
+
* jamais un second : retirer le seul sink du hub laisserait l'entrée de la
|
|
684
|
+
* connexion, et le `subscribe` suivant serait acquitté sans rien rouvrir.
|
|
685
|
+
* Retire aussi la marque « admise » et le verdict retenu de ce canal.
|
|
686
|
+
*
|
|
687
|
+
* @returns `false` si la connexion n'est pas abonnée à ce canal.
|
|
688
|
+
*/
|
|
689
|
+
endChannel(ctx, channel, reason) {
|
|
690
|
+
const state = ctx.__nfRealtime;
|
|
691
|
+
if (!state || !this.leaveChannel(state, channel)) return false;
|
|
692
|
+
revokeAdmission(state, channel);
|
|
693
|
+
const closed = {
|
|
694
|
+
channel,
|
|
695
|
+
reason
|
|
696
|
+
};
|
|
697
|
+
state.peer.notify("realtime:closed", closed);
|
|
698
|
+
this.log(`WS ${reason} → ${channel}`, "DEBUG");
|
|
699
|
+
return true;
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Retire la connexion d'un canal : hub, état de la connexion, admission de sa règle — le chemin UNIQUE d'une sortie, demandée par le client ou décidée par le serveur.
|
|
703
|
+
*
|
|
704
|
+
* @returns `false` si la connexion n'était pas abonnée.
|
|
705
|
+
*/
|
|
706
|
+
leaveChannel(state, channel) {
|
|
707
|
+
const sink = state.channels.get(channel);
|
|
708
|
+
if (sink === void 0) return false;
|
|
709
|
+
const hub = getRealtimeHub();
|
|
710
|
+
hub.unsubscribe(channel, sink);
|
|
711
|
+
state.channels.delete(channel);
|
|
712
|
+
releaseRuleAdmission(state, channel, hub);
|
|
713
|
+
return true;
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Refus d'un abonnement : rendu à l'appelant serveur s'il s'agit d'une imposition, au client sinon — et aux deux quand le client avait aussi demandé ce canal.
|
|
717
|
+
*/
|
|
718
|
+
refuseChannel(state, channel, reason, detail, retryAfterMs) {
|
|
719
|
+
const connection = state.connection;
|
|
720
|
+
if (connection !== null && !connection.settleImposed(channel, false)) return;
|
|
721
|
+
this.denyChannel(state, channel, reason, detail, retryAfterMs);
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Abonne la connexion à un canal via le hub partagé (idempotent par connexion).
|
|
725
|
+
*
|
|
726
|
+
* Un canal dont la politique déclare `allow` passe d'abord par la file
|
|
727
|
+
* d'admission de la connexion ; tout autre canal s'ouvre tout de suite, sans
|
|
728
|
+
* rien lire de plus qu'aujourd'hui (une recherche de politique au `subscribe`,
|
|
729
|
+
* trame de contrôle rare).
|
|
730
|
+
*/
|
|
515
731
|
startChannel(ctx, channel) {
|
|
516
732
|
if (!channel) return;
|
|
517
733
|
const state = ctx.__nfRealtime;
|
|
@@ -520,30 +736,97 @@ var RealtimeController = class extends Controller {
|
|
|
520
736
|
this.acknowledgeSubscription(state, channel);
|
|
521
737
|
return;
|
|
522
738
|
}
|
|
523
|
-
const
|
|
524
|
-
if (
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
739
|
+
const admission = state.admission;
|
|
740
|
+
if (admission?.revive(channel) === true) {
|
|
741
|
+
state.connection?.markClientAsked(channel);
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
const hub = getRealtimeHub();
|
|
745
|
+
const cap = hub.maxChannelsPerConnection;
|
|
746
|
+
if (cap !== null && state.channels.size + (admission === null ? 0 : admission.unsettled) >= cap) {
|
|
747
|
+
this.refuseChannel(state, channel, "limit", `plafond de ${cap} canaux par connexion atteint : désabonne-toi d'un canal avant d'en ouvrir un autre, ou relève \`maxChannelsPerConnection\` dans la configuration realtime`);
|
|
531
748
|
this.log(`WS subscribe refusé (cap ${cap} canaux/connexion atteint) → ${channel}`, "DEBUG");
|
|
532
749
|
return;
|
|
533
750
|
}
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
|
|
751
|
+
const rule = hub.resolveChannelAdmission(channel);
|
|
752
|
+
if (rule === null) {
|
|
753
|
+
this.openChannel(ctx, state, channel);
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
this.admitChannel(ctx, state, channel, rule, hub);
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Soumet l'abonnement à la règle `allow` du canal — APRÈS le verrou des trames, qui a déjà jugé `authenticated`/`roles`/`scopes` (ADR-0022 §6, décision 1 ter, moment 2).
|
|
760
|
+
*
|
|
761
|
+
* `allow` s'applique même sans module de sécurité : il ne fait que retirer
|
|
762
|
+
* des abonnés, jamais en ajouter — l'ignorer ouvrirait `user:<id>` à tous.
|
|
763
|
+
* La file de la connexion n'est créée qu'à son premier canal à `allow`.
|
|
764
|
+
*/
|
|
765
|
+
admitChannel(ctx, state, channel, rule, hub) {
|
|
766
|
+
let admission = state.admission;
|
|
767
|
+
if (admission === null) {
|
|
768
|
+
const who = hub.getTokenForPeer(state.peer).getIdentity?.();
|
|
769
|
+
if (who === void 0) {
|
|
770
|
+
this.refuseChannel(state, channel, "forbidden", "l'authentificateur de cette connexion ne produit pas d'identité d'admission (`getIdentity`) : aucun canal à `allow` ne lui est ouvert");
|
|
771
|
+
this.log(`WS subscribe refusé : jeton sans getIdentity sur un canal à allow → ${channel}`, warnedNoIdentity ? "DEBUG" : "WARNING");
|
|
772
|
+
warnedNoIdentity = true;
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
admission = new ConnectionAdmission({
|
|
776
|
+
open: (ch) => this.openChannel(ctx, state, ch),
|
|
777
|
+
deny: (ch, reason, detail, retryAfterMs) => {
|
|
778
|
+
this.refuseChannel(state, ch, reason, detail, retryAfterMs);
|
|
779
|
+
this.log(`WS subscribe refusé (${reason}) → ${ch}`, "DEBUG");
|
|
780
|
+
},
|
|
781
|
+
warn: (message) => this.log(message, "WARNING")
|
|
782
|
+
}, hub.admissionBudget, who, explainsDenials(this.kernel?.environment));
|
|
783
|
+
state.admission = admission;
|
|
784
|
+
}
|
|
785
|
+
admission.request(channel, rule);
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Rend un refus d'abonnement observable (`realtime:denied`) — le détail ne part qu'hors production.
|
|
789
|
+
*
|
|
790
|
+
* @param detail - phrase pour le développeur ; `undefined` quand l'appelant
|
|
791
|
+
* ne l'a pas rédigée (production).
|
|
792
|
+
*/
|
|
793
|
+
denyChannel(state, channel, reason, detail, retryAfterMs) {
|
|
794
|
+
const denied = {
|
|
795
|
+
channel,
|
|
796
|
+
reason,
|
|
797
|
+
...detail === void 0 ? {} : deniedDetail(this.kernel?.environment, detail),
|
|
798
|
+
...retryAfterMs === void 0 ? {} : { retryAfterMs }
|
|
799
|
+
};
|
|
800
|
+
state.peer.notify("realtime:denied", denied);
|
|
801
|
+
}
|
|
802
|
+
/** Ouvre l'abonnement au hub et l'acquitte — canal sans `allow`, ou admis par sa règle. */
|
|
803
|
+
openChannel(ctx, state, channel) {
|
|
804
|
+
const sink = (payload, frame) => {
|
|
805
|
+
if (frame !== void 0) {
|
|
806
|
+
const data = frame.encodeFor(state.peer.codec);
|
|
807
|
+
if (data !== void 0) {
|
|
808
|
+
state.transport.send(data);
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
if (!frame.claimFailure(state.peer.codec)) return;
|
|
812
|
+
}
|
|
813
|
+
state.peer.notify(channel, payload);
|
|
537
814
|
};
|
|
538
815
|
let servedByPattern = false;
|
|
539
816
|
const isServedByPattern = () => servedByPattern;
|
|
540
817
|
if (getRealtimeHub().subscribeClient(channel, sink, (ch, publish) => {
|
|
541
818
|
const decFactory = this._decoratedChannels?.[ch];
|
|
542
819
|
if (decFactory) return decFactory(ch, publish);
|
|
820
|
+
if (this.createRealtimeChannel === BASE_CREATE_CHANNEL) {
|
|
821
|
+
const byPattern = patternChannelFactory(this, this._decoratedChannels, ch);
|
|
822
|
+
if (byPattern === null) return null;
|
|
823
|
+
servedByPattern = true;
|
|
824
|
+
return byPattern(ch, publish);
|
|
825
|
+
}
|
|
543
826
|
const dispose = this.createRealtimeChannel(ch, publish);
|
|
544
827
|
if (dispose !== null) servedByPattern = true;
|
|
545
828
|
return dispose;
|
|
546
|
-
}, (payload) =>
|
|
829
|
+
}, (payload) => JsonRpcPeer.buildNotification(channel, payload), this.connectionOf(ctx, state))) {
|
|
547
830
|
state.channels.set(channel, sink);
|
|
548
831
|
if (isServedByPattern()) getRealtimeHub().noticeUnguardedDynamicChannel(channel);
|
|
549
832
|
this.acknowledgeSubscription(state, channel);
|
|
@@ -551,12 +834,7 @@ var RealtimeController = class extends Controller {
|
|
|
551
834
|
return;
|
|
552
835
|
}
|
|
553
836
|
const floor = getRealtimeHub().isClosedBySystemFloor(channel);
|
|
554
|
-
|
|
555
|
-
channel,
|
|
556
|
-
reason: floor ? "forbidden" : "unknown",
|
|
557
|
-
...deniedDetail(this.kernel?.environment, floor ? `« ${channel} » appartient au namespace de plateforme, dont le plancher est CLOS tant qu'aucun module de sécurité n'est chargé — ce n'est pas ton identité qui est en cause` : `aucun producteur pour « ${channel} » sur ce pod : vérifie l'orthographe du canal, puis qu'un \`@RealtimeChannel("${channel}")\` est bien déclaré dans un controller CHARGÉ (le runtime lit \`dist/\`, pas les sources)`)
|
|
558
|
-
};
|
|
559
|
-
state.peer.notify("realtime:denied", denied);
|
|
837
|
+
this.refuseChannel(state, channel, floor ? "forbidden" : "unknown", floor ? `« ${channel} » appartient au namespace de plateforme, dont le plancher est CLOS tant qu'aucun module de sécurité n'est chargé — ce n'est pas ton identité qui est en cause` : `aucun producteur pour « ${channel} » sur ce pod : vérifie l'orthographe du canal, puis qu'un \`@RealtimeChannel("${channel}")\` est bien déclaré dans un controller CHARGÉ (le runtime lit \`dist/\`, pas les sources)`);
|
|
560
838
|
this.log(floor ? `WS subscribe refusé (canal de plateforme, aucun module de sécurité) → ${channel}` : `WS subscribe refusé (aucun producteur pour ce canal) → ${channel}`, "DEBUG");
|
|
561
839
|
}
|
|
562
840
|
/**
|
|
@@ -617,10 +895,11 @@ var RealtimeController = class extends Controller {
|
|
|
617
895
|
* mutualisé tant que Ph.6 n'a pas tranché).
|
|
618
896
|
*
|
|
619
897
|
* Erreurs → {@link RpcError} (`data.status` = statut HTTP équivalent) :
|
|
620
|
-
* `-32602` params invalides, 404 path inconnu, 408 timeout d'action…
|
|
898
|
+
* `-32602` params invalides, 404 path inconnu, 408 timeout d'action… Un
|
|
899
|
+
* `Retry-After` posé par l'action part en `data.retryAfter` (secondes).
|
|
621
900
|
* Tout autre throw de l'action reste opaque (`-32603`, Zero Trust).
|
|
622
901
|
*/
|
|
623
|
-
async invokeApiRequest(ctx, params,
|
|
902
|
+
async invokeApiRequest(ctx, params, token) {
|
|
624
903
|
const p = isRecord(params) ? params : void 0;
|
|
625
904
|
const path = p?.path;
|
|
626
905
|
if (typeof path !== "string" || path.charCodeAt(0) !== 47) throw new RpcError("api.request: params.path invalide", JsonRpcError.INVALID_PARAMS);
|
|
@@ -628,13 +907,13 @@ var RealtimeController = class extends Controller {
|
|
|
628
907
|
if (method !== "GET" && method !== "POST" && method !== "PUT" && method !== "PATCH" && method !== "DELETE") throw new RpcError(`api.request: méthode ${method} non supportée`, JsonRpcError.INVALID_PARAMS);
|
|
629
908
|
const router = ctx.router;
|
|
630
909
|
if (!isFrameworkRouter(router)) throw new RpcError("api.request: router indisponible", JsonRpcServerError.DEFAULT, { status: 500 });
|
|
631
|
-
const
|
|
632
|
-
|
|
910
|
+
const target = parseRequestTarget(path);
|
|
911
|
+
if (target === null) throw new RpcError("api.request: params.path invalide", JsonRpcError.INVALID_PARAMS);
|
|
912
|
+
const pathname = stripTrailingSlashes(target.pathname);
|
|
633
913
|
const frame = ctx.beginFrame(method, path);
|
|
634
914
|
let store = null;
|
|
915
|
+
const renderSink = {};
|
|
635
916
|
try {
|
|
636
|
-
const token = getRealtimeHub().getTokenForPeer(peer);
|
|
637
|
-
const renderSink = {};
|
|
638
917
|
const frameStore = {
|
|
639
918
|
requestId: frame?.requestId ?? ctx.requestId,
|
|
640
919
|
scheme: ctx.scheme,
|
|
@@ -654,17 +933,18 @@ var RealtimeController = class extends Controller {
|
|
|
654
933
|
frame?.phaseStart("resolve");
|
|
655
934
|
let resolver;
|
|
656
935
|
try {
|
|
657
|
-
resolver = router.resolve(ctx, pathname, method
|
|
936
|
+
resolver = router.resolve(ctx, pathname, method);
|
|
658
937
|
} finally {
|
|
659
938
|
frame?.phaseEnd("resolve");
|
|
660
939
|
}
|
|
661
940
|
if (!resolver.resolve) throw new RpcError(`api.request: not found ${pathname}`, JsonRpcServerError.DEFAULT, { status: 404 });
|
|
662
941
|
if (frame) frame.resolver = resolver;
|
|
663
942
|
frameStore.resolver = resolver;
|
|
664
|
-
if (
|
|
665
|
-
const sp = new URLSearchParams(
|
|
943
|
+
if (target.search.length > 1) {
|
|
944
|
+
const sp = new URLSearchParams(target.search.slice(1));
|
|
666
945
|
const query = {};
|
|
667
946
|
for (const [k, v] of sp) {
|
|
947
|
+
if (Object.hasOwn(Object.prototype, k)) continue;
|
|
668
948
|
const prev = query[k];
|
|
669
949
|
if (prev === void 0) query[k] = v;
|
|
670
950
|
else if (Array.isArray(prev)) prev.push(v);
|
|
@@ -688,7 +968,7 @@ var RealtimeController = class extends Controller {
|
|
|
688
968
|
frame?.phaseStart("action");
|
|
689
969
|
try {
|
|
690
970
|
const { result: actionResult } = await resolver.executeActionGuarded(void 0, true);
|
|
691
|
-
const raw = await
|
|
971
|
+
const raw = await actionResult;
|
|
692
972
|
if (renderSink.body !== void 0) {
|
|
693
973
|
const text = Buffer.isBuffer(renderSink.body) ? renderSink.body.toString("utf8") : renderSink.body;
|
|
694
974
|
let body;
|
|
@@ -717,7 +997,7 @@ var RealtimeController = class extends Controller {
|
|
|
717
997
|
} catch (e) {
|
|
718
998
|
const status = httpStatusOfFrameError(e);
|
|
719
999
|
frame?.finish(status ?? 500, e);
|
|
720
|
-
throw toFrameRpcError(e, status, frame?.profiled ? frame.requestId : void 0);
|
|
1000
|
+
throw toFrameRpcError(e, status, frame?.profiled ? frame.requestId : void 0, status === null ? null : retryAfterSeconds(renderSink.retryAfter));
|
|
721
1001
|
} finally {
|
|
722
1002
|
if (store !== null) RequestContext.release(store);
|
|
723
1003
|
ctx.collectFrame(frame);
|
|
@@ -728,15 +1008,29 @@ var RealtimeController = class extends Controller {
|
|
|
728
1008
|
if (!channel) return;
|
|
729
1009
|
const state = ctx.__nfRealtime;
|
|
730
1010
|
if (!state) return;
|
|
731
|
-
|
|
732
|
-
if (sink) {
|
|
733
|
-
getRealtimeHub().unsubscribe(channel, sink);
|
|
734
|
-
state.channels.delete(channel);
|
|
1011
|
+
if (this.leaveChannel(state, channel)) {
|
|
735
1012
|
this.log(`WS unsubscribe → ${channel}`, "DEBUG");
|
|
1013
|
+
return;
|
|
736
1014
|
}
|
|
1015
|
+
state.admission?.cancel(channel);
|
|
1016
|
+
state.connection?.settleImposed(channel, false);
|
|
737
1017
|
}
|
|
738
1018
|
};
|
|
739
1019
|
/**
|
|
1020
|
+
* La fabrique par défaut de la base (`null` partout) : une classe qui la garde
|
|
1021
|
+
* ne surcharge pas `createRealtimeChannel`, et ses motifs décorés servent alors
|
|
1022
|
+
* les noms qu'ils couvrent. Lue une fois, comparée par référence au `subscribe`.
|
|
1023
|
+
*/
|
|
1024
|
+
const BASE_CREATE_CHANNEL = Reflect.get(RealtimeController.prototype, "createRealtimeChannel");
|
|
1025
|
+
/**
|
|
1026
|
+
* Une frame JSON-RPC porte-t-elle un `id` — réponse ou requête, jamais une
|
|
1027
|
+
* notification ? Même règle que `isNotification` du cœur (`id` absent), sur une
|
|
1028
|
+
* frame non typée.
|
|
1029
|
+
*/
|
|
1030
|
+
function carriesId(frame) {
|
|
1031
|
+
return typeof frame === "object" && frame !== null && Reflect.get(frame, "id") !== void 0;
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
740
1034
|
* Construit un {@link IRealtimeHandshake} immuable depuis le `WebsocketContext`
|
|
741
1035
|
* de @nodefony/http — DTO neutre passé aux authenticators réseau (zéro
|
|
742
1036
|
* dépendance security/http côté contrat). Cold path (1× par upgrade).
|
|
@@ -747,6 +1041,18 @@ var RealtimeController = class extends Controller {
|
|
|
747
1041
|
* - `protocols` : la liste des sous-protocoles annoncés (`Sec-WebSocket-Protocol`,
|
|
748
1042
|
* csv séparé virgule selon RFC 6455 §4.1).
|
|
749
1043
|
*/
|
|
1044
|
+
/**
|
|
1045
|
+
* Les sous-protocoles offerts au handshake (`Sec-WebSocket-Protocol`, liste
|
|
1046
|
+
* séparée par des virgules, RFC 6455 §4.1), dans l'ordre de préférence du
|
|
1047
|
+
* client — la lecture unique du DTO du handshake et de la négociation du codec.
|
|
1048
|
+
*
|
|
1049
|
+
* @param raw - l'en-tête tel que Node le rend : chaîne, liste, ou absent.
|
|
1050
|
+
* @returns les noms, sans espaces ni entrées vides ; `[]` sans en-tête.
|
|
1051
|
+
*/
|
|
1052
|
+
function offeredSubprotocols(raw) {
|
|
1053
|
+
if (raw === void 0) return [];
|
|
1054
|
+
return (typeof raw === "string" ? raw.split(",") : raw.flatMap((p) => p.split(","))).map((x) => x.trim()).filter((x) => x.length > 0);
|
|
1055
|
+
}
|
|
750
1056
|
function buildHandshakeFromContext(ctx) {
|
|
751
1057
|
const headers = ctx.request?.headers ?? {};
|
|
752
1058
|
const cookies = /* @__PURE__ */ new Map();
|
|
@@ -755,11 +1061,7 @@ function buildHandshakeFromContext(ctx) {
|
|
|
755
1061
|
const c = rawCookies[name];
|
|
756
1062
|
if (c && typeof c.value === "string") cookies.set(name, c.value);
|
|
757
1063
|
}
|
|
758
|
-
const
|
|
759
|
-
let protocols;
|
|
760
|
-
if (Array.isArray(rawProto)) protocols = rawProto.flatMap((p) => p.split(",").map((x) => x.trim()));
|
|
761
|
-
else if (typeof rawProto === "string") protocols = rawProto.split(",").map((x) => x.trim()).filter(Boolean);
|
|
762
|
-
else protocols = [];
|
|
1064
|
+
const protocols = offeredSubprotocols(headers["sec-websocket-protocol"]);
|
|
763
1065
|
return {
|
|
764
1066
|
headers,
|
|
765
1067
|
cookies,
|
|
@@ -784,4 +1086,4 @@ function handshakePath(ctx) {
|
|
|
784
1086
|
return raw.charCodeAt(0) === 47 ? raw : "/";
|
|
785
1087
|
}
|
|
786
1088
|
}
|
|
787
|
-
export { RealtimeController, RealtimeController as default };
|
|
1089
|
+
export { HANDSHAKE_FAILED_LOG, RealtimeController, RealtimeController as default, retryAfterSeconds };
|