@nodefony/realtime 10.0.0-beta.4 → 10.0.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +79 -10
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +79 -12
  4. package/dist/nodefony/config/config.js +7 -0
  5. package/dist/nodefony/decorators/realtimeDecorators.js +125 -19
  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 +143 -31
  9. package/dist/nodefony/src/backplane/controlOrder.js +57 -0
  10. package/dist/nodefony/src/backplane/envelope.js +113 -12
  11. package/dist/nodefony/src/cluster/ClusterProbeClient.js +2 -0
  12. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
  13. package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
  14. package/dist/nodefony/src/server/RealtimeConnection.js +110 -0
  15. package/dist/nodefony/src/server/RealtimeController.js +344 -85
  16. package/dist/nodefony/src/server/RealtimeHub.js +604 -61
  17. package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
  18. package/dist/nodefony/src/server/admissionBudget.js +176 -0
  19. package/dist/nodefony/src/server/channelPolicyRegistry.js +73 -0
  20. package/dist/nodefony/src/server/connectionAdmission.js +242 -0
  21. package/dist/nodefony/src/server/contractRoutes.js +85 -0
  22. package/dist/nodefony/src/server/deniedDetail.js +10 -3
  23. package/dist/nodefony/src/server/frameAdmission.js +168 -0
  24. package/dist/nodefony/src/server/subscribedAck.js +10 -1
  25. package/dist/nodefony/src/server/syslogUplink.js +3 -4
  26. package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
  27. package/dist/nodefony/src/service/RealtimeService.js +42 -1
  28. package/dist/nodefony/src/service/publicOrigin.js +40 -0
  29. package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
  30. package/dist/nodefony/testing/backplaneConformance.js +271 -0
  31. package/dist/nodefony/testing/index.js +21 -4
  32. package/dist/types/index.d.ts +34 -7
  33. package/dist/types/nodefony/config/config.d.ts +9 -0
  34. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +86 -6
  35. package/dist/types/nodefony/interfaces/IBackplane.d.ts +34 -50
  36. package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
  37. package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
  38. package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
  39. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
  40. package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
  41. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
  42. package/dist/types/nodefony/interfaces/index.d.ts +2 -1
  43. package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
  44. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +79 -16
  45. package/dist/types/nodefony/src/backplane/controlOrder.d.ts +52 -0
  46. package/dist/types/nodefony/src/backplane/envelope.d.ts +34 -0
  47. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +10 -0
  48. package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
  49. package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +17 -0
  50. package/dist/types/nodefony/src/server/RealtimeController.d.ts +93 -13
  51. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +321 -24
  52. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
  53. package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
  54. package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +34 -0
  55. package/dist/types/nodefony/src/server/connectionAdmission.d.ts +68 -0
  56. package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
  57. package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
  58. package/dist/types/nodefony/src/server/frameAdmission.d.ts +70 -0
  59. package/dist/types/nodefony/src/server/subscribedAck.d.ts +7 -0
  60. package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
  61. package/dist/types/nodefony/src/service/RealtimeService.d.ts +10 -2
  62. package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
  63. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
  64. package/dist/types/nodefony/testing/backplaneConformance.d.ts +66 -0
  65. package/dist/types/nodefony/testing/index.d.ts +17 -4
  66. package/docs/actions.md +442 -19
  67. package/docs/architecture.md +83 -8
  68. package/docs/cadence-et-contre-pression.md +87 -11
  69. package/docs/canaux-a-membres.md +267 -0
  70. package/docs/cas-d-usage.md +1702 -0
  71. package/docs/configuration.md +137 -17
  72. package/docs/cookbook-chat.md +333 -85
  73. package/docs/index.md +11 -1
  74. package/docs/observabilite.md +71 -14
  75. package/docs/protocole.md +133 -24
  76. package/docs/securite.md +294 -33
  77. package/docs/vocabulaire.md +13 -12
  78. package/package.json +14 -11
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" });
552
914
  ```
553
915
 
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).
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
945
+ ```
946
+
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
 
@@ -638,7 +1039,28 @@ describe("ChatController — actions", () => {
638
1039
  C'est l'attribut `user` du jeton que `@CurrentUser()` rend. Pour éprouver un **refus** (`@IsGranted`,
639
1040
  `policy` de canal), il faut en plus le verrou de frame de `@nodefony/security`, passé en
640
1041
  `frameAuthorizer` : `@nodefony/realtime` ne connaît pas la sécurité, et sans ce verrou une règle
641
- déclarée n'est appliquée par personne — en test comme en production. La table des membres du
1042
+ déclarée n'est appliquée par personne — en test comme en production.
1043
+
1044
+ `@nodefony/security/testing` fournit les deux pièces, et c'est la forme à préférer au jeton écrit à
1045
+ la main ci-dessus : `createTestIdentity({ identifier, roles })`
1046
+ (`security/nodefony/testing/index.ts#createTestIdentity`) rend l'identité authentifiée, et
1047
+ `createTestFrameAuthorizer(getRealtimeHub())` (`security/nodefony/testing/index.ts#createTestFrameAuthorizer`) le **vrai**
1048
+ verrou du pare-feu — même fabrique, même hiérarchie de rôles, mêmes canaux système qu'au runtime.
1049
+ Un verrou factice qui ne regarde que `isAuthenticated()` laisse passer au vert un canal mal protégé.
1050
+
1051
+ ````ts ignore
1052
+ import { getRealtimeHub } from "@nodefony/realtime";
1053
+ import { createRealtimeHarness } from "@nodefony/realtime/testing";
1054
+ import {
1055
+ createTestFrameAuthorizer,
1056
+ createTestIdentity,
1057
+ } from "@nodefony/security/testing";
1058
+
1059
+ const h = createRealtimeHarness((ctx) => new ChatController(ctx), {
1060
+ identity: createTestIdentity({ identifier: "bob", roles: ["ROLE_USER"] }),
1061
+ frameAuthorizer: createTestFrameAuthorizer(getRealtimeHub()),
1062
+ });
1063
+ ``` La table des membres du
642
1064
  harnais et l'exemple de refus sont dans le `README.md` du paquet, section « Tester un controller ».
643
1065
  `nodefony create controller --kind realtime` génère ce test, identité comprise, quand l'application
644
1066
  porte la sécurité.
@@ -656,3 +1078,4 @@ Couverture : `npm run coverage` dans `@nodefony/realtime`.
656
1078
  - La garde qui décide, côté HTTP comme WebSocket → [firewall](../../security/docs/firewall.md)
657
1079
  - Les signatures exactes ne sont pas recopiées ici : elles vivent dans le graphe symbolique
658
1080
  `.ai/symbols.json`, régénéré depuis les TSDoc du code.
1081
+ ````
@@ -344,11 +344,11 @@ La pile se lit de haut en bas ; chaque étage ne connaît que son voisin du dess
344
344
  dispatch des notifications, gestion des erreurs, `dispose()` propre. Deux points
345
345
  d'interception y sont posés :
346
346
 
347
- - `beforeDispatch` (`JsonRpcPeer.ts#JsonRpcPeerOptions.beforeDispatch`) — un verrou **synchrone** appelé avant tout
347
+ - `beforeDispatch` (`JsonRpcPeer.ts#IJsonRpcPeerHooks.beforeDispatch`) — un verrou **synchrone** appelé avant tout
348
348
  traitement de frame. Il rend `true`/`false`. Un refus sur une requête produit
349
349
  `-32001 unauthorized` ; sur une notification, la frame est jetée
350
350
  (`JsonRpcPeer.ts#JsonRpcPeer.receive`).
351
- - `onFrameAudit` (`JsonRpcPeer.ts#JsonRpcPeerOptions.onFrameAudit`) — la trace des événements protocolaires notables
351
+ - `onFrameAudit` (`JsonRpcPeer.ts#IJsonRpcPeerHooks.onFrameAudit`) — la trace des événements protocolaires notables
352
352
  (frame invalide, refusée, méthode inconnue, erreur interne).
353
353
 
354
354
  La contrainte de synchronisme n'est pas un oubli : un `await` par frame coûterait une
@@ -445,7 +445,8 @@ Les étapes, dans l'ordre exact du code :
445
445
  est révocable, préfixes de canaux broadcast, canaux entrants.
446
446
  6. **Message de bienvenue** `realtime:welcome` : canaux et méthodes découvrables, plus
447
447
  l'identité résolue (type, authentifié ou non, rôles, portées). Le client sait **qui il
448
- est** sans appeler la moindre route.
448
+ est** sans appeler la moindre route. Le champ `codec`, quand il est posé, dit le codec de
449
+ trame des frames suivantes ; absent, c'est JSON-RPC texte (ADR-0022 §2).
449
450
 
450
451
  À la fermeture, un unique `onFinish` (`RealtimeController.ts#RealtimeController.onHandshake`) fait le ménage complet :
451
452
  désabonnement de chaque canal tenu, retrait des deux registres, `fireClose()` du transport,
@@ -647,6 +648,38 @@ sa recette mérite qu'on s'y arrête :
647
648
  > celle qui ne lève aucune erreur. L'`originId` est résolu à la construction : coût nul par
648
649
  > message.
649
650
 
651
+ ### Des octets sur le bus
652
+
653
+ JSON ne sait pas porter des octets : un `Uint8Array` y devient un objet ordinaire
654
+ (`{"0":1,"1":2}`), sans erreur. Les octets traversent donc le bus par une voie à eux, ouverte
655
+ quand le serveur a un codec binaire (`codec: "cbor"`, [configuration](configuration.md)) :
656
+
657
+ - **`redis`** : un **second canal**, nommé d'après le codec
658
+ (`nodefony:realtime:<cloison>:nodefony.jsonrpc.cbor.v1`,
659
+ `RedisBackplane.ts#resolveRedisBytesChannel`). Il porte la même enveloppe
660
+ `{ channel, payload, originId }`, encodée en CBOR, octets natifs dans `payload`, scellée
661
+ comme le texte. Une charge sans octets garde le canal texte : un serveur plus ancien, qui
662
+ n'écoute pas le second canal, continue de recevoir tout le reste.
663
+ - **`cluster`** : le master forke ses workers en sérialisation `advanced` (clonage
664
+ structuré) — un `Uint8Array` traverse le relais tel quel, sans codec ni sceau : l'IPC ne
665
+ quitte pas la machine.
666
+
667
+ Le hub ne confie des octets au bus que si le driver les accepte
668
+ (`IBackplane.enableBytes`, `RealtimeHub.ts#RealtimeHub.publish`). Sinon il les garde sur le
669
+ serveur qui les publie, et un avertissement de plateforme, émis une seule fois, nomme le canal.
670
+
671
+ | Charge publiée sur un canal diffusé | Abonnés de ce serveur | Autres serveurs, codec binaire + driver capable | Autres serveurs, sinon |
672
+ | -------------------------------------- | --------------------- | ----------------------------------------------- | ------------------------------ |
673
+ | valeur JSON (`{ text: "bonjour" }`) | reçue | reçue | reçue |
674
+ | `Uint8Array` | reçue, intacte | reçue, intacte | **non envoyée**, avertissement |
675
+ | objet qui **contient** un `Uint8Array` | reçue, intacte | reçue, intacte | **non envoyée**, avertissement |
676
+ | autre vue typée, `ArrayBuffer` | reçue, intacte | **non envoyée**, avertissement | **non envoyée**, avertissement |
677
+
678
+ Sans codec binaire, seule la charge de premier niveau est examinée : chercher des octets au
679
+ fond d'un objet coûterait sur chaque publication d'une application qui n'en publie pas. Un
680
+ objet qui contient des octets sans codec binaire reste donc déformé sur le bus — comme il
681
+ l'est déjà pour les navigateurs, qui le reçoivent en JSON.
682
+
650
683
  ### Le cloisonnement du canal Redis
651
684
 
652
685
  Le canal pub/sub de base est `nodefony:realtime` (`RedisBackplane.ts#REDIS_RT_CHANNEL`). Problème : le
@@ -693,6 +726,48 @@ Le module se déclare non critique (`src/packages/@nodefony/realtime/index.ts`)
693
726
  tout repli est annoncé. Enfin, `enabled: false` rend le module totalement inerte : ni API
694
727
  d'administration, ni backplane, ni sonde.
695
728
 
729
+ ### Écrire un driver, et le prouver
730
+
731
+ Un driver mal réglé compile, démarre et perd des messages sans un mot. Les exigences qui
732
+ l'en gardent sont écrites sur le contrat (`IBackplane.ts#IBackplane`) ; trois d'entre elles
733
+ piègent tout journal partitionné :
734
+
735
+ - **chaque serveur lit TOUT** — un groupe de lecteurs par serveur ; un groupe partagé
736
+ répartirait les messages, et un serveur sur deux ne verrait qu'une partie du trafic ;
737
+ - **l'ordre par émetteur** — la clé de partition est l'`originId`, jamais le canal ;
738
+ - **aucun rejeu au démarrage** — on lit à partir du dernier message : relue depuis le
739
+ début, une fin de partie fermerait la partie rouverte depuis.
740
+
741
+ Dès que le bus quitte la machine, le driver **scelle** ses messages avec le sceau des
742
+ drivers livrés, exporté pour lui : `sealBackplaneEnvelope` à l'émission,
743
+ `openBackplaneEnvelope` à la réception (`envelope.ts#sealBackplaneEnvelope`).
744
+
745
+ La preuve est la même pour tous : `runBackplaneConformance`, exportée par
746
+ `@nodefony/realtime/testing` (`backplaneConformance.ts#runBackplaneConformance`). Elle
747
+ relie plusieurs pairs du driver sur un même bus et vérifie la diffusion à tous, l'ordre,
748
+ l'absence de rejeu, le sceau, le transport des ordres de contrôle, le refus de l'espace
749
+ réservé à l'entrée du hub, le remplacement du handler et l'arrêt. Les drivers `cluster` et
750
+ `redis` la passent dans le dépôt ; `loopback`, qui n'a aucun pair, n'a rien à y prouver.
751
+
752
+ ```typescript
753
+ import { describe, it } from "vitest";
754
+ import { runBackplaneConformance } from "@nodefony/realtime/testing";
755
+
756
+ // Chaque appel rend un pair NEUF, relié au même bus, non démarré.
757
+ runBackplaneConformance(
758
+ "mon-bus",
759
+ (secret) => new MonBackplane({ url, secret }),
760
+ {
761
+ describe,
762
+ it,
763
+ },
764
+ );
765
+ ```
766
+
767
+ Le lanceur de tests est passé, pas importé : la suite tourne sous n'importe quel lanceur
768
+ qui fournit `describe` et `it`. Un bus lent relève `quietMs`, le silence attendu avant de
769
+ conclure qu'un message refusé n'arrivera pas.
770
+
696
771
  ## 🧩 Extension — brancher son propre fond de panier
697
772
 
698
773
  Trois voies, de la plus déclarative à la plus directe.
@@ -741,10 +816,10 @@ refonte. Ici, seulement **où** ils sont ; le **quoi** et le **comment** sont da
741
816
 
742
817
  | Point de greffe | Étage | Moment | Ancrage |
743
818
  | ---------------------------- | --------- | -------------- | ------------------------------------------------------ |
744
- | Contrôle d'origine | hub | à l'upgrade | `RealtimeHub.ts#RealtimeHub.publishLocal` |
745
- | Authentificateur réseau | hub | au handshake | `RealtimeHub.ts#RealtimeHub.unsubscribe` |
746
- | Verrou de frame | protocole | à chaque frame | `RealtimeHub.ts#RealtimeHub.markBroadcastChannel` |
747
- | Politique déclarée par canal | hub | au handshake | `RealtimeHub.ts#RealtimeHub.setBackplane` |
819
+ | Contrôle d'origine | hub | à l'upgrade | `RealtimeHub.ts#RealtimeHub.checkOrigin` |
820
+ | Authentificateur réseau | hub | au handshake | `RealtimeHub.ts#RealtimeHub.useAuthenticator` |
821
+ | Verrou de frame | protocole | à chaque frame | `RealtimeHub.ts#RealtimeHub.setFrameAuthorizer` |
822
+ | Politique déclarée par canal | hub | au handshake | `RealtimeHub.ts#RealtimeHub.registerChannelPolicy` |
748
823
  | Audit de frame | protocole | sur refus | `RealtimeController.ts#RealtimeController.onHandshake` |
749
824
 
750
825
  Deux propriétés architecturales méritent d'être notées ici, parce qu'elles expliquent des
@@ -776,7 +851,7 @@ un débit.
776
851
  | Back-pressure | `maxBufferedAmount`, `totalBufferedAmount`, `slowConsumers`, `drops` | le risque mémoire numéro un |
777
852
  | Fond de panier | `backplane` (la carte d'identité de `describe()`) | ce qui est réellement branché |
778
853
 
779
- Le contrat complet est `IRealtimeProbe` (`IRealtimeProbe.ts#IRealtimeProbe`). La version enrichie
854
+ Le contrat complet est `IRealtimeProbe` (`nodefony/src/realtime/platformHealth.ts#IRealtimeProbe`). La version enrichie
780
855
  `buildOwnHealth()` (`RealtimeAdminApi.ts#buildOwnHealth`) y ajoute l'identifiant d'instance et la sonde
781
856
  process, plus — si elles sont disponibles — les sondes ORM et les compteurs d'erreurs. Deux
782
857
  chemins de lecture, un seul producteur :