@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.
Files changed (50) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  2. package/dist/index.js +4 -6
  3. package/dist/nodefony/config/config.js +1 -2
  4. package/dist/nodefony/config/configSchema.js +1 -2
  5. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  6. package/dist/nodefony/decorators/realtimeDecorators.js +1 -2
  7. package/dist/nodefony/interfaces/IRealtimeConnectionState.js +1 -0
  8. package/dist/nodefony/src/backplane/ClusterBackplane.js +1 -2
  9. package/dist/nodefony/src/backplane/LoopbackBackplane.js +1 -2
  10. package/dist/nodefony/src/backplane/RedisBackplane.js +1 -2
  11. package/dist/nodefony/src/backplane/backplaneRegistry.js +1 -2
  12. package/dist/nodefony/src/backplane/bootNotices.js +1 -2
  13. package/dist/nodefony/src/backplane/envelope.js +1 -2
  14. package/dist/nodefony/src/backplane/originId.js +1 -2
  15. package/dist/nodefony/src/backplane/publishQueue.js +0 -1
  16. package/dist/nodefony/src/cluster/ClusterProbeClient.js +1 -2
  17. package/dist/nodefony/src/guards.js +1 -2
  18. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +1 -2
  19. package/dist/nodefony/src/server/RealtimeAdminApi.js +1 -2
  20. package/dist/nodefony/src/server/RealtimeController.js +154 -80
  21. package/dist/nodefony/src/server/RealtimeHub.js +3 -3
  22. package/dist/nodefony/src/server/ServerRealtimeSocket.js +18 -9
  23. package/dist/nodefony/src/server/deniedDetail.js +1 -2
  24. package/dist/nodefony/src/server/subscribedAck.js +29 -0
  25. package/dist/nodefony/src/server/syslogUplink.js +1 -2
  26. package/dist/nodefony/src/server/welcomeEnv.js +1 -2
  27. package/dist/nodefony/src/service/RealtimeService.js +1 -2
  28. package/dist/nodefony/src/transport/WsConnectionTransport.js +42 -2
  29. package/dist/nodefony/testing/index.js +77 -4
  30. package/dist/types/index.d.ts +1 -0
  31. package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +40 -0
  32. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +13 -0
  33. package/dist/types/nodefony/interfaces/index.d.ts +1 -0
  34. package/dist/types/nodefony/src/server/RealtimeController.d.ts +58 -0
  35. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +18 -8
  36. package/dist/types/nodefony/src/server/subscribedAck.d.ts +23 -0
  37. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +26 -1
  38. package/dist/types/nodefony/testing/index.d.ts +6 -1
  39. package/docs/actions.md +44 -43
  40. package/docs/architecture.md +125 -119
  41. package/docs/cadence-et-contre-pression.md +31 -22
  42. package/docs/configuration.md +106 -82
  43. package/docs/cookbook-chat.md +37 -34
  44. package/docs/index.md +35 -24
  45. package/docs/observabilite.md +49 -49
  46. package/docs/protocole.md +91 -68
  47. package/docs/securite.md +125 -114
  48. package/docs/vocabulaire.md +117 -114
  49. package/package.json +10 -10
  50. package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
@@ -1,9 +1,8 @@
1
- //#region \0@oxc-project+runtime@0.153.0/helpers/esm/decorate.js
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 __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js";
21
- import __decorate from "./_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorate.js";
22
- import { IDENTITY_CHANGED_EVENT, Kernel, Module, services, withTimeout } from "nodefony";
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]), __decorateMetadata("design:paramtypes", [typeof Kernel === "undefined" ? Object : Kernel])], Realtime);
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
- //#region nodefony/config/config.ts
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
- //#region nodefony/config/configSchema.ts
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
- //#region nodefony/config/defineModuleConfig.ts
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
- //#region nodefony/decorators/realtimeDecorators.ts
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 };
@@ -1,6 +1,6 @@
1
1
  import resolveBackplaneOriginId from "./originId.js";
2
2
  import { CLUSTER_RT_KIND } from "nodefony";
3
- //#region nodefony/src/backplane/ClusterBackplane.ts
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
- //#region nodefony/src/backplane/LoopbackBackplane.ts
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
- //#region nodefony/src/backplane/RedisBackplane.ts
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
- //#region nodefony/src/backplane/backplaneRegistry.ts
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
- //#region nodefony/src/backplane/bootNotices.ts
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
- //#region nodefony/src/backplane/envelope.ts
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
- //#region nodefony/src/backplane/originId.ts
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 };
@@ -89,5 +89,4 @@ var BackplanePublishQueue = class {
89
89
  };
90
90
  }
91
91
  };
92
- //#endregion
93
92
  export { BackplanePublishQueue, BackplanePublishQueue as default, DEFAULT_MAX_QUEUE_BYTES };
@@ -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
- //#region nodefony/src/cluster/ClusterProbeClient.ts
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
- //#region nodefony/src/guards.ts
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
- //#region nodefony/src/server/AnonymousRealtimeToken.ts
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
- //#region nodefony/src/server/RealtimeAdminApi.ts
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
- //#region nodefony/src/server/RealtimeController.ts
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.context;
211
- if (!ctx) return;
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?.transport.feed(message.toString());
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.context.__nfRealtime;
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.request(method, params, timeoutMs);
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.context.__nfRealtime?.peer.notify(method, params);
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
- const holder = ctx;
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
- holder.__nfRealtime = state;
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 || state.channels.has(channel)) return;
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 store = {
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
- let result;
609
- try {
610
- result = await RequestContext.run(store, async () => {
611
- frame?.phaseStart("action");
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
- const { result: actionResult } = await resolver.executeActionGuarded(void 0, true);
614
- const raw = await Promise.resolve(actionResult);
615
- if (renderSink.body !== void 0) {
616
- const text = Buffer.isBuffer(renderSink.body) ? renderSink.body.toString("utf8") : renderSink.body;
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("action");
684
+ frame?.phaseEnd("identity");
636
685
  }
637
- });
638
- } finally {
639
- RequestContext.release(store);
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 };