@nodefony/realtime 10.0.0-beta.5 → 10.0.0-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +55 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +91 -12
  4. package/dist/nodefony/config/config.js +7 -0
  5. package/dist/nodefony/decorators/realtimeDecorators.js +341 -43
  6. package/dist/nodefony/interfaces/IRealtimeConnection.js +1 -0
  7. package/dist/nodefony/src/backplane/ClusterBackplane.js +32 -4
  8. package/dist/nodefony/src/backplane/RedisBackplane.js +146 -41
  9. package/dist/nodefony/src/backplane/controlOrder.js +86 -0
  10. package/dist/nodefony/src/backplane/envelope.js +505 -27
  11. package/dist/nodefony/src/backplane/ingress.js +93 -0
  12. package/dist/nodefony/src/backplane/presenceMessage.js +205 -0
  13. package/dist/nodefony/src/backplane/publishQueue.js +97 -3
  14. package/dist/nodefony/src/cluster/ClusterProbeClient.js +2 -0
  15. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
  16. package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
  17. package/dist/nodefony/src/server/RealtimeConnection.js +135 -0
  18. package/dist/nodefony/src/server/RealtimeController.js +409 -107
  19. package/dist/nodefony/src/server/RealtimeHub.js +1158 -111
  20. package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
  21. package/dist/nodefony/src/server/admissionBudget.js +176 -0
  22. package/dist/nodefony/src/server/channelPolicyRegistry.js +95 -0
  23. package/dist/nodefony/src/server/channelPolicyResolution.js +191 -0
  24. package/dist/nodefony/src/server/channelPresence.js +488 -0
  25. package/dist/nodefony/src/server/connectionAdmission.js +242 -0
  26. package/dist/nodefony/src/server/contractRoutes.js +75 -0
  27. package/dist/nodefony/src/server/deniedDetail.js +10 -3
  28. package/dist/nodefony/src/server/frameAdmission.js +160 -0
  29. package/dist/nodefony/src/server/presenceBus.js +486 -0
  30. package/dist/nodefony/src/server/realtimeShutdown.js +31 -0
  31. package/dist/nodefony/src/server/remotePresence.js +541 -0
  32. package/dist/nodefony/src/server/subscribedAck.js +13 -1
  33. package/dist/nodefony/src/server/syslogUplink.js +3 -4
  34. package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
  35. package/dist/nodefony/src/service/RealtimeService.js +51 -1
  36. package/dist/nodefony/src/service/publicOrigin.js +40 -0
  37. package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
  38. package/dist/nodefony/testing/backplaneConformance.js +347 -0
  39. package/dist/nodefony/testing/index.js +43 -12
  40. package/dist/nodefony/testing/presenceClient.js +54 -0
  41. package/dist/types/index.d.ts +39 -7
  42. package/dist/types/nodefony/config/config.d.ts +9 -0
  43. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +198 -17
  44. package/dist/types/nodefony/interfaces/IBackplane.d.ts +46 -51
  45. package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
  46. package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
  47. package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
  48. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
  49. package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
  50. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
  51. package/dist/types/nodefony/interfaces/index.d.ts +2 -1
  52. package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
  53. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +89 -18
  54. package/dist/types/nodefony/src/backplane/controlOrder.d.ts +75 -0
  55. package/dist/types/nodefony/src/backplane/envelope.d.ts +77 -0
  56. package/dist/types/nodefony/src/backplane/ingress.d.ts +48 -0
  57. package/dist/types/nodefony/src/backplane/presenceMessage.d.ts +150 -0
  58. package/dist/types/nodefony/src/backplane/publishQueue.d.ts +25 -1
  59. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +10 -0
  60. package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
  61. package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +20 -0
  62. package/dist/types/nodefony/src/server/RealtimeController.d.ts +110 -12
  63. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +448 -42
  64. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
  65. package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
  66. package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +41 -0
  67. package/dist/types/nodefony/src/server/channelPolicyResolution.d.ts +112 -0
  68. package/dist/types/nodefony/src/server/channelPresence.d.ts +155 -0
  69. package/dist/types/nodefony/src/server/connectionAdmission.d.ts +68 -0
  70. package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
  71. package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
  72. package/dist/types/nodefony/src/server/frameAdmission.d.ts +66 -0
  73. package/dist/types/nodefony/src/server/presenceBus.d.ts +112 -0
  74. package/dist/types/nodefony/src/server/realtimeShutdown.d.ts +21 -0
  75. package/dist/types/nodefony/src/server/remotePresence.d.ts +151 -0
  76. package/dist/types/nodefony/src/server/subscribedAck.d.ts +10 -0
  77. package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
  78. package/dist/types/nodefony/src/service/RealtimeService.d.ts +17 -2
  79. package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
  80. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
  81. package/dist/types/nodefony/testing/backplaneConformance.d.ts +78 -0
  82. package/dist/types/nodefony/testing/index.d.ts +13 -2
  83. package/dist/types/nodefony/testing/presenceClient.d.ts +20 -0
  84. package/docs/actions.md +420 -18
  85. package/docs/architecture.md +101 -11
  86. package/docs/cadence-et-contre-pression.md +87 -11
  87. package/docs/canaux-a-membres.md +277 -0
  88. package/docs/cas-d-usage.md +1703 -0
  89. package/docs/configuration.md +175 -19
  90. package/docs/cookbook-chat.md +701 -224
  91. package/docs/index.md +11 -1
  92. package/docs/observabilite.md +97 -21
  93. package/docs/protocole.md +142 -33
  94. package/docs/securite.md +347 -41
  95. package/docs/vocabulaire.md +13 -12
  96. package/package.json +16 -13
package/docs/actions.md CHANGED
@@ -274,10 +274,11 @@ Le trajet complet d'une requête, des étapes qu'elle traverse aux branches par
274
274
 
275
275
  ### Deux voies, un même registre
276
276
 
277
- | Voie | Quand la choisir |
278
- | -------------------------------------------------------------------------------- | ----------------------------------------------------------- |
279
- | `@RealtimeAction("nom")` (`realtimeDecorators.ts#RealtimeAction`) | le cas courant — un nom fixe, une méthode, déclaratif |
280
- | `realtimeActions()` (`RealtimeController.ts#RealtimeController.realtimeActions`) | la table est **calculée** (noms dynamiques, boucle, config) |
277
+ | Voie | Quand la choisir |
278
+ | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
279
+ | `@RealtimeAction("nom")` (`realtimeDecorators.ts#RealtimeAction`) | le cas courant — un nom fixe, une méthode, déclaratif |
280
+ | `realtimeActions()` (`RealtimeController.ts#RealtimeController.realtimeActions`) | la table est **calculée** (noms dynamiques, boucle, config) |
281
+ | `realtimeActions()` **complète** d'un `ContractRealtimeController<C>` | le contrôleur implémente un **contrat** : une méthode oubliée ne compile pas (voir « Un contrat pour les deux bouts ») |
281
282
 
282
283
  Les deux sont fusionnées au handshake, et **l'override gagne** en cas de conflit de nom : une
283
284
  classe peut ainsi remplacer une action héritée sans toucher au parent
@@ -304,6 +305,347 @@ Trust stricte, appliquée dans `JsonRpcPeer.handleRequest()` (`JsonRpcPeer.ts#Js
304
305
  > chercher d'erreur maison dans `@nodefony/realtime` : il n'y en a pas, et
305
306
  > `tests/unit/errorSurface.test.ts` refuse d'en exporter une que personne ne lève.
306
307
 
308
+ ## 🤝 Un contrat pour les deux bouts
309
+
310
+ Sans contrat, chaque côté type ce qu'il **envoie**, jamais ce que l'autre **reçoit** ou
311
+ **implémente**. Le navigateur appelle `request("orders:quote", …)` ; le serveur peut n'avoir
312
+ aucune action de ce nom, ou lui donner une autre forme — tout compile, et l'erreur sort en
313
+ production (`-32601`, ou une réponse de forme fausse). Un **contrat** se déclare UNE fois, et le
314
+ compilateur vérifie les deux bouts.
315
+
316
+ ### Le contrat, les deux portes, le flux SSE
317
+
318
+ Le contrat est une interface de **types seuls** : son fichier, partagé par les deux côtés,
319
+ compile en zéro octet, et le navigateur qui l'importe ne tire aucun code serveur. Chaque côté
320
+ entre ensuite par **sa** porte, et la direction (ce que j'envoie, ce que je reçois) est
321
+ **calculée** — jamais réécrite à la main.
322
+
323
+ ```ts
324
+ // ── shared/orders.ts — le contrat, déclaré UNE fois (types seuls) ──────────
325
+ import type { IRealtimeContract } from "nodefony/client";
326
+
327
+ export interface OrdersContract extends IRealtimeContract {
328
+ // ce que le serveur pousse (socket, flux SSE, webhook)
329
+ serverToClient: {
330
+ "orders:status": { orderId: string; at: Date };
331
+ [channel: `orders:user:${string}`]: { unread: number };
332
+ };
333
+ // ce que le client envoie sur un canal
334
+ clientToServer: { "orders:typing": { on: boolean } };
335
+ // les méthodes du serveur : le client appelle, le serveur répond
336
+ serverMethods: {
337
+ "orders:quote": { in: { orderId: string }; out: { total: number } };
338
+ };
339
+ // les méthodes du client : le serveur appelle, le client répond
340
+ clientMethods: {
341
+ "ui:confirm": { in: { text: string }; out: { ok: boolean } };
342
+ };
343
+ }
344
+
345
+ // ── frontend/src/orders.ts — la porte du CLIENT ─────────────────────────────
346
+ import { NodefonySocket } from "nodefony/client";
347
+
348
+ const socket = NodefonySocket.fromContract<OrdersContract>({
349
+ url: "/orders/realtime",
350
+ });
351
+ socket.on("orders:status", (m) => console.log(m.orderId, m.at)); // m.at : string (JSON)
352
+ const quote = await socket.request("orders:quote", { orderId: "A-1" }); // { total: number }
353
+ socket.register("ui:confirm", (p) => ({ ok: p.text.length > 0 }));
354
+ void quote;
355
+
356
+ // ── nodefony/controllers/OrdersRealtime.ts — la porte du SERVEUR ────────────
357
+ import type { ContextType } from "@nodefony/http";
358
+ import type { ContractServerEmit } from "nodefony";
359
+ import { ContractRealtimeController, RealtimePolicy } from "@nodefony/realtime";
360
+
361
+ @RealtimePolicy({ "orders:quote": { roles: ["ROLE_USER"] } })
362
+ export class OrdersRealtime extends ContractRealtimeController<OrdersContract> {
363
+ constructor(context: ContextType) {
364
+ super("orders", context);
365
+ }
366
+
367
+ // TOUTES les méthodes du contrat, et cette table SEULE les sert.
368
+ protected realtimeActions() {
369
+ return {
370
+ "orders:quote": (p: { orderId: string }) => ({
371
+ total: p.orderId.length * 100,
372
+ }),
373
+ };
374
+ }
375
+
376
+ // Facultatif, canal par canal : accepter une entrée est une permission.
377
+ protected override realtimeInbound() {
378
+ return {
379
+ "orders:typing": (p: { on: boolean }) => {
380
+ void p.on; // NON FIABLE : le type n'est pas une validation
381
+ },
382
+ };
383
+ }
384
+
385
+ // Le flux SSE lit la MÊME carte que la socket.
386
+ async statusStream(): Promise<void> {
387
+ const sse = await this.renderSse<ContractServerEmit<OrdersContract>>();
388
+ await sse.send(
389
+ { orderId: "A-1", at: new Date() },
390
+ { event: "orders:status" },
391
+ );
392
+ sse.close();
393
+ }
394
+ }
395
+ ```
396
+
397
+ | Porte | Ce qu'elle dérive du contrat |
398
+ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
399
+ | `NodefonySocket.fromContract<C>()` (client) | émet `clientToServer` · reçoit `serverToClient` · appelle `serverMethods` · implémente `clientMethods` |
400
+ | `extends ContractRealtimeController<C>` (serveur) | émet `serverToClient` · reçoit `clientToServer` · implémente `serverMethods` · appelle `clientMethods` (`requestClient`) |
401
+ | `getRealtimeHub<C>()` (service, tâche, jeu) | `publish` n'accepte que les canaux de `serverToClient`, avec leur charge |
402
+ | `ContractRealtimePublish<C>` (fabrique de canal) | le `publish` reçu par `@RealtimeChannel` / `createRealtimeChannel`, même règle |
403
+ | `renderSse<ContractServerEmit<C>>()` (serveur) | `event:` = un canal de `serverToClient`, la charge est celle du canal |
404
+ | `@RealtimePolicy({ … })` (serveur) | chaque nom doit être une méthode ou un canal du contrat |
405
+
406
+ Les quatre clés sont **facultatives** : un tableau de bord ne déclare que `serverToClient`, et son
407
+ contrôleur n'écrit rien de plus que `extends ContractRealtimeController<C> {}` — la table des
408
+ méthodes n'est due que si le contrat déclare des `serverMethods`. Une direction non déclarée reste
409
+ libre.
410
+
411
+ ### Diffuser depuis un service — le même contrat
412
+
413
+ Le cas le plus courant ne passe par aucun contrôleur : un service, une tâche, une boucle de jeu
414
+ publient. Le hub se lit à travers le contrat, et une faute de frappe ne compile plus :
415
+
416
+ ```ts
417
+ import { getRealtimeHub } from "@nodefony/realtime";
418
+ import type { ChatContract } from "../shared/chat";
419
+
420
+ const chat = getRealtimeHub<ChatContract>(); // même objet hub : 0 octet, 0 ns de plus
421
+ chat.publish("chat:room", message); // charge vérifiée par le contrat
422
+ getRealtimeHub().publish("n'importe:quoi", { x: 1 }); // sans paramètre : libre, comme avant
423
+ ```
424
+
425
+ Une fabrique de canal déclare son `publish` de la même façon :
426
+ `presence(_channel: string, publish: ContractRealtimePublish<ChatContract>)`. Le contrat est choisi
427
+ par l'appelant : c'est le fichier partagé qui fait foi.
428
+
429
+ > [!NOTE]
430
+ > **Lire les noms.** `Contract…<C>` est la même chose que sa forme libre, lue à travers le contrat :
431
+ > `RealtimeController` → `ContractRealtimeController<C>`, `RealtimePublish` →
432
+ > `ContractRealtimePublish<C>`, `RealtimeHub` → `IContractRealtimeHub<C>`. `Typed…` type un handler
433
+ > par ses types directs ; `…Of<X>` lit ce que porte une classe (`RealtimeContractOf`).
434
+
435
+ ### Deux cas réels — l'atelier et la partie
436
+
437
+ **L'atelier : une mesure typée une fois, trois lecteurs.** Chaque machine pousse sa télémétrie
438
+ sur son propre canal (`machine:<id>`). Le tableau de bord la lit par la socket, l'écran d'atelier
439
+ — un simple navigateur, sans socket — par un flux SSE, et l'opérateur peut arrêter une machine.
440
+ Un seul contrat, et la charge est la MÊME aux trois endroits.
441
+
442
+ ```ts
443
+ // ── shared/plant.ts ─────────────────────────────────────────────────────────
444
+ import type { IRealtimeContract } from "nodefony/client";
445
+
446
+ export interface PlantContract extends IRealtimeContract {
447
+ serverToClient: {
448
+ [machine: `machine:${string}`]: {
449
+ temperature: number;
450
+ rpm: number;
451
+ at: Date;
452
+ };
453
+ };
454
+ serverMethods: {
455
+ "machine:stop": {
456
+ in: { machineId: string; reason: string };
457
+ out: { stopped: boolean };
458
+ };
459
+ };
460
+ }
461
+
462
+ // ── frontend/src/dashboard.ts — le tableau de bord ──────────────────────────
463
+ import { NodefonySocket } from "nodefony/client";
464
+
465
+ const plant = NodefonySocket.fromContract<PlantContract>({
466
+ url: "/plant/realtime",
467
+ });
468
+ plant.on("machine:press-07", (m) => {
469
+ if (m.temperature > 90) {
470
+ void plant.request("machine:stop", {
471
+ machineId: "press-07",
472
+ reason: "surchauffe",
473
+ });
474
+ }
475
+ });
476
+
477
+ // ── nodefony/controllers/PlantRealtime.ts — le serveur ──────────────────────
478
+ import type { ContextType } from "@nodefony/http";
479
+ import type { ContractServerEmit } from "nodefony";
480
+ import { ContractRealtimeController, RealtimePolicy } from "@nodefony/realtime";
481
+
482
+ @RealtimePolicy({
483
+ "machine:stop": { roles: ["ROLE_OPERATOR"] }, // arrêter une machine : un rôle
484
+ "machine:*": { authenticated: true }, // lire la télémétrie : être connecté
485
+ })
486
+ export class PlantRealtime extends ContractRealtimeController<PlantContract> {
487
+ constructor(context: ContextType) {
488
+ super("plant", context);
489
+ }
490
+
491
+ protected realtimeActions() {
492
+ return {
493
+ "machine:stop": (p: { machineId: string; reason: string }) => ({
494
+ stopped: p.machineId.length > 0 && p.reason.length > 0,
495
+ }),
496
+ };
497
+ }
498
+
499
+ // L'écran d'atelier : UN flux = UN canal, sans champ `event:`.
500
+ async machineStream(machineId: string): Promise<void> {
501
+ const sse =
502
+ await this.renderSse<
503
+ Pick<ContractServerEmit<PlantContract>, `machine:${string}`>
504
+ >();
505
+ await sse.send({ temperature: 71, rpm: 1450, at: new Date() });
506
+ void machineId;
507
+ sse.close();
508
+ }
509
+ }
510
+ ```
511
+
512
+ **La partie : les entrées du joueur, typées jusque dans le serveur.** Le client envoie ses
513
+ déplacements soixante fois par seconde ; le serveur les reçoit typés, sans jamais les croire, et
514
+ pousse l'état de la partie. Aucune méthode n'est demandée au serveur : la table est vide.
515
+
516
+ ```ts
517
+ // ── shared/arena.ts ─────────────────────────────────────────────────────────
518
+ import type { IRealtimeContract } from "nodefony/client";
519
+
520
+ export interface ArenaContract extends IRealtimeContract {
521
+ clientToServer: { "arena:input": { dx: number; dy: number; seq: number } };
522
+ serverToClient: {
523
+ "arena:state": {
524
+ tick: number;
525
+ players: Array<{ id: string; x: number; y: number }>;
526
+ };
527
+ };
528
+ }
529
+
530
+ // ── frontend/src/arena.ts — le joueur ───────────────────────────────────────
531
+ import { NodefonySocket } from "nodefony/client";
532
+
533
+ const arena = NodefonySocket.fromContract<ArenaContract>({
534
+ url: "/arena/realtime",
535
+ });
536
+ let seq = 0;
537
+ arena.publish("arena:input", { dx: 1, dy: 0, seq: ++seq });
538
+ arena.on("arena:state", (s) => console.log(s.tick, s.players.length));
539
+
540
+ // ── nodefony/controllers/ArenaRealtime.ts — le serveur ──────────────────────
541
+ import type { ContextType } from "@nodefony/http";
542
+ import { ContractRealtimeController } from "@nodefony/realtime";
543
+
544
+ const MAX_STEP = 1;
545
+
546
+ export class ArenaRealtime extends ContractRealtimeController<ArenaContract> {
547
+ constructor(context: ContextType) {
548
+ super("arena", context);
549
+ }
550
+
551
+ // Aucune méthode serveur dans ce contrat : aucune table à écrire.
552
+
553
+ // L'entrée est TYPÉE, mais vient du réseau : bornée avant d'être appliquée.
554
+ protected override realtimeInbound() {
555
+ return {
556
+ "arena:input": (input: { dx: number; dy: number; seq: number }) => {
557
+ const dx = Math.max(-MAX_STEP, Math.min(MAX_STEP, input.dx));
558
+ const dy = Math.max(-MAX_STEP, Math.min(MAX_STEP, input.dy));
559
+ this.notifyClient("arena:state", {
560
+ tick: input.seq,
561
+ players: [{ id: "p1", x: dx, y: dy }],
562
+ });
563
+ },
564
+ };
565
+ }
566
+ }
567
+ ```
568
+
569
+ Le serveur d'une vraie partie ne pousse évidemment pas l'état à chaque entrée : il les accumule et
570
+ diffuse l'état à cadence fixe, par un canal partagé — voir
571
+ [`cadence-et-contre-pression.md`](./cadence-et-contre-pression.md).
572
+
573
+ ### Ce que chaque bout reçoit — ce que JSON en fait
574
+
575
+ Ce que j'**envoie** est le type déclaré ; ce que je **reçois** est `Jsonify<T>` : ce que JSON rend
576
+ de ce type, quel que soit le codec. Une `Date` arrive en `string`, un membre fonction ou
577
+ `undefined` disparaît, `bigint` n'a pas de forme JSON. Seul `Uint8Array` reste `Uint8Array` — en
578
+ chaîne d'octets native sous le codec CBOR, sous la convention `{"$bytes":"<base64url>"}` sur une
579
+ connexion texte dont l'application a activé le binaire, annoncée par le champ `bytes` de l'accueil :
580
+ un client sans codec les relit aussi en `Uint8Array` (ADR-0022 §2).
581
+
582
+ | Qui reçoit | Quoi | Type reçu |
583
+ | -------------------------------------------------------- | ---------------- | ------------ |
584
+ | le client, `on(…)` | `serverToClient` | `Jsonify<T>` |
585
+ | le serveur, entrée d'un canal | `clientToServer` | `Jsonify<T>` |
586
+ | qui **implémente** une méthode | son `in` | `Jsonify<T>` |
587
+ | qui **appelle** une méthode (`request`, `requestClient`) | son `out` | `Jsonify<T>` |
588
+ | un composant, hook de canal (socket en tête) | `serverToClient` | `Jsonify<T>` |
589
+
590
+ `serverToClient: { meeting: { start: Date } }` : sans cette règle, `m.start.getTime()` compilait
591
+ et plantait au navigateur ; avec elle, `m.start` est une `string` et l'éditeur le souligne.
592
+
593
+ ### Lire dans une vue — le hook prend la socket du contrat
594
+
595
+ Dans React, Vue, Svelte ou Angular, la lecture passe par la **même** socket typée, donnée en
596
+ premier argument du hook de canal. Le nom est contraint aux canaux de `serverToClient`, la charge
597
+ est déduite — aucun générique écrit à la main, aucune carte recopiée.
598
+
599
+ ```ts
600
+ export const chat = NodefonySocket.fromContract<ChatContract>({
601
+ url: "/chat/realtime",
602
+ });
603
+
604
+ const presence = useNodefonyChannelData(chat, "chat:presence", { present: [] }); // React, Vue
605
+ useNodefonyChannel(chat, "chat:room", (m) => add(m)); // m : Jsonify<ChatMessage>
606
+ nodefonyChannelData(chat, "chat:presence"); // Svelte
607
+ injectNodefonyChannelData(chat, "chat:presence"); // Angular
608
+ ```
609
+
610
+ Les hooks lisent les cartes de `NodefonySocket.on` (`client/realtime/observe.ts#ChannelPayload`) :
611
+ une seule règle, quatre liaisons. La socket reste celle de l'application — le hook ne l'ouvre ni
612
+ ne la ferme. La forme historique `useNodefonyChannelData<T>("canal")` reste valide, mais son `<T>`
613
+ n'est vérifié par rien : une charge qui change côté serveur n'y casse aucune compilation.
614
+
615
+ ### Ce que le compilateur refuse
616
+
617
+ | Faute | Où elle est refusée |
618
+ | -------------------------------------------------------------------- | -------------------------------------------------------------------- |
619
+ | une méthode de `serverMethods` **oubliée** | `realtimeActions()` du contrôleur |
620
+ | un handler de **forme fausse** (paramètres ou résultat) | `realtimeActions()` / `realtimeInbound()` |
621
+ | un nom de **politique** hors contrat (`"orders:qoute"`) | `@RealtimePolicy` — l'erreur affiche `notInContract: "orders:qoute"` |
622
+ | un canal émis **hors** `serverToClient` | `notifyClient`, `realtimeChannels()`, `renderSse` |
623
+ | une entrée acceptée sur un canal que le **serveur** émet (inversion) | `realtimeInbound()` |
624
+ | une charge fausse sur un canal ou une méthode connus | client et serveur |
625
+ | une diffusion de service hors contrat (nom ou charge) | `getRealtimeHub<C>().publish`, `ContractRealtimePublish<C>` |
626
+ | une charge **scalaire** (`3`, `"ok"`) — refusée aussi à l'exécution | `getRealtimeHub<C>().publish`, `ContractRealtimePublish<C>` |
627
+ | un canal lu **hors** `serverToClient` dans une vue | hooks de canal React, Vue, Svelte, Angular (socket en tête) |
628
+
629
+ > [!IMPORTANT]
630
+ > **La table sert SEULE les méthodes d'un contrat — c'est une règle de performance.** N'ajoute pas
631
+ > `@RealtimeAction` à une méthode déjà servie par la table : au handshake, le décorateur fabrique
632
+ > un handler que la table écrase aussitôt, à **chaque connexion**, avec un appel de plus par
633
+ > requête, et l'injection des paramètres décorés (`@Body`…) saute sans un mot. La politique d'une
634
+ > méthode se pose sur la classe, par `@RealtimePolicy`, écrite une seule fois. Des handlers qui
635
+ > n'ont pas besoin de `this` se rangent dans une table **constante** du module, rendue telle
636
+ > quelle : aucune allocation par connexion.
637
+
638
+ À savoir encore :
639
+
640
+ - **Le type n'est pas une validation.** Les paramètres reçus restent NON FIABLES ; la garde de
641
+ canal (politique, `allow`) s'applique à toutes les livraisons.
642
+ - **Côté client, les noms de canaux restent ouverts** (canaux dynamiques, de plateforme) :
643
+ `publish` et `on` acceptent tout nom, et typent la charge d'un nom déclaré.
644
+ - **`realtimeChannels()` surchargée** : TypeScript ne donne pas son type de retour à une méthode
645
+ qui surcharge ; écrire `["orders:status" as const]` ou annoter le retour.
646
+ - **Un `renderSse()` sans paramètre** reste un flux libre (la porte MCP en est un).
647
+ - **Le webhook** lira la même carte quand l'émission applicative existera (en 10.1, #70).
648
+
307
649
  ## 🔐 Autorisation — qui peut appeler quoi
308
650
 
309
651
  > [!IMPORTANT]
@@ -339,7 +681,24 @@ browse() { … }
339
681
  ```
340
682
 
341
683
  Ouvrir une action reste donc possible — mais c'est désormais un acte **délibéré et lisible à la
342
- déclaration**, au lieu d'un silence. Si une politique est déjà déclarée sous le même nom (canal
684
+ déclaration**, au lieu d'un silence.
685
+
686
+ Une action servie par la **table** de `realtimeActions()` n'a pas de décorateur où écrire sa
687
+ politique : elle prend le même défaut fermé, et sa politique se pose **sur la classe**, par
688
+ `@RealtimePolicy` (`realtimeDecorators.ts#RealtimePolicy`) — écrite une fois, à la définition de
689
+ la classe, dans le même registre :
690
+
691
+ ```ts ignore
692
+ @RealtimePolicy({
693
+ "orders:quote": { roles: ["ROLE_SALES"] },
694
+ "orders:user:*": { authenticated: true }, // un motif garde une famille de canaux
695
+ })
696
+ class OrdersRealtime extends ContractRealtimeController<OrdersContract> { … }
697
+ ```
698
+
699
+ Sur un contrôleur de contrat, `@RealtimePolicy` lit le contrat sur la classe : un nom mal
700
+ orthographié ne compile pas, au lieu de garder un canal fantôme pendant que le vrai reste au
701
+ défaut. Si une politique est déjà déclarée sous le même nom (canal
343
702
  homonyme), elle est conservée : le défaut ne rétrograde jamais une règle plus stricte.
344
703
 
345
704
  ### Situation 2 — les namespaces réservés portent un plancher
@@ -406,7 +765,7 @@ rien ne change pour elle, ni comportement ni coût.
406
765
  > ferme la socket en `4001` si la session est morte (`RealtimeHub.ts#RealtimeHub.probe`). Après une
407
766
  > déconnexion, une action peut donc encore voir l'ancien utilisateur pendant cette fenêtre. Pour
408
767
  > une écriture sensible — paiement, droits, suppression — passe par une route
409
- > `POST + WEBSOCKET` appelée par `socket.mutate()` : ce pont revalide l'identité à **chaque**
768
+ > `POST + WEBSOCKET` appelée par `socket.post()` : ce pont revalide l'identité à **chaque**
410
769
  > appel et porte la clé d'idempotence.
411
770
 
412
771
  ### La règle DEV-only
@@ -472,21 +831,23 @@ fois ? »**.
472
831
  | ------------------------------ | ------------------------------ | ------------------------------------ |
473
832
  | `nodefony:kernel:ping` | oui — lecture pure | oui |
474
833
  | `nodefony:kernel:gc` | oui — l'effet est un cycle GC | oui, mais coûteux (pause du process) |
834
+ | `nodefony:realtime:throughput` | oui — charge déterministe | oui, mais jusqu'à 1 Mo par appel |
475
835
  | `api.request` en lecture | oui — c'est un `GET` | oui |
476
836
  | `nodefony:scaffold:cancel` | oui — annuler deux fois annule | oui |
477
837
  | `nodefony:scaffold:run` | **non** — crée un travail | **non** : deux appels, deux jobs |
478
- | une mutation (`socket.mutate`) | **non** par nature | oui, **avec une clé d'idempotence** |
838
+ | une écriture (`socket.post`) | **non** par nature | oui, **avec une clé d'idempotence** |
479
839
 
480
- Pour les mutations passant par le pont API, la clé n'est pas une convention : elle est **exigée
481
- par la signature** de `mutate()` (`NodefonySocket.ts#NodefonySocket.mutate`), et c'est la garde `@Idempotent`
840
+ Pour les écritures passant par le pont API, la clé n'est pas une convention : la socket la **pose
841
+ seule** sur chaque `post`/`put`/`patch`/`delete` (`NodefonySocket.ts#NodefonySocket.post`), la
842
+ trame rejouée après une reconnexion porte la même, et c'est la garde `@Idempotent`
482
843
  (`routerDecorators.ts#Idempotent`) qui, côté serveur, reconnaît le rejeu et rend la réponse déjà calculée
483
844
  au lieu de refaire l'effet.
484
845
 
485
846
  ```ts ignore
486
- await socket.mutate("/nodefony/security/api/apikeys/42/revoke", {
487
- method: "POST",
488
- idempotencyKey: crypto.randomUUID(), // rejouer cette frame ne révoque qu'une fois
489
- });
847
+ // rejouer cette trame ne révoque qu'une fois — la clé est posée par la socket
848
+ await socket.post("/nodefony/security/api/apikeys/42/revoke");
849
+ // dédoublonner une INTENTION (double clic) : l'écran fournit sa clé
850
+ await socket.post("/orders", panier, { idempotencyKey: panier.id });
490
851
  ```
491
852
 
492
853
  > [!IMPORTANT]
@@ -548,13 +909,53 @@ socket, avec la même garde et le même résultat qu'en REST — `invokeApiReque
548
909
  `realtimeApiRequest()` (`RealtimeController.ts#RealtimeController.realtimeApiRequest`).
549
910
 
550
911
  ```ts ignore
551
- const modules = await socket.request("/nodefony/kernel/api/modules");
912
+ const modules = await socket.get("/nodefony/kernel/api/modules");
913
+ const { sentAt } = await socket.post("/chat/send", { text: "salut" });
914
+ ```
915
+
916
+ Chaque méthode porte le **verbe HTTP** que la route déclare — `get`, `post`, `put`, `patch`,
917
+ `delete` (`NodefonySocket.ts#NodefonySocket.get`) : une route `methods: ["POST", "WEBSOCKET"]` s'appelle
918
+ `socket.post(…)`, rien d'autre à apprendre. `request()` reste l'appel d'une **action RPC** par son nom.
919
+ Écris une action RPC pour ce qui n'a de sens **que** sur la socket ; passe par le pont pour tout ce
920
+ qui est déjà une route. Le détail du pont vit dans le [vocabulaire](./vocabulaire.md) et
921
+ l'[architecture](./architecture.md).
922
+
923
+ ### Le contrat type les routes
924
+
925
+ Sans contrat, `get` et `post` rendent `unknown` et acceptent n'importe quel corps. Le contrat de
926
+ l'application (`IRealtimeContract`, `RealtimeEventMap.ts#IRealtimeContract`) les type par la clé
927
+ `serverRoutes` : `"<VERBE> <chemin>"` → le schéma que la route **valide**, référencé par `typeof`
928
+ (une seule source — jamais une copie), et ce qu'elle rend.
929
+
930
+ ```ts ignore
931
+ // shared/chatContract.ts — types seuls, aucun octet dans le navigateur
932
+ import type { SendBody } from "./chatSchemas";
933
+ export interface ChatContract extends IRealtimeContract {
934
+ serverRoutes: {
935
+ "POST /chat/send": { body: typeof SendBody; result: { sentAt: number } };
936
+ };
937
+ }
938
+
939
+ // navigateur
940
+ const chat = NodefonySocket.fromContract<ChatContract>({
941
+ url: "/chat/realtime",
942
+ });
943
+ await chat.post("/chat/send", { text: "salut" }); // { sentAt: number }
944
+ await chat.post("/chat/send", { texte: "x" }); // ✗ ne compile pas
552
945
  ```
553
946
 
554
- La forme se discrimine toute seule : un chemin commence par `/`, jamais un nom d'action —
555
- `NodefonySocket.request()` (`NodefonySocket.ts#NodefonySocket.request`). Écris une action RPC pour ce qui n'a de sens **que** sur la socket ;
556
- passe par le pont pour tout ce qui est déjà une route. Le détail du pont vit dans le
557
- [vocabulaire](./vocabulaire.md) et l'[architecture](./architecture.md).
947
+ Le contrat est un **type** : il ferait compiler un appel vers une route absente. Le contrôleur qui
948
+ l'implémente déclare donc la **valeur** — la table statique `serverRoutes`, `satisfies
949
+ ContractRouteSchemas<C>` — et le démarrage la confronte aux routes montées
950
+ (`contractRoutes.ts#assertContractRoutes`) : route absente, route sans transport `WEBSOCKET` pour ce
951
+ verbe, ou autre schéma que celui que la route valide → démarrage refusé, l'entrée nommée. Un
952
+ chemin hors contrat reste libre. Une route à **paramètre de chemin** se déclare par son gabarit
953
+ (`"GET /chat/messages/{id}": { params: typeof IdParams; result }`) et s'appelle en le nommant :
954
+ `chat.get("/chat/messages/{id}", { params: { id } })` — la socket encode chaque valeur pour son
955
+ segment (`runtime/routePath.ts#fillRoutePath`) et refuse une valeur manquante, vide, `.` ou `..`.
956
+ Seuls des segments `{nom}` entiers se déclarent (`routePath.ts#isSimpleRouteTemplate`) : une
957
+ contrainte passe par `requirements`. Le chat complet, messages privés compris :
958
+ [cookbook](./cookbook-chat.md#quatre--un-contrat-pour-les-deux-côtés--lenvoi-et-les-messages-privés-typés).
558
959
 
559
960
  ## ⚠️ Pièges
560
961
 
@@ -565,6 +966,7 @@ passe par le pont pour tout ce qui est déjà une route. Le détail du pont vit
565
966
  | `-32601 method not found` | nom mal orthographié, ou action déclarée sur un **autre** endpoint | vérifier `socket.serverMethods` — c'est la liste réelle de CETTE connexion |
566
967
  | `-32603 internal error` sans détail | un `throw` ordinaire est rendu opaque au client (Zero Trust) | lever une `RpcError` avec un code et un message publiables |
567
968
  | `api.request` rejeté avec `data.status: 422` | la route invoquée valide son entrée (`schema`, ou une `ZodError` de l'action) | lire `data.fields` : chaque champ fautif, sa partie (`in`) et son message |
969
+ | `api.request` rejeté avec `data.status: 429` | la route borne le débit de l'appelant | attendre `data.retryAfter` secondes (l'en-tête `Retry-After` de la même route en HTTP) |
568
970
  | `-32001 unauthorized` sur une action légitime | le nom commence par `nodefony:`, le namespace réservé à la plateforme | renommer hors de `nodefony:`, ou obtenir `ROLE_NODEFONY_ADMIN` |
569
971
  | Une action est refusée à un anonyme alors qu'on la croyait publique | une action est fermée par défaut (`{ authenticated: true }`) | déclarer `{ authenticated: false }` pour l'ouvrir ; un rôle se pose par une règle de préfixe |
570
972
  | Un travail relancé crée deux jobs | action non idempotente rejouée après une reconnexion | action compagnon d'annulation, ou identifiant fourni par l'appelant + mémorisation |