@nodefony/realtime 10.0.0-beta.5 → 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 +50 -0
- 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 +340 -81
- 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 +91 -11
- 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 +12 -1
- package/docs/actions.md +419 -18
- 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 +314 -85
- package/docs/index.md +11 -1
- package/docs/observabilite.md +71 -14
- package/docs/protocole.md +133 -24
- package/docs/securite.md +284 -32
- package/docs/vocabulaire.md +13 -12
- package/package.json +14 -11
|
@@ -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 { DEFAULT_ACTION_POLICY, getRealtimeActions, getRealtimeChannels, getRealtimeInbound } 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 { admitByRule, 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
|
/**
|
|
@@ -79,6 +85,9 @@ function webSocketServerOf(ctx) {
|
|
|
79
85
|
return server && "options" in server ? server : null;
|
|
80
86
|
}
|
|
81
87
|
let warnedUnenforcedPolicies = false;
|
|
88
|
+
/** Connexions déjà prévenues du flux brut refusé sous codec binaire — alloué au premier refus. */
|
|
89
|
+
let rawBinaryNoticed = null;
|
|
90
|
+
let warnedNoIdentity = false;
|
|
82
91
|
/**
|
|
83
92
|
* RealtimeController — base d'un endpoint WebSocket temps réel SERVEUR (JSON-RPC 2.0).
|
|
84
93
|
*
|
|
@@ -124,6 +133,11 @@ var RealtimeController = class extends Controller {
|
|
|
124
133
|
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
134
|
}
|
|
126
135
|
/**
|
|
136
|
+
* Le codec négocié au handshake (ADR-0022 §2), `null` hors WebSocket. Choisi par
|
|
137
|
+
* le constructeur : c'est la dernière fenêtre avant le `101`.
|
|
138
|
+
*/
|
|
139
|
+
#codec;
|
|
140
|
+
/**
|
|
127
141
|
* @param name - nom du service (celui de la classe, par convention).
|
|
128
142
|
* @param context - le contexte de la connexion.
|
|
129
143
|
* @throws BootConfigurationError si la classe se déclare singleton.
|
|
@@ -132,6 +146,28 @@ var RealtimeController = class extends Controller {
|
|
|
132
146
|
new.target.assertScope();
|
|
133
147
|
super(name, context);
|
|
134
148
|
this.#connection = isWebsocketContext(context) ? context : null;
|
|
149
|
+
this.#codec = this.#connection !== null ? this.#negotiateCodec(this.#connection) : null;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Choisit le codec de la connexion d'après l'offre du handshake (ADR-0022 §2)
|
|
153
|
+
* et le confie à l'admission, qui le renverra dans le `101` (RFC 6455 §4.2.2 :
|
|
154
|
+
* le sous-protocole CHOISI, jamais le premier offert). Le contrôleur WS est
|
|
155
|
+
* construit AVANT l'acceptation (`HttpKernel.handleWebsocket`) : c'est la
|
|
156
|
+
* dernière fenêtre où ce choix atteint encore le client. Le client, lui, lit
|
|
157
|
+
* le codec dans `realtime:welcome`, jamais dans cet écho.
|
|
158
|
+
*
|
|
159
|
+
* Rien n'est retenu pour un client qui n'offre aucun sous-protocole — le cas
|
|
160
|
+
* de toute connexion d'avant les codecs. Un `forward` reconstruit une
|
|
161
|
+
* instance après l'acceptation : son choix, identique, est alors ignoré.
|
|
162
|
+
*
|
|
163
|
+
* @param ctx - le contexte de la connexion.
|
|
164
|
+
* @returns le codec à annoncer dans l'accueil, puis à employer.
|
|
165
|
+
*/
|
|
166
|
+
#negotiateCodec(ctx) {
|
|
167
|
+
const offered = offeredSubprotocols(ctx.request?.headers["sec-websocket-protocol"]);
|
|
168
|
+
const codec = negotiateFrameCodec(getRealtimeHub().frameCodecs, offered);
|
|
169
|
+
if (offered.length > 0) ctx.admission?.selectSubprotocol(offered.includes(codec.protocol) ? codec.protocol : false);
|
|
170
|
+
return codec;
|
|
135
171
|
}
|
|
136
172
|
/**
|
|
137
173
|
* La connexion WebSocket que sert CETTE instance — rétrécie UNE fois, ici,
|
|
@@ -188,6 +224,9 @@ var RealtimeController = class extends Controller {
|
|
|
188
224
|
* surcharger ; défaut : **aucun** (sûr — un client ne peut rien pousser au serveur
|
|
189
225
|
* tant qu'un canal n'est pas explicitement déclaré ici). Seam des backings entrants
|
|
190
226
|
* (SIP, bridge). Cf {@link RealtimeInboundHandler} (params NON FIABLES).
|
|
227
|
+
*
|
|
228
|
+
* Typée par `Listen` (ce que le client émet, 3ᵉ générique) : un canal hors de
|
|
229
|
+
* la carte ne compile pas, et chaque handler reçoit la forme de SON canal.
|
|
191
230
|
*/
|
|
192
231
|
realtimeInbound() {
|
|
193
232
|
return {};
|
|
@@ -196,7 +235,7 @@ var RealtimeController = class extends Controller {
|
|
|
196
235
|
* Opt-in du **pont API** (une action, deux transports) : `true` → la
|
|
197
236
|
* connexion expose la méthode RPC `api.request {path}` qui re-route le path
|
|
198
237
|
* porté par le message vers l'action controller correspondante — la MÊME
|
|
199
|
-
* action que le GET REST (`socket.
|
|
238
|
+
* action que le GET REST (`socket.get("/nodefony/kernel/api/modules")`
|
|
200
239
|
* côté client). Le pont n'atteint QUE les routes déclarant le transport
|
|
201
240
|
* `WEBSOCKET` dans leurs `methods` (zéro bypass : une action dit à quels
|
|
202
241
|
* transports elle répond). À surcharger ; défaut : `false` — aucune surface
|
|
@@ -239,7 +278,12 @@ var RealtimeController = class extends Controller {
|
|
|
239
278
|
state?.transport.feed(message);
|
|
240
279
|
return;
|
|
241
280
|
}
|
|
242
|
-
if (state
|
|
281
|
+
if (state === void 0) return;
|
|
282
|
+
if (state.binaryCodec !== null) {
|
|
283
|
+
state.transport.feed(message);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
this.receiveBinary(state.transport, message);
|
|
243
287
|
}
|
|
244
288
|
/**
|
|
245
289
|
* Faute de câblage : `handleRealtime` reçu par une instance qui ne sert pas
|
|
@@ -292,11 +336,32 @@ var RealtimeController = class extends Controller {
|
|
|
292
336
|
* client. Même contre-pression que les frames JSON-RPC.
|
|
293
337
|
*
|
|
294
338
|
* @param data - les octets à émettre (`Uint8Array`, donc un `Buffer` aussi).
|
|
295
|
-
*
|
|
296
|
-
*
|
|
339
|
+
* Sur une connexion qui a négocié un codec BINAIRE, refusé (ADR-0022 §2) : le
|
|
340
|
+
* client y lirait ces octets comme une frame du codec. Des octets passent
|
|
341
|
+
* alors par un canal, avec ses portes.
|
|
342
|
+
*
|
|
343
|
+
* @returns `false` si le handshake n'est pas terminé, la connexion fermée, la
|
|
344
|
+
* trame jetée par la contre-pression, ou la connexion en codec binaire.
|
|
297
345
|
*/
|
|
298
346
|
sendBinaryToClient(data) {
|
|
299
|
-
|
|
347
|
+
const state = this.coldConnState();
|
|
348
|
+
if (state === void 0) return false;
|
|
349
|
+
if (state.binaryCodec !== null) {
|
|
350
|
+
this.noticeRawBinaryUnderCodec(state);
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
return state.transport.sendBinary(data);
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Journalise, une fois par connexion, le flux brut refusé sous codec binaire.
|
|
357
|
+
*
|
|
358
|
+
* @param state - l'état de la connexion.
|
|
359
|
+
*/
|
|
360
|
+
noticeRawBinaryUnderCodec(state) {
|
|
361
|
+
rawBinaryNoticed ??= /* @__PURE__ */ new WeakSet();
|
|
362
|
+
if (rawBinaryNoticed.has(state)) return;
|
|
363
|
+
rawBinaryNoticed.add(state);
|
|
364
|
+
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
365
|
}
|
|
301
366
|
/**
|
|
302
367
|
* État realtime de CETTE connexion, `undefined` avant le welcome — pour les
|
|
@@ -320,6 +385,8 @@ var RealtimeController = class extends Controller {
|
|
|
320
385
|
* Échecs (close WebSocket, codes plage applicative RFC 6455 §7.4.2) :
|
|
321
386
|
* - Origin refusée → code 4003 (`forbidden`).
|
|
322
387
|
* - `authenticate` throw → code 4001 (`unauthorized`).
|
|
388
|
+
* - `authenticate` sans réponse dans `authenticateTimeoutMs` → code 1013
|
|
389
|
+
* (`try again later`, ADR-0021 §S11) : le client recule puis revient.
|
|
323
390
|
*/
|
|
324
391
|
async onHandshake(ctx) {
|
|
325
392
|
if (ctx.__nfRealtime?.welcomed) return;
|
|
@@ -329,13 +396,19 @@ var RealtimeController = class extends Controller {
|
|
|
329
396
|
const handshake = buildHandshakeFromContext(ctx);
|
|
330
397
|
if (!hub.checkOrigin(handshake.origin)) {
|
|
331
398
|
this.log(`WS realtime upgrade refused: Origin "${handshake.origin ?? "(missing)"}" not allowed`, "WARNING");
|
|
332
|
-
conn.close(
|
|
399
|
+
conn.close(ORIGIN_REFUSED_CLOSE_CODE, "origin not allowed");
|
|
333
400
|
return;
|
|
334
401
|
}
|
|
335
402
|
const authenticator = hub.resolveAuthenticator(handshake);
|
|
336
403
|
let token;
|
|
337
404
|
if (authenticator?.supports(handshake)) try {
|
|
338
|
-
|
|
405
|
+
const outcome = await hub.admissionBudget.authenticateWithin(authenticator.authenticate(handshake));
|
|
406
|
+
if (outcome === AUTHENTICATE_TIMED_OUT) {
|
|
407
|
+
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");
|
|
408
|
+
conn.close(TRY_AGAIN_LATER_CLOSE_CODE, "try again later");
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
token = outcome;
|
|
339
412
|
authenticator.onSuccess?.(handshake, token);
|
|
340
413
|
} catch (e) {
|
|
341
414
|
const err = e instanceof Error ? e : new Error(String(e));
|
|
@@ -343,28 +416,29 @@ var RealtimeController = class extends Controller {
|
|
|
343
416
|
authenticator.onFailure?.(handshake, err);
|
|
344
417
|
} catch {}
|
|
345
418
|
this.log(`WS realtime auth failed (${authenticator.name}): ${err.message}`, "WARNING");
|
|
346
|
-
conn.close(
|
|
419
|
+
conn.close(IDENTITY_DEAD_CLOSE_CODE, "unauthorized");
|
|
347
420
|
return;
|
|
348
421
|
}
|
|
349
422
|
else token = ANONYMOUS_REALTIME_TOKEN;
|
|
423
|
+
if (conn.readyState !== TransportState.OPEN) return;
|
|
350
424
|
const transport = new WsConnectionTransport(conn, readBackpressureOptions(webSocketServerOf(ctx)));
|
|
425
|
+
const codecs = hub.frameCodecs;
|
|
426
|
+
const negotiated = this.#codec ?? codecs.text;
|
|
427
|
+
const binaryCodec = negotiated === codecs.binary ? negotiated : null;
|
|
428
|
+
const outbound = ctx.outboundTracer();
|
|
351
429
|
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);
|
|
430
|
+
codec: codecs.text,
|
|
431
|
+
write: outbound === null ? (data) => transport.send(data) : (data, frame) => {
|
|
432
|
+
transport.send(data);
|
|
433
|
+
if (carriesId(frame)) outbound(data);
|
|
363
434
|
},
|
|
364
435
|
onNotification: (method, params) => this.onRealtimeNotification(ctx, method, params),
|
|
365
436
|
onError: (context, err) => this.log(`${context}: ${err instanceof Error ? err.message : String(err)}`, "ERROR")
|
|
366
437
|
};
|
|
367
|
-
peerOptions.beforeDispatch = (frame) =>
|
|
438
|
+
peerOptions.beforeDispatch = (frame) => {
|
|
439
|
+
if (state.admitted?.[frame.method] === true) return true;
|
|
440
|
+
return gateFrame(state, frame, hub);
|
|
441
|
+
};
|
|
368
442
|
peerOptions.onFrameAudit = (reason, frame, auditedPeer) => {
|
|
369
443
|
if (reason !== "denied") return;
|
|
370
444
|
this.log("WS realtime frame refused by authorizer", "WARNING");
|
|
@@ -373,11 +447,12 @@ var RealtimeController = class extends Controller {
|
|
|
373
447
|
if (f.id !== void 0) return;
|
|
374
448
|
const channel = f.method === "subscribe" ? channelParam(f.params) : typeof f.method === "string" ? f.method : void 0;
|
|
375
449
|
if (channel !== void 0) {
|
|
376
|
-
const
|
|
450
|
+
const unadmitted = f.method === channel && state.inbound !== null && Object.hasOwn(state.inbound, channel) && hub.resolveChannelAdmission(channel) !== null;
|
|
451
|
+
const hint = unadmitted ? null : identityHint(this.kernel);
|
|
377
452
|
const denied = {
|
|
378
453
|
channel,
|
|
379
454
|
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}`))
|
|
455
|
+
...deniedDetail(this.kernel?.environment, 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
456
|
};
|
|
382
457
|
auditedPeer.notify("realtime:denied", denied);
|
|
383
458
|
}
|
|
@@ -387,9 +462,9 @@ var RealtimeController = class extends Controller {
|
|
|
387
462
|
transport.onMessage((raw) => {
|
|
388
463
|
let parsed;
|
|
389
464
|
try {
|
|
390
|
-
parsed =
|
|
465
|
+
parsed = peer.decode(raw);
|
|
391
466
|
} catch {
|
|
392
|
-
if (transport.discard("malformed") === 1) this.log("WS realtime : trame texte illisible (pas du JSON) reçue et écartée"
|
|
467
|
+
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
468
|
return;
|
|
394
469
|
}
|
|
395
470
|
peer.receive(parsed);
|
|
@@ -400,17 +475,14 @@ var RealtimeController = class extends Controller {
|
|
|
400
475
|
};
|
|
401
476
|
for (const [name, handler] of Object.entries(allActions)) {
|
|
402
477
|
peer.register(name, handler);
|
|
403
|
-
if (hub.
|
|
478
|
+
if (!hub.hasExactChannelPolicy(name)) hub.registerChannelPolicy(name, DEFAULT_ACTION_POLICY);
|
|
404
479
|
}
|
|
405
480
|
if (this.realtimeApiRequest()) peer.register("api.request", (params) => this.invokeApiRequest(ctx, params, peer));
|
|
406
481
|
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
|
-
}
|
|
482
|
+
registerControllerPolicies(hub, this.constructor, null);
|
|
483
|
+
if (!warnedUnenforcedPolicies && hub.hasUnenforcedChannelPolicies()) {
|
|
484
|
+
warnedUnenforcedPolicies = true;
|
|
485
|
+
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
486
|
}
|
|
415
487
|
const noticeSyslog = this.syslog;
|
|
416
488
|
const noticeName = this.name;
|
|
@@ -447,14 +519,25 @@ var RealtimeController = class extends Controller {
|
|
|
447
519
|
welcomed: true,
|
|
448
520
|
peer,
|
|
449
521
|
transport,
|
|
522
|
+
binaryCodec,
|
|
450
523
|
channels: /* @__PURE__ */ new Map(),
|
|
451
|
-
inbound
|
|
524
|
+
inbound,
|
|
525
|
+
admission: null,
|
|
526
|
+
admitted: null,
|
|
527
|
+
retained: null,
|
|
528
|
+
connection: null
|
|
452
529
|
};
|
|
453
530
|
ctx.__nfRealtime = state;
|
|
454
531
|
ctx.once("onFinish", () => {
|
|
455
532
|
const liveHub = getRealtimeHub();
|
|
456
533
|
for (const [channel, sink] of state.channels) liveHub.unsubscribe(channel, sink);
|
|
457
534
|
state.channels.clear();
|
|
535
|
+
state.connection?.close();
|
|
536
|
+
if (state.admission !== null) {
|
|
537
|
+
state.admission.close();
|
|
538
|
+
state.admission = null;
|
|
539
|
+
}
|
|
540
|
+
hub.untrackRetainedVerdicts(state);
|
|
458
541
|
liveHub.unregisterConnection(transport);
|
|
459
542
|
if (revocable) liveHub.unregisterRevocable(revocable);
|
|
460
543
|
transport.fireClose();
|
|
@@ -470,15 +553,15 @@ var RealtimeController = class extends Controller {
|
|
|
470
553
|
methods: peer.methods,
|
|
471
554
|
subscriptionAcks: true,
|
|
472
555
|
...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
|
-
}
|
|
556
|
+
identity: welcomeIdentityOf(token)
|
|
480
557
|
};
|
|
558
|
+
if (binaryCodec !== null) welcome.codec = binaryCodec.protocol;
|
|
559
|
+
else if (peer.codec.bytesMarker !== void 0) welcome.bytes = peer.codec.bytesMarker;
|
|
481
560
|
peer.notify("realtime:welcome", welcome);
|
|
561
|
+
if (binaryCodec !== null) {
|
|
562
|
+
peer.codec = binaryCodec;
|
|
563
|
+
ctx.frameCodec = binaryCodec;
|
|
564
|
+
}
|
|
482
565
|
this.log("WS realtime client connected", "INFO");
|
|
483
566
|
}
|
|
484
567
|
/**
|
|
@@ -498,7 +581,7 @@ var RealtimeController = class extends Controller {
|
|
|
498
581
|
const handler = state?.inbound?.[method];
|
|
499
582
|
if (handler) {
|
|
500
583
|
getRealtimeHub().recordInbound();
|
|
501
|
-
handler(params, (payload) => state.peer.notify(method, payload));
|
|
584
|
+
handler(params, (payload) => state.peer.notify(method, payload), state.connection ?? this.connectionOf(ctx, state));
|
|
502
585
|
}
|
|
503
586
|
}
|
|
504
587
|
/**
|
|
@@ -509,9 +592,109 @@ var RealtimeController = class extends Controller {
|
|
|
509
592
|
* par connexion).
|
|
510
593
|
*/
|
|
511
594
|
acknowledgeSubscription(state, channel) {
|
|
595
|
+
const connection = state.connection;
|
|
596
|
+
if (connection?.isImposing(channel) === true) {
|
|
597
|
+
state.transport.send(imposedSubscribedAckFrame(channel));
|
|
598
|
+
connection.settleImposed(channel, true);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
512
601
|
state.transport.send(subscribedAckFrame(channel));
|
|
513
602
|
}
|
|
514
|
-
/**
|
|
603
|
+
/**
|
|
604
|
+
* Poignée publique de la connexion, créée au premier besoin (abonnement, trame entrante) puis gardée sur son état.
|
|
605
|
+
*
|
|
606
|
+
* Cold path : une fois par connexion. Les deux fermetures la relient à CE
|
|
607
|
+
* contrôleur, dont les fabriques servent ses canaux.
|
|
608
|
+
*/
|
|
609
|
+
connectionOf(ctx, state) {
|
|
610
|
+
let connection = state.connection;
|
|
611
|
+
if (connection === null) {
|
|
612
|
+
const peer = state.peer;
|
|
613
|
+
connection = new RealtimeConnection({
|
|
614
|
+
impose: (channel) => this.imposeChannel(ctx, channel),
|
|
615
|
+
end: (channel, reason) => this.endChannel(ctx, channel, reason)
|
|
616
|
+
}, () => getRealtimeHub().getTokenForPeer(peer).getIdentity?.().id ?? null);
|
|
617
|
+
state.connection = connection;
|
|
618
|
+
}
|
|
619
|
+
return connection;
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* 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).
|
|
623
|
+
*
|
|
624
|
+
* Le verdict est rendu à l'appelant par `RealtimeConnection.settleImposed` :
|
|
625
|
+
* à l'acquittement ({@link acknowledgeSubscription}) ou au refus
|
|
626
|
+
* ({@link refuseChannel}) — qui n'envoie alors rien au client, qui n'a rien
|
|
627
|
+
* demandé.
|
|
628
|
+
*/
|
|
629
|
+
imposeChannel(ctx, channel) {
|
|
630
|
+
const state = ctx.__nfRealtime;
|
|
631
|
+
const connection = state?.connection;
|
|
632
|
+
if (!state || !connection) return;
|
|
633
|
+
if (state.channels.has(channel)) {
|
|
634
|
+
connection.settleImposed(channel, true);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
const frame = {
|
|
638
|
+
method: "subscribe",
|
|
639
|
+
params: { channel }
|
|
640
|
+
};
|
|
641
|
+
if (!getRealtimeHub().runAuthorizer(frame, state.peer)) {
|
|
642
|
+
connection.settleImposed(channel, false);
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
this.startChannel(ctx, channel);
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Met fin à l'abonnement de la connexion à un canal, sur décision du serveur, et le lui notifie (`realtime:closed`).
|
|
649
|
+
*
|
|
650
|
+
* Même chemin que l'`unsubscribe` du client (hub ET état de la connexion),
|
|
651
|
+
* jamais un second : retirer le seul sink du hub laisserait l'entrée de la
|
|
652
|
+
* connexion, et le `subscribe` suivant serait acquitté sans rien rouvrir.
|
|
653
|
+
* Retire aussi la marque « admise » et le verdict retenu de ce canal.
|
|
654
|
+
*
|
|
655
|
+
* @returns `false` si la connexion n'est pas abonnée à ce canal.
|
|
656
|
+
*/
|
|
657
|
+
endChannel(ctx, channel, reason) {
|
|
658
|
+
const state = ctx.__nfRealtime;
|
|
659
|
+
if (!state || !this.leaveChannel(state, channel)) return false;
|
|
660
|
+
revokeAdmission(state, channel);
|
|
661
|
+
const closed = {
|
|
662
|
+
channel,
|
|
663
|
+
reason
|
|
664
|
+
};
|
|
665
|
+
state.peer.notify("realtime:closed", closed);
|
|
666
|
+
this.log(`WS ${reason} → ${channel}`, "DEBUG");
|
|
667
|
+
return true;
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* 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.
|
|
671
|
+
*
|
|
672
|
+
* @returns `false` si la connexion n'était pas abonnée.
|
|
673
|
+
*/
|
|
674
|
+
leaveChannel(state, channel) {
|
|
675
|
+
const sink = state.channels.get(channel);
|
|
676
|
+
if (sink === void 0) return false;
|
|
677
|
+
getRealtimeHub().unsubscribe(channel, sink);
|
|
678
|
+
state.channels.delete(channel);
|
|
679
|
+
releaseRuleAdmission(state, channel);
|
|
680
|
+
return true;
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* 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.
|
|
684
|
+
*/
|
|
685
|
+
refuseChannel(state, channel, reason, detail, retryAfterMs) {
|
|
686
|
+
const connection = state.connection;
|
|
687
|
+
if (connection !== null && !connection.settleImposed(channel, false)) return;
|
|
688
|
+
this.denyChannel(state, channel, reason, detail, retryAfterMs);
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Abonne la connexion à un canal via le hub partagé (idempotent par connexion).
|
|
692
|
+
*
|
|
693
|
+
* Un canal dont la politique déclare `allow` passe d'abord par la file
|
|
694
|
+
* d'admission de la connexion ; tout autre canal s'ouvre tout de suite, sans
|
|
695
|
+
* rien lire de plus qu'aujourd'hui (une recherche de politique au `subscribe`,
|
|
696
|
+
* trame de contrôle rare).
|
|
697
|
+
*/
|
|
515
698
|
startChannel(ctx, channel) {
|
|
516
699
|
if (!channel) return;
|
|
517
700
|
const state = ctx.__nfRealtime;
|
|
@@ -520,20 +703,84 @@ var RealtimeController = class extends Controller {
|
|
|
520
703
|
this.acknowledgeSubscription(state, channel);
|
|
521
704
|
return;
|
|
522
705
|
}
|
|
523
|
-
const
|
|
524
|
-
if (
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
706
|
+
const admission = state.admission;
|
|
707
|
+
if (admission?.revive(channel) === true) {
|
|
708
|
+
state.connection?.markClientAsked(channel);
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
const hub = getRealtimeHub();
|
|
712
|
+
const cap = hub.maxChannelsPerConnection;
|
|
713
|
+
if (cap !== null && state.channels.size + (admission === null ? 0 : admission.unsettled) >= cap) {
|
|
714
|
+
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
715
|
this.log(`WS subscribe refusé (cap ${cap} canaux/connexion atteint) → ${channel}`, "DEBUG");
|
|
532
716
|
return;
|
|
533
717
|
}
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
|
|
718
|
+
const rule = hub.resolveChannelAdmission(channel);
|
|
719
|
+
if (rule === null) {
|
|
720
|
+
this.openChannel(ctx, state, channel);
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
this.admitChannel(ctx, state, channel, rule, hub);
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* 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).
|
|
727
|
+
*
|
|
728
|
+
* `allow` s'applique même sans module de sécurité : il ne fait que retirer
|
|
729
|
+
* des abonnés, jamais en ajouter — l'ignorer ouvrirait `user:<id>` à tous.
|
|
730
|
+
* La file de la connexion n'est créée qu'à son premier canal à `allow`.
|
|
731
|
+
*/
|
|
732
|
+
admitChannel(ctx, state, channel, rule, hub) {
|
|
733
|
+
let admission = state.admission;
|
|
734
|
+
if (admission === null) {
|
|
735
|
+
const who = hub.getTokenForPeer(state.peer).getIdentity?.();
|
|
736
|
+
if (who === void 0) {
|
|
737
|
+
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");
|
|
738
|
+
this.log(`WS subscribe refusé : jeton sans getIdentity sur un canal à allow → ${channel}`, warnedNoIdentity ? "DEBUG" : "WARNING");
|
|
739
|
+
warnedNoIdentity = true;
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
admission = new ConnectionAdmission({
|
|
743
|
+
open: (ch) => {
|
|
744
|
+
this.openChannel(ctx, state, ch);
|
|
745
|
+
if (state.channels.has(ch)) admitByRule(state, ch);
|
|
746
|
+
},
|
|
747
|
+
deny: (ch, reason, detail, retryAfterMs) => {
|
|
748
|
+
this.refuseChannel(state, ch, reason, detail, retryAfterMs);
|
|
749
|
+
this.log(`WS subscribe refusé (${reason}) → ${ch}`, "DEBUG");
|
|
750
|
+
},
|
|
751
|
+
warn: (message) => this.log(message, "WARNING")
|
|
752
|
+
}, hub.admissionBudget, who, explainsDenials(this.kernel?.environment));
|
|
753
|
+
state.admission = admission;
|
|
754
|
+
}
|
|
755
|
+
admission.request(channel, rule);
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Rend un refus d'abonnement observable (`realtime:denied`) — le détail ne part qu'hors production.
|
|
759
|
+
*
|
|
760
|
+
* @param detail - phrase pour le développeur ; `undefined` quand l'appelant
|
|
761
|
+
* ne l'a pas rédigée (production).
|
|
762
|
+
*/
|
|
763
|
+
denyChannel(state, channel, reason, detail, retryAfterMs) {
|
|
764
|
+
const denied = {
|
|
765
|
+
channel,
|
|
766
|
+
reason,
|
|
767
|
+
...detail === void 0 ? {} : deniedDetail(this.kernel?.environment, detail),
|
|
768
|
+
...retryAfterMs === void 0 ? {} : { retryAfterMs }
|
|
769
|
+
};
|
|
770
|
+
state.peer.notify("realtime:denied", denied);
|
|
771
|
+
}
|
|
772
|
+
/** Ouvre l'abonnement au hub et l'acquitte — canal sans `allow`, ou admis par sa règle. */
|
|
773
|
+
openChannel(ctx, state, channel) {
|
|
774
|
+
const sink = (payload, frame) => {
|
|
775
|
+
if (frame !== void 0) {
|
|
776
|
+
const data = frame.encodeFor(state.peer.codec);
|
|
777
|
+
if (data !== void 0) {
|
|
778
|
+
state.transport.send(data);
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
if (!frame.claimFailure(state.peer.codec)) return;
|
|
782
|
+
}
|
|
783
|
+
state.peer.notify(channel, payload);
|
|
537
784
|
};
|
|
538
785
|
let servedByPattern = false;
|
|
539
786
|
const isServedByPattern = () => servedByPattern;
|
|
@@ -543,7 +790,7 @@ var RealtimeController = class extends Controller {
|
|
|
543
790
|
const dispose = this.createRealtimeChannel(ch, publish);
|
|
544
791
|
if (dispose !== null) servedByPattern = true;
|
|
545
792
|
return dispose;
|
|
546
|
-
}, (payload) =>
|
|
793
|
+
}, (payload) => JsonRpcPeer.buildNotification(channel, payload), this.connectionOf(ctx, state))) {
|
|
547
794
|
state.channels.set(channel, sink);
|
|
548
795
|
if (isServedByPattern()) getRealtimeHub().noticeUnguardedDynamicChannel(channel);
|
|
549
796
|
this.acknowledgeSubscription(state, channel);
|
|
@@ -551,12 +798,7 @@ var RealtimeController = class extends Controller {
|
|
|
551
798
|
return;
|
|
552
799
|
}
|
|
553
800
|
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);
|
|
801
|
+
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
802
|
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
803
|
}
|
|
562
804
|
/**
|
|
@@ -628,8 +870,9 @@ var RealtimeController = class extends Controller {
|
|
|
628
870
|
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
871
|
const router = ctx.router;
|
|
630
872
|
if (!isFrameworkRouter(router)) throw new RpcError("api.request: router indisponible", JsonRpcServerError.DEFAULT, { status: 500 });
|
|
631
|
-
const
|
|
632
|
-
|
|
873
|
+
const target = parseRequestTarget(path);
|
|
874
|
+
if (target === null) throw new RpcError("api.request: params.path invalide", JsonRpcError.INVALID_PARAMS);
|
|
875
|
+
const pathname = stripTrailingSlashes(target.pathname);
|
|
633
876
|
const frame = ctx.beginFrame(method, path);
|
|
634
877
|
let store = null;
|
|
635
878
|
try {
|
|
@@ -661,8 +904,8 @@ var RealtimeController = class extends Controller {
|
|
|
661
904
|
if (!resolver.resolve) throw new RpcError(`api.request: not found ${pathname}`, JsonRpcServerError.DEFAULT, { status: 404 });
|
|
662
905
|
if (frame) frame.resolver = resolver;
|
|
663
906
|
frameStore.resolver = resolver;
|
|
664
|
-
if (
|
|
665
|
-
const sp = new URLSearchParams(
|
|
907
|
+
if (target.search.length > 1) {
|
|
908
|
+
const sp = new URLSearchParams(target.search.slice(1));
|
|
666
909
|
const query = {};
|
|
667
910
|
for (const [k, v] of sp) {
|
|
668
911
|
const prev = query[k];
|
|
@@ -728,15 +971,23 @@ var RealtimeController = class extends Controller {
|
|
|
728
971
|
if (!channel) return;
|
|
729
972
|
const state = ctx.__nfRealtime;
|
|
730
973
|
if (!state) return;
|
|
731
|
-
|
|
732
|
-
if (sink) {
|
|
733
|
-
getRealtimeHub().unsubscribe(channel, sink);
|
|
734
|
-
state.channels.delete(channel);
|
|
974
|
+
if (this.leaveChannel(state, channel)) {
|
|
735
975
|
this.log(`WS unsubscribe → ${channel}`, "DEBUG");
|
|
976
|
+
return;
|
|
736
977
|
}
|
|
978
|
+
state.admission?.cancel(channel);
|
|
979
|
+
state.connection?.settleImposed(channel, false);
|
|
737
980
|
}
|
|
738
981
|
};
|
|
739
982
|
/**
|
|
983
|
+
* Une frame JSON-RPC porte-t-elle un `id` — réponse ou requête, jamais une
|
|
984
|
+
* notification ? Même règle que `isNotification` du cœur (`id` absent), sur une
|
|
985
|
+
* frame non typée.
|
|
986
|
+
*/
|
|
987
|
+
function carriesId(frame) {
|
|
988
|
+
return typeof frame === "object" && frame !== null && Reflect.get(frame, "id") !== void 0;
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
740
991
|
* Construit un {@link IRealtimeHandshake} immuable depuis le `WebsocketContext`
|
|
741
992
|
* de @nodefony/http — DTO neutre passé aux authenticators réseau (zéro
|
|
742
993
|
* dépendance security/http côté contrat). Cold path (1× par upgrade).
|
|
@@ -747,6 +998,18 @@ var RealtimeController = class extends Controller {
|
|
|
747
998
|
* - `protocols` : la liste des sous-protocoles annoncés (`Sec-WebSocket-Protocol`,
|
|
748
999
|
* csv séparé virgule selon RFC 6455 §4.1).
|
|
749
1000
|
*/
|
|
1001
|
+
/**
|
|
1002
|
+
* Les sous-protocoles offerts au handshake (`Sec-WebSocket-Protocol`, liste
|
|
1003
|
+
* séparée par des virgules, RFC 6455 §4.1), dans l'ordre de préférence du
|
|
1004
|
+
* client — la lecture unique du DTO du handshake et de la négociation du codec.
|
|
1005
|
+
*
|
|
1006
|
+
* @param raw - l'en-tête tel que Node le rend : chaîne, liste, ou absent.
|
|
1007
|
+
* @returns les noms, sans espaces ni entrées vides ; `[]` sans en-tête.
|
|
1008
|
+
*/
|
|
1009
|
+
function offeredSubprotocols(raw) {
|
|
1010
|
+
if (raw === void 0) return [];
|
|
1011
|
+
return (typeof raw === "string" ? raw.split(",") : raw.flatMap((p) => p.split(","))).map((x) => x.trim()).filter((x) => x.length > 0);
|
|
1012
|
+
}
|
|
750
1013
|
function buildHandshakeFromContext(ctx) {
|
|
751
1014
|
const headers = ctx.request?.headers ?? {};
|
|
752
1015
|
const cookies = /* @__PURE__ */ new Map();
|
|
@@ -755,11 +1018,7 @@ function buildHandshakeFromContext(ctx) {
|
|
|
755
1018
|
const c = rawCookies[name];
|
|
756
1019
|
if (c && typeof c.value === "string") cookies.set(name, c.value);
|
|
757
1020
|
}
|
|
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 = [];
|
|
1021
|
+
const protocols = offeredSubprotocols(headers["sec-websocket-protocol"]);
|
|
763
1022
|
return {
|
|
764
1023
|
headers,
|
|
765
1024
|
cookies,
|