@nodefony/realtime 10.0.0-beta.3 → 10.0.0-beta.4
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/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/index.js +4 -6
- package/dist/nodefony/config/config.js +1 -2
- package/dist/nodefony/config/configSchema.js +1 -2
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/decorators/realtimeDecorators.js +1 -2
- package/dist/nodefony/interfaces/IRealtimeConnectionState.js +1 -0
- package/dist/nodefony/src/backplane/ClusterBackplane.js +1 -2
- package/dist/nodefony/src/backplane/LoopbackBackplane.js +1 -2
- package/dist/nodefony/src/backplane/RedisBackplane.js +1 -2
- package/dist/nodefony/src/backplane/backplaneRegistry.js +1 -2
- package/dist/nodefony/src/backplane/bootNotices.js +1 -2
- package/dist/nodefony/src/backplane/envelope.js +1 -2
- package/dist/nodefony/src/backplane/originId.js +1 -2
- package/dist/nodefony/src/backplane/publishQueue.js +0 -1
- package/dist/nodefony/src/cluster/ClusterProbeClient.js +1 -2
- package/dist/nodefony/src/guards.js +1 -2
- package/dist/nodefony/src/server/AnonymousRealtimeToken.js +1 -2
- package/dist/nodefony/src/server/RealtimeAdminApi.js +1 -2
- package/dist/nodefony/src/server/RealtimeController.js +154 -80
- package/dist/nodefony/src/server/RealtimeHub.js +3 -3
- package/dist/nodefony/src/server/ServerRealtimeSocket.js +18 -9
- package/dist/nodefony/src/server/deniedDetail.js +1 -2
- package/dist/nodefony/src/server/subscribedAck.js +29 -0
- package/dist/nodefony/src/server/syslogUplink.js +1 -2
- package/dist/nodefony/src/server/welcomeEnv.js +1 -2
- package/dist/nodefony/src/service/RealtimeService.js +1 -2
- package/dist/nodefony/src/transport/WsConnectionTransport.js +42 -2
- package/dist/nodefony/testing/index.js +77 -4
- package/dist/types/index.d.ts +1 -0
- package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +40 -0
- package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +13 -0
- package/dist/types/nodefony/interfaces/index.d.ts +1 -0
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +58 -0
- package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +18 -8
- package/dist/types/nodefony/src/server/subscribedAck.d.ts +23 -0
- package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +26 -1
- package/dist/types/nodefony/testing/index.d.ts +6 -1
- package/docs/actions.md +44 -43
- package/docs/architecture.md +125 -119
- package/docs/cadence-et-contre-pression.md +31 -22
- package/docs/configuration.md +106 -82
- package/docs/cookbook-chat.md +37 -34
- package/docs/index.md +35 -24
- package/docs/observabilite.md +49 -49
- package/docs/protocole.md +91 -68
- package/docs/securite.md +125 -114
- package/docs/vocabulaire.md +117 -114
- package/package.json +10 -10
- package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
// \0@oxc-project+runtime@0.155.0/helpers/esm/decorate.js
|
|
2
2
|
function __decorate(decorators, target, key, desc) {
|
|
3
3
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
4
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
5
|
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
6
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
7
|
}
|
|
8
|
-
//#endregion
|
|
9
8
|
export { __decorate as default };
|
package/dist/index.js
CHANGED
|
@@ -17,10 +17,9 @@ import { ClusterBackplane, processIpcTransport } from "./nodefony/src/backplane/
|
|
|
17
17
|
import { getBackplaneDriver, listBackplaneDrivers, registerBackplaneDriver } from "./nodefony/src/backplane/backplaneRegistry.js";
|
|
18
18
|
import { ClusterProbeClient, clusterProbeHealth, clusterProbeInstance, clusterProbeRequestEnrich, mergeClusterHealth, processProbeTransport, setClusterProbeClient } from "./nodefony/src/cluster/ClusterProbeClient.js";
|
|
19
19
|
import { buildOwnHealth, buildRealtimeHealth, createRealtimeAdminApi } from "./nodefony/src/server/RealtimeAdminApi.js";
|
|
20
|
-
import
|
|
21
|
-
import
|
|
22
|
-
|
|
23
|
-
//#region index.ts
|
|
20
|
+
import __decorate from "./_virtual/_@oxc-project_runtime@0.155.0/helpers/esm/decorate.js";
|
|
21
|
+
import { IDENTITY_CHANGED_EVENT, Module, services, withTimeout } from "nodefony";
|
|
22
|
+
// index.ts
|
|
24
23
|
/**
|
|
25
24
|
* @nodefony/realtime — Couche realtime serveur Nodefony.
|
|
26
25
|
*
|
|
@@ -219,7 +218,6 @@ let Realtime = class Realtime extends Module {
|
|
|
219
218
|
}
|
|
220
219
|
}
|
|
221
220
|
};
|
|
222
|
-
Realtime = __decorate([services([RealtimeService])
|
|
221
|
+
Realtime = __decorate([services([RealtimeService])], Realtime);
|
|
223
222
|
var realtime_default = Realtime;
|
|
224
|
-
//#endregion
|
|
225
223
|
export { ANONYMOUS_REALTIME_TOKEN, BROWSER_ORIGIN, ClusterBackplane, ClusterProbeClient, LoopbackBackplane, MAX_CLIENT_SEVERITY, REDIS_RT_CHANNEL, RESERVED_SYSTEM_PREFIXES, Realtime, RealtimeAction, RealtimeBroadcast, RealtimeChannel, RealtimeController, RealtimeHub, RealtimeInbound, RealtimeService, RedisBackplane, SLOW_CONSUMER_BYTES, ServerRealtimeSocket, WsConnectionTransport, buildOwnHealth, buildRealtimeHealth, clusterProbeHealth, clusterProbeInstance, clusterProbeRequestEnrich, createRealtimeAdminApi, createRedisServiceTransport, createSyslogUplinkHandler, realtime_default as default, defineRealtimeConfig, getBackplaneDriver, getDeclaredBroadcastPrefixes, getRealtimeHub, isReservedSystemChannel, listBackplaneDrivers, mergeClusterHealth, processIpcTransport, processProbeTransport, realtimeConfigJsonSchema, registerBackplaneDriver, resolveBackplaneOriginId, resolveRedisChannel, serverSocket, setClusterProbeClient };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
|
|
2
|
+
// nodefony/config/config.ts
|
|
3
3
|
/**
|
|
4
4
|
* @nodefony/realtime — CONFIGURATION DU MODULE (schéma Zod = source unique).
|
|
5
5
|
*
|
|
@@ -62,5 +62,4 @@ const realtimeConfigSchema = z.strictObject({
|
|
|
62
62
|
* valides par construction ; passés au `super(..., config)` du Module class.
|
|
63
63
|
*/
|
|
64
64
|
const config = realtimeConfigSchema.parse({});
|
|
65
|
-
//#endregion
|
|
66
65
|
export { config as default, realtimeConfigSchema };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { realtimeConfigSchema } from "./config.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
|
|
3
|
+
// nodefony/config/configSchema.ts
|
|
4
4
|
/**
|
|
5
5
|
* JSON Schema de la configuration de `@nodefony/realtime` — lu SANS démarrer
|
|
6
6
|
* l'application, par `nodefony env` et la notice `.env.example`, qui y
|
|
@@ -15,5 +15,4 @@ import { z } from "zod";
|
|
|
15
15
|
function configJsonSchema() {
|
|
16
16
|
return z.toJSONSchema(realtimeConfigSchema);
|
|
17
17
|
}
|
|
18
|
-
//#endregion
|
|
19
18
|
export { configJsonSchema };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { realtimeConfigSchema } from "./config.js";
|
|
2
2
|
import { configJsonSchema } from "./configSchema.js";
|
|
3
3
|
import { parseModuleConfig } from "nodefony";
|
|
4
|
-
|
|
4
|
+
// nodefony/config/defineModuleConfig.ts
|
|
5
5
|
/**
|
|
6
6
|
* Builder type-safe de la configuration de `@nodefony/realtime`.
|
|
7
7
|
*
|
|
@@ -83,5 +83,4 @@ function isRealtimeConfig(value) {
|
|
|
83
83
|
function realtimeConfigJsonSchema() {
|
|
84
84
|
return configJsonSchema();
|
|
85
85
|
}
|
|
86
|
-
//#endregion
|
|
87
86
|
export { buildRealtimeConfig, defineRealtimeConfig, isRealtimeConfig, realtimeConfigJsonSchema };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isCallable, isRecord } from "../src/guards.js";
|
|
2
2
|
import "reflect-metadata";
|
|
3
|
-
|
|
3
|
+
// nodefony/decorators/realtimeDecorators.ts
|
|
4
4
|
/** Clés de métadonnées posées sur le constructor de la classe controller. */
|
|
5
5
|
const ACTIONS_KEY = "realtime:actions";
|
|
6
6
|
const CHANNELS_KEY = "realtime:channels";
|
|
@@ -287,5 +287,4 @@ function getDeclaredBroadcastPrefixes() {
|
|
|
287
287
|
function applyDeclaredBroadcastPrefixes(hub) {
|
|
288
288
|
for (const prefix of declaredBroadcastPrefixes) hub.markBroadcastChannel(prefix);
|
|
289
289
|
}
|
|
290
|
-
//#endregion
|
|
291
290
|
export { DEFAULT_ACTION_POLICY, RealtimeAction, RealtimeBroadcast, RealtimeChannel, RealtimeInbound, applyDeclaredBroadcastPrefixes, getDeclaredBroadcastPrefixes, getRealtimeActions, getRealtimeChannelPolicies, getRealtimeChannels, getRealtimeInbound };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import resolveBackplaneOriginId from "./originId.js";
|
|
2
2
|
import { CLUSTER_RT_KIND } from "nodefony";
|
|
3
|
-
|
|
3
|
+
// nodefony/src/backplane/ClusterBackplane.ts
|
|
4
4
|
/**
|
|
5
5
|
* Transport de production : `process.send` (émission) + `process.on("message")`
|
|
6
6
|
* (réception). Standalone — utilisable tel quel. En vrai câblage cluster (Phase 3c),
|
|
@@ -101,5 +101,4 @@ var ClusterBackplane = class ClusterBackplane {
|
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
103
|
};
|
|
104
|
-
//#endregion
|
|
105
104
|
export { ClusterBackplane, ClusterBackplane as default, processIpcTransport };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import resolveBackplaneOriginId from "./originId.js";
|
|
2
|
-
|
|
2
|
+
// nodefony/src/backplane/LoopbackBackplane.ts
|
|
3
3
|
/**
|
|
4
4
|
* Backplane **mono-process no-op** — implémentation de RÉFÉRENCE du port
|
|
5
5
|
* {@link IBackplane} quand il n'existe **aucun pair** (1 process = 1 pod).
|
|
@@ -36,5 +36,4 @@ var LoopbackBackplane = class LoopbackBackplane {
|
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
38
|
};
|
|
39
|
-
//#endregion
|
|
40
39
|
export { LoopbackBackplane, LoopbackBackplane as default };
|
|
@@ -2,7 +2,7 @@ import { isCallable, isRecord, isThenable } from "../guards.js";
|
|
|
2
2
|
import resolveBackplaneOriginId from "./originId.js";
|
|
3
3
|
import { openBackplaneEnvelope, sealBackplaneEnvelope } from "./envelope.js";
|
|
4
4
|
import { BackplanePublishQueue } from "./publishQueue.js";
|
|
5
|
-
|
|
5
|
+
// nodefony/src/backplane/RedisBackplane.ts
|
|
6
6
|
/**
|
|
7
7
|
* BASE du canal Redis **dédié** transportant les enveloppes realtime entre pods.
|
|
8
8
|
* Un seul canal pub/sub par app (les canaux logiques voyagent DANS l'enveloppe,
|
|
@@ -189,5 +189,4 @@ var RedisBackplane = class RedisBackplane {
|
|
|
189
189
|
};
|
|
190
190
|
}
|
|
191
191
|
};
|
|
192
|
-
//#endregion
|
|
193
192
|
export { REDIS_RT_CHANNEL, RedisBackplane, RedisBackplane as default, createRedisServiceTransport, isRedisPublisher, isRedisSubscriber, resolveRedisChannel };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
// nodefony/src/backplane/backplaneRegistry.ts
|
|
2
2
|
const drivers = /* @__PURE__ */ new Map();
|
|
3
3
|
/**
|
|
4
4
|
* Enregistre (ou remplace) la fabrique d'un driver backplane. Appelé par les
|
|
@@ -16,5 +16,4 @@ function getBackplaneDriver(name) {
|
|
|
16
16
|
function listBackplaneDrivers() {
|
|
17
17
|
return [...drivers.keys()];
|
|
18
18
|
}
|
|
19
|
-
//#endregion
|
|
20
19
|
export { getBackplaneDriver, listBackplaneDrivers, registerBackplaneDriver };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
// nodefony/src/backplane/bootNotices.ts
|
|
2
2
|
/** Le geste qui rend le temps réel partagé entre processus et pods. */
|
|
3
3
|
const REDIS_BACKPLANE_FIX = "use(\"@nodefony/realtime\", { backplane: { driver: \"redis\" } }) + NF_REDIS_URL=redis://…";
|
|
4
4
|
/**
|
|
@@ -39,5 +39,4 @@ function realtimeBackplaneDownNotice(driver, reason) {
|
|
|
39
39
|
fix: driver === "redis" ? "vérifier NF_REDIS_URL et que Redis répond" : void 0
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
|
-
//#endregion
|
|
43
42
|
export { realtimeBackplaneDownNotice, realtimeLocalOnlyNotice };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
-
|
|
2
|
+
// nodefony/src/backplane/envelope.ts
|
|
3
3
|
/**
|
|
4
4
|
* **Sceau d'enveloppe backplane** — authenticité des messages transportés sur un
|
|
5
5
|
* bus **PARTAGÉ** (Redis pub/sub, et tout driver userland cross-host : NATS,
|
|
@@ -106,5 +106,4 @@ function openBackplaneEnvelope(raw, secret) {
|
|
|
106
106
|
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
|
|
107
107
|
return msg;
|
|
108
108
|
}
|
|
109
|
-
//#endregion
|
|
110
109
|
export { openBackplaneEnvelope, sealBackplaneEnvelope };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { hostname } from "node:os";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
|
|
3
|
+
// nodefony/src/backplane/originId.ts
|
|
4
4
|
/**
|
|
5
5
|
* Résout l'identifiant d'origine d'un backplane — l'étiquette que l'anti-écho
|
|
6
6
|
* compare pour jeter les messages qui reviennent à leur émetteur.
|
|
@@ -31,5 +31,4 @@ function resolveBackplaneOriginId() {
|
|
|
31
31
|
if (!host) return randomUUID();
|
|
32
32
|
return `${host}:${process.pid}`;
|
|
33
33
|
}
|
|
34
|
-
//#endregion
|
|
35
34
|
export { resolveBackplaneOriginId as default, resolveBackplaneOriginId };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isRecord } from "../guards.js";
|
|
2
2
|
import { CLUSTER_PROBE_CTL_KIND, CLUSTER_PROBE_KIND, CLUSTER_PROBE_SNAPSHOT_KIND, RichProcessProbe, isClusterMessage, isClusterProbeEnrich, readOrmRich } from "nodefony";
|
|
3
|
-
|
|
3
|
+
// nodefony/src/cluster/ClusterProbeClient.ts
|
|
4
4
|
/**
|
|
5
5
|
* Vrai si la valeur est une liste de santés d'instance. Le snapshot vient du
|
|
6
6
|
* master du cluster par IPC — un process de la MÊME application, pas le
|
|
@@ -291,5 +291,4 @@ function clusterProbeInstance(pid) {
|
|
|
291
291
|
if (!health) return null;
|
|
292
292
|
return health.instances.find((i) => i.process?.pid === pid || i.instanceId === String(pid)) ?? null;
|
|
293
293
|
}
|
|
294
|
-
//#endregion
|
|
295
294
|
export { ClusterProbeClient, ClusterProbeClient as default, clusterProbeHealth, clusterProbeInstance, clusterProbeRequestEnrich, mergeClusterHealth, processProbeTransport, setClusterProbeClient };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
// nodefony/src/guards.ts
|
|
2
2
|
/**
|
|
3
3
|
* Gardes de type internes au paquet — elles remplacent une conversion (`as`)
|
|
4
4
|
* par une vérification faite à l'exécution, pour qu'une valeur d'une autre
|
|
@@ -37,5 +37,4 @@ function isCallable(value) {
|
|
|
37
37
|
function isThenable(value) {
|
|
38
38
|
return isRecord(value) && typeof value.then === "function";
|
|
39
39
|
}
|
|
40
|
-
//#endregion
|
|
41
40
|
export { isCallable, isRecord, isThenable };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
// nodefony/src/server/AnonymousRealtimeToken.ts
|
|
2
2
|
/**
|
|
3
3
|
* Token realtime ANONYME — fallback Zero Trust quand aucun matcher
|
|
4
4
|
* `IRealtimeAuthenticatorMatcher` ne capture le handshake.
|
|
@@ -28,5 +28,4 @@ const ANONYMOUS_REALTIME_TOKEN = Object.freeze({
|
|
|
28
28
|
},
|
|
29
29
|
getAttribute(_key) {}
|
|
30
30
|
});
|
|
31
|
-
//#endregion
|
|
32
31
|
export { ANONYMOUS_REALTIME_TOKEN, ANONYMOUS_REALTIME_TOKEN as default };
|
|
@@ -2,7 +2,7 @@ import { getRealtimeHub } from "./RealtimeHub.js";
|
|
|
2
2
|
import { listBackplaneDrivers } from "../backplane/backplaneRegistry.js";
|
|
3
3
|
import { clusterProbeHealth } from "../cluster/ClusterProbeClient.js";
|
|
4
4
|
import { Nodefony, ProcessProbe, readOrmHealth } from "nodefony";
|
|
5
|
-
|
|
5
|
+
// nodefony/src/server/RealtimeAdminApi.ts
|
|
6
6
|
const processProbe = new ProcessProbe();
|
|
7
7
|
/**
|
|
8
8
|
* Producteur `IAdminApi` de la **socket Nodefony** — exposé sous
|
|
@@ -83,5 +83,4 @@ function createRealtimeAdminApi() {
|
|
|
83
83
|
adminEndpoints: () => endpoints
|
|
84
84
|
};
|
|
85
85
|
}
|
|
86
|
-
//#endregion
|
|
87
86
|
export { buildOwnHealth, buildRealtimeHealth, createRealtimeAdminApi };
|
|
@@ -5,11 +5,12 @@ import { DEFAULT_ACTION_POLICY, getRealtimeActions, getRealtimeChannelPolicies,
|
|
|
5
5
|
import { createSyslogUplinkHandler } from "./syslogUplink.js";
|
|
6
6
|
import { WsConnectionTransport } from "../transport/WsConnectionTransport.js";
|
|
7
7
|
import { welcomeEnv } from "./welcomeEnv.js";
|
|
8
|
+
import { subscribedAckFrame } from "./subscribedAck.js";
|
|
8
9
|
import { deniedDetail } from "./deniedDetail.js";
|
|
9
10
|
import { BootConfigurationError, JsonRpcError, JsonRpcPeer, JsonRpcServerError, PLATFORM_INBOUND, RequestContext, RpcEnvelope, RpcError, Scope, identityHint, isJsonRpcId, isPromise, jsonRpcFailure } from "nodefony";
|
|
10
|
-
import { readBackpressureOptions } from "@nodefony/http";
|
|
11
|
-
import { Controller, buildContextParamArgs, getParamArgsMeta } from "@nodefony/framework";
|
|
12
|
-
|
|
11
|
+
import { isWebsocketContext, readBackpressureOptions, toValidationFields } from "@nodefony/http";
|
|
12
|
+
import { Controller, buildContextParamArgs, getParamArgsMeta, isFrameworkRouter } from "@nodefony/framework";
|
|
13
|
+
// nodefony/src/server/RealtimeController.ts
|
|
13
14
|
/**
|
|
14
15
|
* Une action RPC n'a ni variables de route ni URL : `@Param` et `@Query` y
|
|
15
16
|
* lisent cet objet vide.
|
|
@@ -32,6 +33,7 @@ function httpStatusOfFrameError(e) {
|
|
|
32
33
|
if (typeof status === "number") return status;
|
|
33
34
|
return e.code === JsonRpcError.INVALID_PARAMS ? 400 : null;
|
|
34
35
|
}
|
|
36
|
+
if (e instanceof Error && toValidationFields(e) !== null) return 422;
|
|
35
37
|
const code = isRecord(e) ? e.code : void 0;
|
|
36
38
|
return typeof code === "number" && code >= 400 && code <= 599 ? code : null;
|
|
37
39
|
}
|
|
@@ -55,6 +57,10 @@ function toFrameRpcError(e, status, requestId) {
|
|
|
55
57
|
if (status !== null) {
|
|
56
58
|
const data = { status };
|
|
57
59
|
if (requestId) data.requestId = requestId;
|
|
60
|
+
if (status === 422 && e instanceof Error) {
|
|
61
|
+
const fields = toValidationFields(e);
|
|
62
|
+
if (fields !== null) data.fields = fields;
|
|
63
|
+
}
|
|
58
64
|
return new RpcError(e instanceof Error ? e.message : String(e), JsonRpcServerError.DEFAULT, data);
|
|
59
65
|
}
|
|
60
66
|
return e;
|
|
@@ -125,8 +131,20 @@ var RealtimeController = class extends Controller {
|
|
|
125
131
|
constructor(name, context) {
|
|
126
132
|
new.target.assertScope();
|
|
127
133
|
super(name, context);
|
|
134
|
+
this.#connection = isWebsocketContext(context) ? context : null;
|
|
128
135
|
}
|
|
129
136
|
/**
|
|
137
|
+
* La connexion WebSocket que sert CETTE instance — rétrécie UNE fois, ici,
|
|
138
|
+
* plutôt qu'à chaque frame.
|
|
139
|
+
*
|
|
140
|
+
* Sûr parce que l'instance est liée à sa connexion pour toute sa vie : la
|
|
141
|
+
* portée `singleton` est refusée au constructeur, et le contexte n'est posé
|
|
142
|
+
* qu'à la construction. `null` si la classe sert aussi une route HTTP et que
|
|
143
|
+
* cette instance en est issue. Le chemin de chaque frame lit ce champ au lieu
|
|
144
|
+
* de l'accesseur `context` : une lecture de champ, sans appel.
|
|
145
|
+
*/
|
|
146
|
+
#connection;
|
|
147
|
+
/**
|
|
130
148
|
* Map des factories de canaux EXACT, lue depuis les décorateurs `@RealtimeChannel`
|
|
131
149
|
* au handshake (cold-path) et mémoïsée par instance. `null` = pas de décorateur
|
|
132
150
|
* sur cette classe → la base court-circuite la lecture metadata par frame.
|
|
@@ -207,8 +225,8 @@ var RealtimeController = class extends Controller {
|
|
|
207
225
|
* écoute (welcome) » de `NodefonySocketCoverage.test.ts`.
|
|
208
226
|
*/
|
|
209
227
|
handleRealtime(message) {
|
|
210
|
-
const ctx = this
|
|
211
|
-
if (
|
|
228
|
+
const ctx = this.#connection;
|
|
229
|
+
if (ctx === null) throw this.outsideWebsocketError();
|
|
212
230
|
if (message == null) {
|
|
213
231
|
this.onHandshake(ctx).catch((e) => {
|
|
214
232
|
this.log(`WS realtime handshake failed: ${e instanceof Error ? e.message : String(e)}`, "ERROR");
|
|
@@ -216,7 +234,28 @@ var RealtimeController = class extends Controller {
|
|
|
216
234
|
});
|
|
217
235
|
return;
|
|
218
236
|
}
|
|
219
|
-
ctx.__nfRealtime
|
|
237
|
+
const state = ctx.__nfRealtime;
|
|
238
|
+
if (typeof message === "string") {
|
|
239
|
+
state?.transport.feed(message);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (state !== void 0) this.receiveBinary(state.transport, message);
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Faute de câblage : `handleRealtime` reçu par une instance qui ne sert pas
|
|
246
|
+
* une connexion WebSocket — route sans `methods: ["WEBSOCKET"]`, ou faux
|
|
247
|
+
* contexte de test sans `type`. Levée plutôt que passée sous silence : le
|
|
248
|
+
* client attendrait un welcome qui ne vient jamais. Chemin froid.
|
|
249
|
+
*/
|
|
250
|
+
outsideWebsocketError() {
|
|
251
|
+
return /* @__PURE__ */ new TypeError(`${this.constructor.name}.handleRealtime : cette instance ne sert pas une connexion WebSocket (contexte de type « ${String(this.context?.type)} »). La route doit déclarer requirements: { methods: ["WEBSOCKET"] } ; un faux contexte de test déclare type: "websocket".`);
|
|
252
|
+
}
|
|
253
|
+
receiveBinary(transport, data) {
|
|
254
|
+
if (this.onRealtimeBinary !== void 0) {
|
|
255
|
+
this.onRealtimeBinary(data);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (transport.discard("binary") === 1) this.log("WS realtime : trame binaire reçue et écartée — le protocole est du JSON-RPC texte ; pour la lire, surcharger onRealtimeBinary()", "WARNING");
|
|
220
259
|
}
|
|
221
260
|
/**
|
|
222
261
|
* **L1 — duplex serveur→client.** Émet une REQUÊTE vers le client de CETTE
|
|
@@ -234,9 +273,9 @@ var RealtimeController = class extends Controller {
|
|
|
234
273
|
* @throws (rejet) si le handshake n'est pas terminé (aucun peer encore).
|
|
235
274
|
*/
|
|
236
275
|
requestClient(method, params, timeoutMs) {
|
|
237
|
-
const state = this.
|
|
276
|
+
const state = this.coldConnState();
|
|
238
277
|
if (!state) return Promise.reject(/* @__PURE__ */ new Error("requestClient: connexion realtime non établie (welcome non émis)"));
|
|
239
|
-
return state.peer.
|
|
278
|
+
return state.peer.requestAs(method, params, timeoutMs);
|
|
240
279
|
}
|
|
241
280
|
/**
|
|
242
281
|
* Notification CIBLÉE serveur→client (sans réponse) vers CETTE connexion, hors
|
|
@@ -245,7 +284,27 @@ var RealtimeController = class extends Controller {
|
|
|
245
284
|
* handshake n'est pas terminé.
|
|
246
285
|
*/
|
|
247
286
|
notifyClient(method, params) {
|
|
248
|
-
this
|
|
287
|
+
this.#connection?.__nfRealtime?.peer.notify(method, params);
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Émet une trame BINAIRE vers CETTE connexion, sans la sérialiser — pendant
|
|
291
|
+
* sortant de {@link onRealtimeBinary}, et de `NodefonySocket.sendBinary` côté
|
|
292
|
+
* client. Même contre-pression que les frames JSON-RPC.
|
|
293
|
+
*
|
|
294
|
+
* @param data - les octets à émettre (`Uint8Array`, donc un `Buffer` aussi).
|
|
295
|
+
* @returns `false` si le handshake n'est pas terminé, la connexion fermée ou
|
|
296
|
+
* la trame jetée par la contre-pression.
|
|
297
|
+
*/
|
|
298
|
+
sendBinaryToClient(data) {
|
|
299
|
+
return this.coldConnState()?.transport.sendBinary(data) ?? false;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* État realtime de CETTE connexion, `undefined` avant le welcome — pour les
|
|
303
|
+
* chemins FROIDS (requête sortante, binaire). `notifyClient` et la réception
|
|
304
|
+
* gardent leur lecture en ligne : ils sont sur le chemin de chaque frame.
|
|
305
|
+
*/
|
|
306
|
+
coldConnState() {
|
|
307
|
+
return this.#connection?.__nfRealtime;
|
|
249
308
|
}
|
|
250
309
|
/**
|
|
251
310
|
* Handshake : seams sécurité #4 (Origin RFC 6455 §10.2) + #2 (authenticator
|
|
@@ -263,8 +322,7 @@ var RealtimeController = class extends Controller {
|
|
|
263
322
|
* - `authenticate` throw → code 4001 (`unauthorized`).
|
|
264
323
|
*/
|
|
265
324
|
async onHandshake(ctx) {
|
|
266
|
-
|
|
267
|
-
if (holder.__nfRealtime?.welcomed) return;
|
|
325
|
+
if (ctx.__nfRealtime?.welcomed) return;
|
|
268
326
|
const conn = ctx.connection;
|
|
269
327
|
if (!conn) return;
|
|
270
328
|
const hub = getRealtimeHub();
|
|
@@ -331,6 +389,7 @@ var RealtimeController = class extends Controller {
|
|
|
331
389
|
try {
|
|
332
390
|
parsed = JSON.parse(raw);
|
|
333
391
|
} catch {
|
|
392
|
+
if (transport.discard("malformed") === 1) this.log("WS realtime : trame texte illisible (pas du JSON) reçue et écartée", "WARNING");
|
|
334
393
|
return;
|
|
335
394
|
}
|
|
336
395
|
peer.receive(parsed);
|
|
@@ -391,7 +450,7 @@ var RealtimeController = class extends Controller {
|
|
|
391
450
|
channels: /* @__PURE__ */ new Map(),
|
|
392
451
|
inbound
|
|
393
452
|
};
|
|
394
|
-
|
|
453
|
+
ctx.__nfRealtime = state;
|
|
395
454
|
ctx.once("onFinish", () => {
|
|
396
455
|
const liveHub = getRealtimeHub();
|
|
397
456
|
for (const [channel, sink] of state.channels) liveHub.unsubscribe(channel, sink);
|
|
@@ -409,6 +468,7 @@ var RealtimeController = class extends Controller {
|
|
|
409
468
|
protocol: "jsonrpc-2.0",
|
|
410
469
|
channels: announcedChannels,
|
|
411
470
|
methods: peer.methods,
|
|
471
|
+
subscriptionAcks: true,
|
|
412
472
|
...welcomeEnv(env),
|
|
413
473
|
identity: {
|
|
414
474
|
type: token.type,
|
|
@@ -441,11 +501,25 @@ var RealtimeController = class extends Controller {
|
|
|
441
501
|
handler(params, (payload) => state.peer.notify(method, payload));
|
|
442
502
|
}
|
|
443
503
|
}
|
|
504
|
+
/**
|
|
505
|
+
* Acquitte un abonnement ACCEPTÉ (`realtime:subscribed`) — le seul signal
|
|
506
|
+
* qui dise au client à partir de quand le flux du canal lui est livré.
|
|
507
|
+
*
|
|
508
|
+
* Un refus n'y passe jamais : il a `realtime:denied`. Cold path (1×/canal et
|
|
509
|
+
* par connexion).
|
|
510
|
+
*/
|
|
511
|
+
acknowledgeSubscription(state, channel) {
|
|
512
|
+
state.transport.send(subscribedAckFrame(channel));
|
|
513
|
+
}
|
|
444
514
|
/** Abonne la connexion à un canal via le hub partagé (idempotent par connexion). */
|
|
445
515
|
startChannel(ctx, channel) {
|
|
446
516
|
if (!channel) return;
|
|
447
517
|
const state = ctx.__nfRealtime;
|
|
448
|
-
if (!state
|
|
518
|
+
if (!state) return;
|
|
519
|
+
if (state.channels.has(channel)) {
|
|
520
|
+
this.acknowledgeSubscription(state, channel);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
449
523
|
const cap = getRealtimeHub().maxChannelsPerConnection;
|
|
450
524
|
if (cap !== null && state.channels.size >= cap) {
|
|
451
525
|
const denied = {
|
|
@@ -472,6 +546,7 @@ var RealtimeController = class extends Controller {
|
|
|
472
546
|
}, (payload) => JSON.stringify(JsonRpcPeer.buildNotification(channel, payload)))) {
|
|
473
547
|
state.channels.set(channel, sink);
|
|
474
548
|
if (isServedByPattern()) getRealtimeHub().noticeUnguardedDynamicChannel(channel);
|
|
549
|
+
this.acknowledgeSubscription(state, channel);
|
|
475
550
|
this.log(`WS subscribe → ${channel}`, "DEBUG");
|
|
476
551
|
return;
|
|
477
552
|
}
|
|
@@ -552,52 +627,21 @@ var RealtimeController = class extends Controller {
|
|
|
552
627
|
const method = typeof p?.method === "string" ? p.method.toUpperCase() : "GET";
|
|
553
628
|
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);
|
|
554
629
|
const router = ctx.router;
|
|
555
|
-
if (!router) throw new RpcError("api.request: router indisponible", JsonRpcServerError.DEFAULT, { status: 500 });
|
|
630
|
+
if (!isFrameworkRouter(router)) throw new RpcError("api.request: router indisponible", JsonRpcServerError.DEFAULT, { status: 500 });
|
|
556
631
|
const qIdx = path.indexOf("?");
|
|
557
632
|
const pathname = qIdx === -1 ? path : path.slice(0, qIdx);
|
|
558
633
|
const frame = ctx.beginFrame(method, path);
|
|
634
|
+
let store = null;
|
|
559
635
|
try {
|
|
560
|
-
frame?.phaseStart("resolve");
|
|
561
|
-
let resolver;
|
|
562
|
-
try {
|
|
563
|
-
resolver = router.resolve(ctx, pathname, method === "GET" ? void 0 : method);
|
|
564
|
-
} finally {
|
|
565
|
-
frame?.phaseEnd("resolve");
|
|
566
|
-
}
|
|
567
|
-
if (!resolver.resolve) throw new RpcError(`api.request: not found ${pathname}`, JsonRpcServerError.DEFAULT, { status: 404 });
|
|
568
|
-
if (frame) frame.resolver = resolver;
|
|
569
|
-
if (qIdx !== -1 && qIdx < path.length - 1) {
|
|
570
|
-
const sp = new URLSearchParams(path.slice(qIdx + 1));
|
|
571
|
-
const query = {};
|
|
572
|
-
for (const [k, v] of sp) {
|
|
573
|
-
const prev = query[k];
|
|
574
|
-
if (prev === void 0) query[k] = v;
|
|
575
|
-
else if (Array.isArray(prev)) prev.push(v);
|
|
576
|
-
else query[k] = [prev, v];
|
|
577
|
-
}
|
|
578
|
-
resolver.queryOverride = query;
|
|
579
|
-
}
|
|
580
636
|
const token = getRealtimeHub().getTokenForPeer(peer);
|
|
581
|
-
if (token.isValid) {
|
|
582
|
-
let valid;
|
|
583
|
-
frame?.phaseStart("identity");
|
|
584
|
-
try {
|
|
585
|
-
valid = await token.isValid();
|
|
586
|
-
} catch {
|
|
587
|
-
valid = false;
|
|
588
|
-
} finally {
|
|
589
|
-
frame?.phaseEnd("identity");
|
|
590
|
-
}
|
|
591
|
-
if (!valid) throw new RpcError("api.request: identité de session expirée ou invalide", JsonRpcServerError.DEFAULT, { status: 401 });
|
|
592
|
-
}
|
|
593
637
|
const renderSink = {};
|
|
594
|
-
const
|
|
595
|
-
requestId: ctx.requestId,
|
|
638
|
+
const frameStore = {
|
|
639
|
+
requestId: frame?.requestId ?? ctx.requestId,
|
|
596
640
|
scheme: ctx.scheme,
|
|
597
641
|
token,
|
|
598
642
|
user: token.getAttribute("user"),
|
|
599
643
|
context: ctx,
|
|
600
|
-
resolver,
|
|
644
|
+
resolver: void 0,
|
|
601
645
|
scope: ctx.container instanceof Scope ? ctx.container : void 0,
|
|
602
646
|
body: p?.body,
|
|
603
647
|
idempotencyKey: typeof p?.idempotencyKey === "string" ? p.idempotencyKey : void 0,
|
|
@@ -605,39 +649,69 @@ var RealtimeController = class extends Controller {
|
|
|
605
649
|
invocation: frame ?? void 0,
|
|
606
650
|
queries: frame?.profilerQueries ?? void 0
|
|
607
651
|
};
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
652
|
+
store = frameStore;
|
|
653
|
+
const result = await RequestContext.run(frameStore, async () => {
|
|
654
|
+
frame?.phaseStart("resolve");
|
|
655
|
+
let resolver;
|
|
656
|
+
try {
|
|
657
|
+
resolver = router.resolve(ctx, pathname, method === "GET" ? void 0 : method);
|
|
658
|
+
} finally {
|
|
659
|
+
frame?.phaseEnd("resolve");
|
|
660
|
+
}
|
|
661
|
+
if (!resolver.resolve) throw new RpcError(`api.request: not found ${pathname}`, JsonRpcServerError.DEFAULT, { status: 404 });
|
|
662
|
+
if (frame) frame.resolver = resolver;
|
|
663
|
+
frameStore.resolver = resolver;
|
|
664
|
+
if (qIdx !== -1 && qIdx < path.length - 1) {
|
|
665
|
+
const sp = new URLSearchParams(path.slice(qIdx + 1));
|
|
666
|
+
const query = {};
|
|
667
|
+
for (const [k, v] of sp) {
|
|
668
|
+
const prev = query[k];
|
|
669
|
+
if (prev === void 0) query[k] = v;
|
|
670
|
+
else if (Array.isArray(prev)) prev.push(v);
|
|
671
|
+
else query[k] = [prev, v];
|
|
672
|
+
}
|
|
673
|
+
resolver.queryOverride = query;
|
|
674
|
+
}
|
|
675
|
+
if (token.isValid) {
|
|
676
|
+
let valid;
|
|
677
|
+
frame?.phaseStart("identity");
|
|
612
678
|
try {
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
let body;
|
|
618
|
-
try {
|
|
619
|
-
body = JSON.parse(text);
|
|
620
|
-
} catch {
|
|
621
|
-
body = text;
|
|
622
|
-
}
|
|
623
|
-
const status = renderSink.status;
|
|
624
|
-
if (status !== void 0 && status >= 400) {
|
|
625
|
-
const error = isRecord(body) ? body.error : void 0;
|
|
626
|
-
throw new RpcError(typeof error === "string" ? error : `api.request: HTTP ${status}`, JsonRpcServerError.DEFAULT, {
|
|
627
|
-
status,
|
|
628
|
-
body
|
|
629
|
-
});
|
|
630
|
-
}
|
|
631
|
-
return body;
|
|
632
|
-
}
|
|
633
|
-
return raw;
|
|
679
|
+
valid = await token.isValid();
|
|
680
|
+
if (valid && token.confirmAccount) valid = await token.confirmAccount();
|
|
681
|
+
} catch {
|
|
682
|
+
valid = false;
|
|
634
683
|
} finally {
|
|
635
|
-
frame?.phaseEnd("
|
|
684
|
+
frame?.phaseEnd("identity");
|
|
636
685
|
}
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
686
|
+
if (!valid) throw new RpcError("api.request: identité de session expirée ou invalide", JsonRpcServerError.DEFAULT, { status: 401 });
|
|
687
|
+
}
|
|
688
|
+
frame?.phaseStart("action");
|
|
689
|
+
try {
|
|
690
|
+
const { result: actionResult } = await resolver.executeActionGuarded(void 0, true);
|
|
691
|
+
const raw = await Promise.resolve(actionResult);
|
|
692
|
+
if (renderSink.body !== void 0) {
|
|
693
|
+
const text = Buffer.isBuffer(renderSink.body) ? renderSink.body.toString("utf8") : renderSink.body;
|
|
694
|
+
let body;
|
|
695
|
+
try {
|
|
696
|
+
body = JSON.parse(text);
|
|
697
|
+
} catch {
|
|
698
|
+
body = text;
|
|
699
|
+
}
|
|
700
|
+
const status = renderSink.status;
|
|
701
|
+
if (status !== void 0 && status >= 400) {
|
|
702
|
+
const error = isRecord(body) ? body.error : void 0;
|
|
703
|
+
throw new RpcError(typeof error === "string" ? error : `api.request: HTTP ${status}`, JsonRpcServerError.DEFAULT, {
|
|
704
|
+
status,
|
|
705
|
+
body
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
return body;
|
|
709
|
+
}
|
|
710
|
+
return raw;
|
|
711
|
+
} finally {
|
|
712
|
+
frame?.phaseEnd("action");
|
|
713
|
+
}
|
|
714
|
+
});
|
|
641
715
|
frame?.finish(200);
|
|
642
716
|
return frame ? new RpcEnvelope(result, { requestId: frame.requestId }) : result;
|
|
643
717
|
} catch (e) {
|
|
@@ -645,6 +719,7 @@ var RealtimeController = class extends Controller {
|
|
|
645
719
|
frame?.finish(status ?? 500, e);
|
|
646
720
|
throw toFrameRpcError(e, status, frame?.requestId);
|
|
647
721
|
} finally {
|
|
722
|
+
if (store !== null) RequestContext.release(store);
|
|
648
723
|
ctx.collectFrame(frame);
|
|
649
724
|
}
|
|
650
725
|
}
|
|
@@ -709,5 +784,4 @@ function handshakePath(ctx) {
|
|
|
709
784
|
return raw.charCodeAt(0) === 47 ? raw : "/";
|
|
710
785
|
}
|
|
711
786
|
}
|
|
712
|
-
//#endregion
|
|
713
787
|
export { RealtimeController, RealtimeController as default };
|