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

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 (51) hide show
  1. package/README.md +29 -10
  2. package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  3. package/dist/index.js +4 -6
  4. package/dist/nodefony/config/config.js +1 -2
  5. package/dist/nodefony/config/configSchema.js +1 -2
  6. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  7. package/dist/nodefony/decorators/realtimeDecorators.js +1 -2
  8. package/dist/nodefony/interfaces/IRealtimeConnectionState.js +1 -0
  9. package/dist/nodefony/src/backplane/ClusterBackplane.js +1 -2
  10. package/dist/nodefony/src/backplane/LoopbackBackplane.js +1 -2
  11. package/dist/nodefony/src/backplane/RedisBackplane.js +1 -2
  12. package/dist/nodefony/src/backplane/backplaneRegistry.js +1 -2
  13. package/dist/nodefony/src/backplane/bootNotices.js +1 -2
  14. package/dist/nodefony/src/backplane/envelope.js +1 -2
  15. package/dist/nodefony/src/backplane/originId.js +1 -2
  16. package/dist/nodefony/src/backplane/publishQueue.js +0 -1
  17. package/dist/nodefony/src/cluster/ClusterProbeClient.js +1 -2
  18. package/dist/nodefony/src/guards.js +1 -2
  19. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +1 -2
  20. package/dist/nodefony/src/server/RealtimeAdminApi.js +1 -2
  21. package/dist/nodefony/src/server/RealtimeController.js +158 -84
  22. package/dist/nodefony/src/server/RealtimeHub.js +3 -3
  23. package/dist/nodefony/src/server/ServerRealtimeSocket.js +18 -9
  24. package/dist/nodefony/src/server/deniedDetail.js +1 -2
  25. package/dist/nodefony/src/server/subscribedAck.js +29 -0
  26. package/dist/nodefony/src/server/syslogUplink.js +1 -2
  27. package/dist/nodefony/src/server/welcomeEnv.js +1 -2
  28. package/dist/nodefony/src/service/RealtimeService.js +1 -2
  29. package/dist/nodefony/src/transport/WsConnectionTransport.js +42 -2
  30. package/dist/nodefony/testing/index.js +77 -4
  31. package/dist/types/index.d.ts +1 -0
  32. package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +40 -0
  33. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +13 -0
  34. package/dist/types/nodefony/interfaces/index.d.ts +1 -0
  35. package/dist/types/nodefony/src/server/RealtimeController.d.ts +60 -2
  36. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +18 -8
  37. package/dist/types/nodefony/src/server/subscribedAck.d.ts +23 -0
  38. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +26 -1
  39. package/dist/types/nodefony/testing/index.d.ts +11 -4
  40. package/docs/actions.md +67 -44
  41. package/docs/architecture.md +125 -119
  42. package/docs/cadence-et-contre-pression.md +31 -22
  43. package/docs/configuration.md +106 -82
  44. package/docs/cookbook-chat.md +56 -34
  45. package/docs/index.md +35 -24
  46. package/docs/observabilite.md +49 -49
  47. package/docs/protocole.md +91 -68
  48. package/docs/securite.md +135 -115
  49. package/docs/vocabulaire.md +117 -114
  50. package/package.json +10 -10
  51. package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
@@ -124,9 +124,9 @@ Nodefony étiquette avec **JSON-RPC 2.0**, une norme publique plutôt qu'un form
124
124
  | **notification** | non | non | pousser un événement, s'abonner, se désabonner |
125
125
 
126
126
  Le point remarquable : le **même** moteur de protocole tourne des deux côtés du fil. La
127
- classe `JsonRpcPeer` (`JsonRpcPeer.ts:311`) est du code isomorphe du cœur — le navigateur
127
+ classe `JsonRpcPeer` (`JsonRpcPeer.ts#JsonRpcPeer`) est du code isomorphe du cœur — le navigateur
128
128
  l'exécute dans `NodefonySocket`, le serveur l'instancie une fois par connexion dans
129
- `RealtimeController.onHandshake()` (`RealtimeController.ts:450`). Le serveur peut donc
129
+ `RealtimeController.onHandshake()` (`RealtimeController.ts#RealtimeController.onHandshake`). Le serveur peut donc
130
130
  appeler le client, pas seulement l'inverse : c'est du vrai duplex, pas un aller-retour
131
131
  déguisé.
132
132
 
@@ -136,16 +136,16 @@ Trois partis pris distinguent cette pile d'un simple « serveur WebSocket ».
136
136
 
137
137
  **Un seul contrat de socket, des deux côtés.** Un service back qui pousse des événements
138
138
  écrit le même code qu'une page front : `publish` / `subscribe` / `on`. Côté serveur c'est
139
- `ServerRealtimeSocket` (`ServerRealtimeSocket.ts:43`), obtenu par `serverSocket()`
140
- (`ServerRealtimeSocket.ts:243`) ; côté navigateur, `NodefonySocket`. Une exception, assumée
141
- et explicite : `ServerRealtimeSocket.request()` (`ServerRealtimeSocket.ts:150`) **rejette
139
+ `ServerRealtimeSocket` (`ServerRealtimeSocket.ts#ServerRealtimeSocket`), obtenu par `serverSocket()`
140
+ (`ServerRealtimeSocket.ts#serverSocket`) ; côté navigateur, `NodefonySocket`. Une exception, assumée
141
+ et explicite : `ServerRealtimeSocket.request()` (`ServerRealtimeSocket.ts#ServerRealtimeSocket.request`) **rejette
142
142
  toujours** — un handle posé sur le hub n'a pas d'interlocuteur unique, puisque le hub est
143
143
  multi-clients. Pour un appel serveur→client ciblé, on passe par la connexion :
144
- `RealtimeController.requestClient()` (`RealtimeController.ts:395`).
144
+ `RealtimeController.requestClient()` (`RealtimeController.ts#RealtimeController.requestClient`).
145
145
 
146
146
  **Un provider par canal, pas un par client.** Si mille onglets s'abonnent au même canal de
147
147
  santé, le calcul ne doit tourner qu'une fois. Le hub crée le producteur au **premier**
148
- abonné et le détruit au **dernier** (`RealtimeHub.subscribe()`, `RealtimeHub.ts:464`) : le
148
+ abonné et le détruit au **dernier** (`RealtimeHub.subscribe()`, `RealtimeHub.ts#RealtimeHub.subscribe`) : le
149
149
  coût suit le nombre de canaux, pas le nombre de clients.
150
150
 
151
151
  **Le cluster est un détail de configuration.** Ni ton contrôleur ni ton client ne savent
@@ -154,7 +154,7 @@ chaîne de caractères. Passer de mono-process à multi-pods ne touche aucune li
154
154
 
155
155
  **Le compromis, dit franchement** : un canal **ne franchit PAS** la frontière du process
156
156
  tant que tu ne l'as pas déclaré. Le défaut est l'isolement (`RealtimeHub.publish()`,
157
- `RealtimeHub.ts:610`) — un choix de sûreté détaillé plus bas.
157
+ `RealtimeHub.ts#RealtimeHub.publish`) — un choix de sûreté détaillé plus bas.
158
158
 
159
159
  ## 🚀 Démarrage rapide
160
160
 
@@ -164,25 +164,31 @@ trois fichiers, et il fonctionne en mono-process comme en cluster.
164
164
  ### 1. Charger le module et choisir le fond de panier
165
165
 
166
166
  ```ts
167
+ // nodefony/config/realtime.ts — le fragment du module, importé par le manifeste
168
+ import type { IRealtimeConfigInput } from "@nodefony/realtime";
169
+
170
+ export const realtimeConfig = () =>
171
+ ({
172
+ // "loopback" = un seul process : le hub suffit, aucun transport réseau.
173
+ // "cluster" (workers IPC) ou "redis" (multi-pods) sans changer une ligne de code.
174
+ backplane: { driver: "loopback" },
175
+ // Défense CSRF de l'upgrade WebSocket : SameSite ne protège pas un handshake.
176
+ csrf: {
177
+ checkOrigin: {
178
+ enabled: true,
179
+ allowList: ["http://localhost:5151"],
180
+ },
181
+ },
182
+ }) satisfies IRealtimeConfigInput;
183
+ ```
184
+
185
+ ```ts ignore
167
186
  // nodefony.config.ts
168
187
  import { defineConfig, use } from "nodefony";
188
+ import { realtimeConfig } from "./nodefony/config/realtime";
169
189
 
170
190
  export default defineConfig(() => ({
171
- modules: [
172
- "@nodefony/framework",
173
- use("@nodefony/realtime", {
174
- // "loopback" = un seul process : le hub suffit, aucun transport réseau.
175
- // "cluster" (workers IPC) ou "redis" (multi-pods) sans changer une ligne de code.
176
- backplane: { driver: "loopback" },
177
- // Défense CSRF de l'upgrade WebSocket : SameSite ne protège pas un handshake.
178
- csrf: {
179
- checkOrigin: {
180
- enabled: true,
181
- allowList: ["http://localhost:5151"],
182
- },
183
- },
184
- }),
185
- ],
191
+ modules: ["@nodefony/framework", use("@nodefony/realtime", realtimeConfig())],
186
192
  }));
187
193
  ```
188
194
 
@@ -291,7 +297,7 @@ export async function start(): Promise<void> {
291
297
  ### Ce qu'on observe
292
298
 
293
299
  Au démarrage du serveur, le module annonce son fond de panier — une ligne, toujours la
294
- même forme, quel que soit le driver (`src/packages/@nodefony/realtime/index.ts:316`) :
300
+ même forme, quel que soit le driver (`src/packages/@nodefony/realtime/index.ts#Realtime.#wireBackplane`) :
295
301
 
296
302
  ```
297
303
  realtime backplane driver=loopback kind=local cross-pod=no (hub local)
@@ -319,13 +325,13 @@ entre les deux **est** le facteur d'amplification du canal.
319
325
 
320
326
  La pile se lit de haut en bas ; chaque étage ne connaît que son voisin du dessous.
321
327
 
322
- | Étage | Qui | Sa seule responsabilité | Ce qu'il ignore |
323
- | ---------- | ------------------------------------------------------- | --------------------------------------- | ----------------------------- |
324
- | Applicatif | ton contrôleur, tes services | le métier : quoi publier, quoi accepter | tout le reste |
325
- | Protocole | `JsonRpcPeer` (`JsonRpcPeer.ts:311`) | étiqueter, corréler, refuser une frame | par où passent les octets |
326
- | Transport | `WsConnectionTransport` (`WsConnectionTransport.ts:46`) | déplacer des octets, mesurer la file | ce que veut dire un message |
327
- | Hub | `RealtimeHub` (`RealtimeHub.ts:214`) | table des abonnés locaux + fan-out | qu'il existe d'autres process |
328
- | Backplane | `IBackplane` (`IBackplane.ts:107`) | porter un message aux autres process | ce qu'est un abonné |
328
+ | Étage | Qui | Sa seule responsabilité | Ce qu'il ignore |
329
+ | ---------- | -------------------------------------------------------------------------- | --------------------------------------- | ----------------------------- |
330
+ | Applicatif | ton contrôleur, tes services | le métier : quoi publier, quoi accepter | tout le reste |
331
+ | Protocole | `JsonRpcPeer` (`JsonRpcPeer.ts#JsonRpcPeer`) | étiqueter, corréler, refuser une frame | par où passent les octets |
332
+ | Transport | `WsConnectionTransport` (`WsConnectionTransport.ts#WsConnectionTransport`) | déplacer des octets, mesurer la file | ce que veut dire un message |
333
+ | Hub | `RealtimeHub` (`RealtimeHub.ts#RealtimeHub`) | table des abonnés locaux + fan-out | qu'il existe d'autres process |
334
+ | Backplane | `IBackplane` (`IBackplane.ts#IBackplane`) | porter un message aux autres process | ce qu'est un abonné |
329
335
 
330
336
  > [!NOTE]
331
337
  > Une part de la documentation historique décrit cette pile en « 5 étages » en comptant
@@ -338,11 +344,11 @@ La pile se lit de haut en bas ; chaque étage ne connaît que son voisin du dess
338
344
  dispatch des notifications, gestion des erreurs, `dispose()` propre. Deux points
339
345
  d'interception y sont posés :
340
346
 
341
- - `beforeDispatch` (`JsonRpcPeer.ts:219`) — un verrou **synchrone** appelé avant tout
347
+ - `beforeDispatch` (`JsonRpcPeer.ts#JsonRpcPeerOptions.beforeDispatch`) — un verrou **synchrone** appelé avant tout
342
348
  traitement de frame. Il rend `true`/`false`. Un refus sur une requête produit
343
349
  `-32001 unauthorized` ; sur une notification, la frame est jetée
344
- (`JsonRpcPeer.ts:449`).
345
- - `onFrameAudit` (`JsonRpcPeer.ts:236`) — la trace des événements protocolaires notables
350
+ (`JsonRpcPeer.ts#JsonRpcPeer.receive`).
351
+ - `onFrameAudit` (`JsonRpcPeer.ts#JsonRpcPeerOptions.onFrameAudit`) — la trace des événements protocolaires notables
346
352
  (frame invalide, refusée, méthode inconnue, erreur interne).
347
353
 
348
354
  La contrainte de synchronisme n'est pas un oubli : un `await` par frame coûterait une
@@ -355,14 +361,14 @@ au handshake, puis lue en O(1). C'est le compromis assumé, documenté dans
355
361
  Particularité contre-intuitive : côté serveur, le transport **n'écoute pas** d'événement
356
362
  socket. C'est le pipeline HTTP de `@nodefony/http` qui appelle la route WebSocket du
357
363
  contrôleur à chaque message reçu, laquelle pousse la charge dans
358
- `WsConnectionTransport.feed()` (`WsConnectionTransport.ts:150`). La fermeture arrive de la
364
+ `WsConnectionTransport.feed()` (`WsConnectionTransport.ts#WsConnectionTransport.feed`). La fermeture arrive de la
359
365
  même façon, par le hook `onFinish` du contexte, qui déclenche `fireClose()`
360
- (`WsConnectionTransport.ts:155`).
366
+ (`WsConnectionTransport.ts#WsConnectionTransport.fireClose`).
361
367
 
362
368
  Ce transport porte aussi la **back-pressure**. Elle n'est pas câblée en dur : les trois
363
369
  leviers sont des clés de configuration du serveur WebSocket, lues par le transport
364
- (`WsConnectionTransport.ts:63-75`) et documentées dans `@nodefony/http`
365
- (`http/nodefony/config/config.ts:1226`).
370
+ (`WsConnectionTransport.ts#WsConnectionTransport`) et documentées dans `@nodefony/http`
371
+ (`http/nodefony/config/config.ts#httpConfigSchema`).
366
372
 
367
373
  | File non drainée (`bufferedAmount`) | Décision | Réglage (défaut) |
368
374
  | ----------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------- |
@@ -378,7 +384,7 @@ un canal d'état, où le prochain instantané remplace celui qu'on a sauté ; ma
378
384
  rien, et une réponse RPC ou un événement jetés sont perdus. Détail et courbes :
379
385
  [Cadence et contre-pression](./cadence-et-contre-pression.md).
380
386
 
381
- À ne pas confondre avec `SLOW_CONSUMER_BYTES` (`RealtimeHub.ts:64`, 1 MiB) : ce seuil-là
387
+ À ne pas confondre avec `SLOW_CONSUMER_BYTES` (`RealtimeHub.ts#SLOW_CONSUMER_BYTES`, 1 MiB) : ce seuil-là
382
388
  ne jette rien, il **compte** — c'est celui à partir duquel la sonde marque une connexion
383
389
  `slowConsumer`.
384
390
 
@@ -390,7 +396,7 @@ Le schéma ci-dessous rend ces étages vivants : active le temps réel et il res
390
396
 
391
397
  ## 🔌 Le cycle de vie d'une connexion
392
398
 
393
- Tout se joue dans `RealtimeController.onHandshake()` (`RealtimeController.ts:450`), appelé
399
+ Tout se joue dans `RealtimeController.onHandshake()` (`RealtimeController.ts#RealtimeController.onHandshake`), appelé
394
400
  une seule fois par connexion, en chemin froid.
395
401
 
396
402
  ```mermaid
@@ -421,19 +427,19 @@ sequenceDiagram
421
427
 
422
428
  Les étapes, dans l'ordre exact du code :
423
429
 
424
- 1. **Contrôle de l'origine** — `RealtimeHub.checkOrigin()` (`RealtimeHub.ts:967`). Refus →
430
+ 1. **Contrôle de l'origine** — `RealtimeHub.checkOrigin()` (`RealtimeHub.ts#RealtimeHub.checkOrigin`). Refus →
425
431
  fermeture `4003`. La politique vient de la configuration ; sans politique, tout passe.
426
432
  2. **Résolution de l'identité** — `RealtimeHub.resolveAuthenticator()`
427
- (`RealtimeHub.ts:943`) parcourt les authentificateurs enregistrés : **le premier motif
433
+ (`RealtimeHub.ts#RealtimeHub.resolveAuthenticator`) parcourt les authentificateurs enregistrés : **le premier motif
428
434
  qui correspond capture**. Aucun ne correspond ? Le jeton anonyme gelé est posé
429
435
  (`ANONYMOUS_REALTIME_TOKEN`) — la lecture d'identité ne rend donc jamais `null`. Un
430
436
  échec d'authentification ferme en `4001`.
431
437
  3. **Création du peer et du transport**, puis association `peer → jeton`
432
- (`RealtimeHub.setTokenForPeer()`, `RealtimeHub.ts:999`), stockée dans une `WeakMap` : le
438
+ (`RealtimeHub.setTokenForPeer()`, `RealtimeHub.ts#RealtimeHub.setTokenForPeer`), stockée dans une `WeakMap` : le
433
439
  jeton disparaît avec le peer, sans fuite.
434
440
  4. **Enregistrement des actions** — celles des décorateurs `@RealtimeAction`, puis celles
435
441
  de la surcharge `realtimeActions()`, qui gagne en cas de conflit. Le pont API
436
- `api.request` n'est ajouté que si `realtimeApiRequest()` (`RealtimeController.ts:321`)
442
+ `api.request` n'est ajouté que si `realtimeApiRequest()` (`RealtimeController.ts#RealtimeController.realtimeApiRequest`)
437
443
  rend `true`.
438
444
  5. **Inscription aux registres** : sonde de connexion, révocation périodique si le jeton
439
445
  est révocable, préfixes de canaux broadcast, canaux entrants.
@@ -441,7 +447,7 @@ Les étapes, dans l'ordre exact du code :
441
447
  l'identité résolue (type, authentifié ou non, rôles, portées). Le client sait **qui il
442
448
  est** sans appeler la moindre route.
443
449
 
444
- À la fermeture, un unique `onFinish` (`RealtimeController.ts:771`) fait le ménage complet :
450
+ À la fermeture, un unique `onFinish` (`RealtimeController.ts#RealtimeController.onHandshake`) fait le ménage complet :
445
451
  désabonnement de chaque canal tenu, retrait des deux registres, `fireClose()` du transport,
446
452
  `dispose()` du peer. C'est ce qui garantit qu'aucun minuteur ni écouteur ne survit à une
447
453
  déconnexion.
@@ -453,37 +459,37 @@ déconnexion.
453
459
 
454
460
  ### Deux gardes qui ferment la connexion
455
461
 
456
- | Garde | Déclencheur | Effet | Ancrage |
457
- | --------------------- | ---------------------------------------------------------------------- | --------------------------------------------------- | --------------------------- |
458
- | Plafond de canaux | une connexion dépasse son quota d'abonnements (256 par défaut) | abonnement refusé + `realtime:denied` motif `limit` | `RealtimeController.ts:852` |
459
- | Révocation d'identité | tick périodique ; `token.isValid()` rend `false` ou lève une exception | fermeture `4001` « session revoked » | `RealtimeHub.ts:49` |
462
+ | Garde | Déclencheur | Effet | Ancrage |
463
+ | --------------------- | ---------------------------------------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------- |
464
+ | Plafond de canaux | une connexion dépasse son quota d'abonnements (256 par défaut) | abonnement refusé + `realtime:denied` motif `limit` | `RealtimeController.ts#RealtimeController.onRealtimeNotification` |
465
+ | Révocation d'identité | tick périodique ; `token.isValid()` rend `false` ou lève une exception | fermeture `4001` « session revoked » | `RealtimeHub.ts#FrameAuthorizer` |
460
466
 
461
467
  La seconde mérite une explication. Le verrou de frame est synchrone et lit une identité
462
468
  figée au handshake : il ne peut donc pas voir une session qui meurt en cours de route (une
463
469
  déconnexion HTTP, par exemple). Un minuteur — démarré au premier inscrit, arrêté dès que le
464
- registre se vide (`RealtimeHub.registerRevocable()`, `RealtimeHub.ts:796`) — relit
470
+ registre se vide (`RealtimeHub.registerRevocable()`, `RealtimeHub.ts#RealtimeHub.registerRevocable`) — relit
465
471
  périodiquement ces identités et coupe les sockets orphelines. Seules les identités
466
472
  **révocables** y entrent : un visiteur anonyme ne coûte rien.
467
473
 
468
474
  ## Le hub — canaux partagés et fan-out
469
475
 
470
- Le hub est un singleton par process (`getRealtimeHub()`, `RealtimeHub.ts:1432`). Il ne
476
+ Le hub est un singleton par process (`getRealtimeHub()`, `RealtimeHub.ts#getRealtimeHub`). Il ne
471
477
  dépend de rien : ce sont les fabriques fournies par les contrôleurs qui portent les
472
478
  dépendances.
473
479
 
474
480
  ### Le provider partagé — un ticker, pas mille
475
481
 
476
- `RealtimeHub.subscribe()` (`RealtimeHub.ts:464`) applique une mécanique en trois temps :
482
+ `RealtimeHub.subscribe()` (`RealtimeHub.ts#RealtimeHub.subscribe`) applique une mécanique en trois temps :
477
483
 
478
484
  1. Le canal existe déjà ? On ajoute simplement le sink de cette connexion. Fin.
479
485
  2. Sinon, on inscrit le sink **avant** d'appeler la fabrique — de sorte que le tout premier
480
486
  paquet du producteur atteigne bien ce premier abonné.
481
487
  3. La fabrique du contrôleur rend `null` (canal inconnu de lui) ? Dernier recours : le
482
488
  registre des **canaux système** (`RealtimeHub.registerSystemChannel()`,
483
- `RealtimeHub.ts:1278`), qu'un module bas niveau alimente au démarrage. Toujours `null` →
489
+ `RealtimeHub.ts#RealtimeHub.registerSystemChannel`), qu'un module bas niveau alimente au démarrage. Toujours `null` →
484
490
  l'abonnement est refusé et rien n'est alloué.
485
491
 
486
- Au dernier désabonnement, `RealtimeHub.unsubscribe()` (`RealtimeHub.ts:578`) appelle le
492
+ Au dernier désabonnement, `RealtimeHub.unsubscribe()` (`RealtimeHub.ts#RealtimeHub.unsubscribe`) appelle le
487
493
  `dispose` du producteur et retire le canal. Un producteur fautif qui lève une exception ne
488
494
  bloque pas le nettoyage.
489
495
 
@@ -496,24 +502,24 @@ bloque pas le nettoyage.
496
502
 
497
503
  Deux méthodes qui se ressemblent et ne font pas du tout la même chose :
498
504
 
499
- | Méthode | Fan-out local | Propagation aux autres process | Qui l'appelle |
500
- | --------------------------------------- | :-----------: | :--------------------------------: | ----------------------------------- |
501
- | `publish()` (`RealtimeHub.ts:610`) | oui | oui, **si** le canal est broadcast | producteurs, contrôleurs, services |
502
- | `publishLocal()` (`RealtimeHub.ts:629`) | oui | **jamais** | l'arrivée d'un message du backplane |
505
+ | Méthode | Fan-out local | Propagation aux autres process | Qui l'appelle |
506
+ | ------------------------------------------------------------ | :-----------: | :--------------------------------: | ----------------------------------- |
507
+ | `publish()` (`RealtimeHub.ts#RealtimeHub.publish`) | oui | oui, **si** le canal est broadcast | producteurs, contrôleurs, services |
508
+ | `publishLocal()` (`RealtimeHub.ts#RealtimeHub.publishLocal`) | oui | **jamais** | l'arrivée d'un message du backplane |
503
509
 
504
510
  C'est **la** règle qui empêche la tempête : un message reçu d'un pair est réinjecté
505
511
  localement et **ne repart pas**. Le câblage se fait une fois pour toutes dans
506
- `RealtimeHub.setBackplane()` (`RealtimeHub.ts:720`), qui branche l'arrivée du backplane
512
+ `RealtimeHub.setBackplane()` (`RealtimeHub.ts#RealtimeHub.setBackplane`), qui branche l'arrivée du backplane
507
513
  directement sur `publishLocal`.
508
514
 
509
- Le fan-out lui-même (`RealtimeHub.ts:346`) est **isolé** : chaque livraison est protégée,
515
+ Le fan-out lui-même (`RealtimeHub.ts#RealtimeHub.#channelPolicyPatterns`) est **isolé** : chaque livraison est protégée,
510
516
  une connexion fautive n'interrompt pas la diffusion aux autres.
511
517
 
512
518
  ### Le forward est OPT-IN — le défaut est l'isolement
513
519
 
514
520
  Par défaut, **aucun canal ne traverse le backplane**. Il faut déclarer un préfixe, via
515
- `@RealtimeBroadcast` sur ton contrôleur (`realtimeDecorators.ts:420`) ou
516
- directement `RealtimeHub.markBroadcastChannel()` (`RealtimeHub.ts:674`).
521
+ `@RealtimeBroadcast` sur ton contrôleur (`realtimeDecorators.ts#RealtimeBroadcast`) ou
522
+ directement `RealtimeHub.markBroadcastChannel()` (`RealtimeHub.ts#RealtimeHub.markBroadcastChannel`).
517
523
 
518
524
  Trois raisons à ce choix, qui prend à contre-pied la plupart des bibliothèques temps réel :
519
525
 
@@ -549,21 +555,21 @@ coûte chaque abonné supplémentaire.
549
555
 
550
556
  ### Le contrat, en cinq méthodes
551
557
 
552
- `IBackplane` (`IBackplane.ts:107`) est délibérément minuscule : le backplane ne connaît ni
558
+ `IBackplane` (`IBackplane.ts#IBackplane`) est délibérément minuscule : le backplane ne connaît ni
553
559
  les abonnés ni les canaux logiques. Tout l'état vit dans le hub.
554
560
 
555
- | Membre | Rôle |
556
- | ------------------------------------------------- | --------------------------------------------------------------- |
557
- | `originId` (`IBackplane.ts:109`) | l'étiquette de CE process, lue par l'anti-écho |
558
- | `start()` (`IBackplane.ts:115`) | ouvrir le transport. Idempotent, synchrone ou asynchrone |
559
- | `publish(channel, payload)` (`IBackplane.ts:121`) | propager aux **autres** pairs. Ne refait pas le fan-out local |
560
- | `onMessage(handler)` (`IBackplane.ts:127`) | recevoir des pairs. Un seul gestionnaire à la fois |
561
- | `stop()` (`IBackplane.ts:130`) | libérer connexions et écouteurs. Idempotent |
562
- | `describe()` (`IBackplane.ts:136`) | la carte d'identité : driver, nature, origine, cross-pod, canal |
561
+ | Membre | Rôle |
562
+ | ---------------------------------------------------------------- | --------------------------------------------------------------- |
563
+ | `originId` (`IBackplane.ts#IBackplane.originId`) | l'étiquette de CE process, lue par l'anti-écho |
564
+ | `start()` (`IBackplane.ts#IBackplane.start`) | ouvrir le transport. Idempotent, synchrone ou asynchrone |
565
+ | `publish(channel, payload)` (`IBackplane.ts#IBackplane.publish`) | propager aux **autres** pairs. Ne refait pas le fan-out local |
566
+ | `onMessage(handler)` (`IBackplane.ts#IBackplane.onMessage`) | recevoir des pairs. Un seul gestionnaire à la fois |
567
+ | `stop()` (`IBackplane.ts#IBackplane.stop`) | libérer connexions et écouteurs. Idempotent |
568
+ | `describe()` (`IBackplane.ts#IBackplane.describe`) | la carte d'identité : driver, nature, origine, cross-pod, canal |
563
569
 
564
570
  Deux absences volontaires. Il n'y a **pas** de `subscribe`/`unsubscribe` par canal : le
565
571
  transport porte **un seul** canal physique, et les canaux logiques voyagent **dans**
566
- l'enveloppe (`IBackplaneMessage`, `IBackplane.ts:51`). Un seul abonnement au démarrage,
572
+ l'enveloppe (`IBackplaneMessage`, `IBackplane.ts#IBackplaneMessage`). Un seul abonnement au démarrage,
567
573
  zéro (dés)abonnement dynamique — et le hub reste seul maître de la table des canaux.
568
574
 
569
575
  `describe()`, lui, alimente **trois sorties cohérentes** depuis une source unique : la ligne
@@ -576,11 +582,11 @@ resynchronise. Ne construis pas au-dessus une fiabilité que le support n'offre
576
582
 
577
583
  ### Les drivers natifs
578
584
 
579
- | Driver | Classe | Nature | Pairs | Traverse les pods |
580
- | ---------- | ----------------------------------------------- | -------------- | ---------------------------------- | :---------------: |
581
- | `loopback` | `LoopbackBackplane` (`LoopbackBackplane.ts:24`) | `local` | aucun (no-op complet) | non |
582
- | `cluster` | `ClusterBackplane` (`ClusterBackplane.ts:89`) | `ipc` | les workers du même process maître | non |
583
- | `redis` | `RedisBackplane` (`RedisBackplane.ts:219`) | `redis-pubsub` | tous les pods abonnés au canal | **oui** |
585
+ | Driver | Classe | Nature | Pairs | Traverse les pods |
586
+ | ---------- | -------------------------------------------------------------- | -------------- | ---------------------------------- | :---------------: |
587
+ | `loopback` | `LoopbackBackplane` (`LoopbackBackplane.ts#LoopbackBackplane`) | `local` | aucun (no-op complet) | non |
588
+ | `cluster` | `ClusterBackplane` (`ClusterBackplane.ts#ClusterBackplane`) | `ipc` | les workers du même process maître | non |
589
+ | `redis` | `RedisBackplane` (`RedisBackplane.ts#RedisBackplane`) | `redis-pubsub` | tous les pods abonnés au canal | **oui** |
584
590
 
585
591
  **`loopback`** est le défaut. En réalité, le hub garde son backplane à `null` en
586
592
  mono-process : le coût est un test de nullité, pas un appel. La classe existe pour
@@ -588,30 +594,30 @@ matérialiser le contrat et prouver, en test, que brancher un backplane sans pai
588
594
  rien.
589
595
 
590
596
  **`cluster`** relie les workers d'un même hôte. Un worker Node ne peut parler qu'au maître :
591
- il émet vers lui (`ClusterBackplane.publish()`, `ClusterBackplane.ts:117`), le maître
597
+ il émet vers lui (`ClusterBackplane.publish()`, `ClusterBackplane.ts#ClusterBackplane.publish`), le maître
592
598
  rediffuse aux autres. Le driver ne s'active qu'en rôle worker avec la variable
593
- `NF_CLUSTER=1` (`src/packages/@nodefony/realtime/index.ts:106`) ; ailleurs il rend
599
+ `NF_CLUSTER=1` (`src/packages/@nodefony/realtime/index.ts`) ; ailleurs il rend
594
600
  `null`. Aucune infrastructure requise — c'est le banc d'essai qui stabilise l'architecture
595
601
  multi-process avant d'ajouter du réseau.
596
602
 
597
603
  **`redis`** franchit la frontière de l'hôte, sans dépendre de la bibliothèque `redis` : il
598
604
  consomme deux connexions du module `@nodefony/redis` via un adaptateur purement structurel,
599
- `createRedisServiceTransport()` (`RedisBackplane.ts:137`). Deux connexions et non une, parce
605
+ `createRedisServiceTransport()` (`RedisBackplane.ts#createRedisServiceTransport`). Deux connexions et non une, parce
600
606
  qu'un client Redis abonné ne peut plus émettre de commandes ordinaires. Module absent ou
601
607
  connexions indisponibles → avertissement, `null`, hub local, démarrage poursuivi
602
- (`src/packages/@nodefony/realtime/index.ts:115`).
608
+ (`src/packages/@nodefony/realtime/index.ts`).
603
609
 
604
610
  ### Le registre de drivers — zéro nom en dur
605
611
 
606
612
  Choisir le driver par une cascade de `if (driver === "redis")` trahirait la promesse de
607
613
  brancherie. La sélection passe donc par un registre — `registerBackplaneDriver()`
608
- (`backplaneRegistry.ts:55`) et `getBackplaneDriver()` (`backplaneRegistry.ts:63`) : chaque
614
+ (`backplaneRegistry.ts#registerBackplaneDriver`) et `getBackplaneDriver()` (`backplaneRegistry.ts#getBackplaneDriver`) : chaque
609
615
  driver porte son nom en membre statique, les natifs s'inscrivent au chargement du module, et
610
616
  le câblage résout une chaîne vers une fabrique sans connaître aucun littéral
611
- (`src/packages/@nodefony/realtime/index.ts:260`).
617
+ (`src/packages/@nodefony/realtime/index.ts#Realtime.onKernelBoot`).
612
618
 
613
619
  Une fabrique reçoit module, `originId`, rôle dans la topologie et configuration validée
614
- (`IBackplaneFactoryContext`, `backplaneRegistry.ts:27`), puis rend une instance ou `null`
620
+ (`IBackplaneFactoryContext`, `backplaneRegistry.ts#IBackplaneFactoryContext`), puis rend une instance ou `null`
615
621
  (« inactif ici »). Elle ne démarre rien : c'est le câblage qui appelle `start()`.
616
622
 
617
623
  ### L'anti-écho et l'`originId`
@@ -622,9 +628,9 @@ et non une :
622
628
 
623
629
  1. **Côté hub** : l'arrivée passe par `publishLocal`, jamais par `publish`. Rien ne repart.
624
630
  2. **Côté backplane** : à la réception, on compare l'`originId` de l'enveloppe au sien et
625
- on jette si c'est le même (`RedisBackplane.ts:235`, `ClusterBackplane.ts:134`).
631
+ on jette si c'est le même (`RedisBackplane.ts#RedisBackplane.originId`, `ClusterBackplane.ts#ClusterBackplane.#ingress`).
626
632
 
627
- L'étiquette elle-même est calculée par `resolveBackplaneOriginId()` (`originId.ts:24`), et
633
+ L'étiquette elle-même est calculée par `resolveBackplaneOriginId()` (`originId.ts#resolveBackplaneOriginId`), et
628
634
  sa recette mérite qu'on s'y arrête :
629
635
 
630
636
  | Étape | Valeur | Pourquoi |
@@ -643,14 +649,14 @@ sa recette mérite qu'on s'y arrête :
643
649
 
644
650
  ### Le cloisonnement du canal Redis
645
651
 
646
- Le canal pub/sub de base est `nodefony:realtime` (`RedisBackplane.ts:20`). Problème : le
652
+ Le canal pub/sub de base est `nodefony:realtime` (`RedisBackplane.ts#REDIS_RT_CHANNEL`). Problème : le
647
653
  numéro de base Redis **ne cloisonne pas** le pub/sub, qui est global au serveur. Deux
648
654
  applications sur un Redis mutualisé se parleraient.
649
655
 
650
- D'où le suffixe de cloison, calculé par `resolveRedisChannel()` (`RedisBackplane.ts:31`) :
656
+ D'où le suffixe de cloison, calculé par `resolveRedisChannel()` (`RedisBackplane.ts#resolveRedisChannel`) :
651
657
  le canal effectif devient `nodefony:realtime:<namespace>`. Le namespace vient de la
652
658
  configuration ; à défaut, il est dérivé du nom de l'application
653
- (`src/packages/@nodefony/realtime/index.ts:139`).
659
+ (`src/packages/@nodefony/realtime/index.ts`).
654
660
 
655
661
  > [!TIP]
656
662
  > Deux déploiements de la **même** application sur un Redis partagé (préproduction et
@@ -658,19 +664,19 @@ configuration ; à défaut, il est dérivé du nom de l'application
658
664
  > namespace **explicite et distinct** dans ces cas-là.
659
665
 
660
666
  L'entrée est par ailleurs blindée : un JSON malformé publié par un tiers sur le même canal
661
- est ignoré, pas propagé (`RedisBackplane.ts:212`).
667
+ est ignoré, pas propagé (`RedisBackplane.ts#RedisBackplane`).
662
668
 
663
669
  ### Le démarrage, et ce qui se passe quand ça rate
664
670
 
665
671
  Le module valide sa configuration par Zod à l'enregistrement
666
- (`src/packages/@nodefony/realtime/index.ts:226`), puis câble tout au démarrage du noyau
667
- (`src/packages/@nodefony/realtime/index.ts:220`) — après l'initialisation des services (le
672
+ (`src/packages/@nodefony/realtime/index.ts#Realtime.onKernelRegister`), puis câble tout au démarrage du noyau
673
+ (`src/packages/@nodefony/realtime/index.ts#Realtime.onKernelRegister`) — après l'initialisation des services (le
668
674
  service Redis a donc ouvert ses connexions) et avant le trafic.
669
675
 
670
676
  Le `start()` du backplane est **attendu explicitement avant** d'être branché : un driver
671
677
  asynchrone brancherait sinon un abonnement incomplet et perdrait ses premiers messages. Et
672
678
  il est **borné par un délai** de cinq secondes
673
- (`src/packages/@nodefony/realtime/index.ts:90`), pour une raison très concrète : un
679
+ (`src/packages/@nodefony/realtime/index.ts#BACKPLANE_START_TIMEOUT_MS`), pour une raison très concrète : un
674
680
  transport réseau qui reste pendu gèlerait la phase de démarrage, et donc la montée des
675
681
  serveurs HTTP eux-mêmes.
676
682
 
@@ -682,7 +688,7 @@ Trois issues, toutes annoncées, jamais silencieuses :
682
688
  | driver inactif ici (loopback, mauvais rôle) | hub local | `INFO` |
683
689
  | driver inconnu, ou démarrage en échec/expiré | hub local, **le démarrage continue** | `WARNING` |
684
690
 
685
- Le module se déclare non critique (`src/packages/@nodefony/realtime/index.ts:151`) : son
691
+ Le module se déclare non critique (`src/packages/@nodefony/realtime/index.ts`) : son
686
692
  échec ne tue jamais le process. C'est de la résilience **sans dégradation silencieuse** —
687
693
  tout repli est annoncé. Enfin, `enabled: false` rend le module totalement inerte : ni API
688
694
  d'administration, ni backplane, ni sonde.
@@ -712,18 +718,18 @@ registerBackplaneDriver(
712
718
 
713
719
  **2. Déclarer un service** nommé `realtimeBackplane` dans le conteneur d'injection —
714
720
  **la voie « instance » pour une application**. `RealtimeService.init()`
715
- (`RealtimeService.ts:79`) le résout et le branche **avant** le registre : une instance
721
+ (`RealtimeService.ts#RealtimeService.init`) le résout et le branche **avant** le registre : une instance
716
722
  déjà fournie court-circuite la sélection par nom.
717
723
 
718
724
  **3. Passer une instance** en second argument de `defineRealtimeConfig()` — réservé au
719
725
  code qui **appelle le builder lui-même** (un `Module` sur mesure, une intégration
720
726
  programmatique). Une application qui configure via `use("@nodefony/realtime", …)` **ne
721
727
  l'atteint pas** : elle ne construit pas la config (le module appelle
722
- `defineRealtimeConfig(this.options)` sans second argument, `index.ts:216`), et une
728
+ `defineRealtimeConfig(this.options)` sans second argument, `index.ts#createRealtimeHarness`), et une
723
729
  instance posée dans l'objet de config est refusée par la validation Zod (clé inconnue du
724
730
  schéma strict). Pour une app, la voie « instance » est l'option 2 ci-dessus.
725
731
 
726
- Le registre s'introspecte : `listBackplaneDrivers()` (`backplaneRegistry.ts:68`) rend les
732
+ Le registre s'introspecte : `listBackplaneDrivers()` (`backplaneRegistry.ts#listBackplaneDrivers`) rend les
727
733
  noms disponibles — c'est ce que le message d'erreur affiche quand un driver déclaré est
728
734
  introuvable.
729
735
 
@@ -733,31 +739,31 @@ L'architecture prévoit des points de greffe pour que `@nodefony/security` s'y b
733
739
  refonte. Ici, seulement **où** ils sont ; le **quoi** et le **comment** sont dans
734
740
  [la page sécurité](./securite.md).
735
741
 
736
- | Point de greffe | Étage | Moment | Ancrage |
737
- | ---------------------------- | --------- | -------------- | --------------------------- |
738
- | Contrôle d'origine | hub | à l'upgrade | `RealtimeHub.ts:620` |
739
- | Authentificateur réseau | hub | au handshake | `RealtimeHub.ts:585` |
740
- | Verrou de frame | protocole | à chaque frame | `RealtimeHub.ts:681` |
741
- | Politique déclarée par canal | hub | au handshake | `RealtimeHub.ts:719` |
742
- | Audit de frame | protocole | sur refus | `RealtimeController.ts:522` |
742
+ | Point de greffe | Étage | Moment | Ancrage |
743
+ | ---------------------------- | --------- | -------------- | ------------------------------------------------------ |
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` |
748
+ | Audit de frame | protocole | sur refus | `RealtimeController.ts#RealtimeController.onHandshake` |
743
749
 
744
750
  Deux propriétés architecturales méritent d'être notées ici, parce qu'elles expliquent des
745
751
  choix de conception visibles partout dans le module :
746
752
 
747
753
  **Coût nul quand la sécurité est absente.** Le verrou de frame n'est branché sur le peer que
748
- si une politique existe (`RealtimeHub.hasFrameAuthorizer()`, `RealtimeHub.ts:1039`, testé une
754
+ si une politique existe (`RealtimeHub.hasFrameAuthorizer()`, `RealtimeHub.ts#RealtimeHub.hasFrameAuthorizer`, testé une
749
755
  fois au handshake). Sans module de sécurité, `beforeDispatch` reste indéfini et le chemin
750
756
  chaud ne paie **rien** du tout.
751
757
 
752
758
  **Échec bruyant plutôt que faux sentiment de sécurité.** Si des canaux déclarent une
753
759
  politique sans qu'aucun décideur ne soit câblé, `hasUnenforcedChannelPolicies()`
754
- (`RealtimeHub.ts:1098`) le détecte et un avertissement est émis une fois par process
755
- (`RealtimeController.ts:639`). Un canal qui **se croit** gardé alors qu'il est ouvert est
760
+ (`RealtimeHub.ts#RealtimeHub.hasUnenforcedChannelPolicies`) le détecte et un avertissement est émis une fois par process
761
+ (`RealtimeController.ts#RealtimeController.onHandshake`). Un canal qui **se croit** gardé alors qu'il est ouvert est
756
762
  bien plus dangereux qu'un canal ouvertement public.
757
763
 
758
764
  ## 📡 Observabilité — la sonde et Studio
759
765
 
760
- `RealtimeHub.probe()` (`RealtimeHub.ts:847`) rend un instantané en **lecture pure** : aucune
766
+ `RealtimeHub.probe()` (`RealtimeHub.ts#RealtimeHub.probe`) rend un instantané en **lecture pure** : aucune
761
767
  allocation sur le chemin chaud, jamais d'exception. Les compteurs sont des primitives
762
768
  incrémentées en O(1) ; ils sont **monotones**, ce qui laisse au lecteur le soin de dériver
763
769
  un débit.
@@ -770,35 +776,35 @@ un débit.
770
776
  | Back-pressure | `maxBufferedAmount`, `totalBufferedAmount`, `slowConsumers`, `drops` | le risque mémoire numéro un |
771
777
  | Fond de panier | `backplane` (la carte d'identité de `describe()`) | ce qui est réellement branché |
772
778
 
773
- Le contrat complet est `IRealtimeProbe` (`IRealtimeProbe.ts:61`). La version enrichie
774
- `buildOwnHealth()` (`RealtimeAdminApi.ts:52`) y ajoute l'identifiant d'instance et la sonde
779
+ Le contrat complet est `IRealtimeProbe` (`IRealtimeProbe.ts#IRealtimeProbe`). La version enrichie
780
+ `buildOwnHealth()` (`RealtimeAdminApi.ts#buildOwnHealth`) y ajoute l'identifiant d'instance et la sonde
775
781
  process, plus — si elles sont disponibles — les sondes ORM et les compteurs d'erreurs. Deux
776
782
  chemins de lecture, un seul producteur :
777
783
 
778
784
  - **HTTP** : `GET /nodefony/realtime/api/health`, exposé par `createRealtimeAdminApi()`
779
- (`RealtimeAdminApi.ts:98`) ;
785
+ (`RealtimeAdminApi.ts#createRealtimeAdminApi`) ;
780
786
  - **Canal temps réel** : `nodefony:socket`, poussé par un ticker.
781
787
 
782
788
  ### En cluster : la vue « pod » plutôt que « worker »
783
789
 
784
790
  Interroger la santé en cluster pose un problème sournois : la requête atterrit sur **un**
785
791
  worker au hasard, dont l'instantané ne dit rien des autres. D'où `ClusterProbeClient`
786
- (`ClusterProbeClient.ts:138`), qui applique un modèle **push** — chaque worker remonte
787
- périodiquement sa santé au maître (`ClusterProbeClient.ts:179`), le maître agrège et
792
+ (`ClusterProbeClient.ts#ClusterProbeClient`), qui applique un modèle **push** — chaque worker remonte
793
+ périodiquement sa santé au maître (`ClusterProbeClient.ts#ClusterProbeClient.constructor`), le maître agrège et
788
794
  rediffuse, chaque worker met en cache. **N'importe lequel** sert alors la vue pod en O(1),
789
795
  sans latence de requête.
790
796
 
791
- L'agrégation, `mergeClusterHealth()` (`ClusterProbeClient.ts:60`), est une fonction pure :
797
+ L'agrégation, `mergeClusterHealth()` (`ClusterProbeClient.ts#mergeClusterHealth`), est une fonction pure :
792
798
  elle somme les scalaires, mais prend le **maximum** de `maxBufferedAmount` — la santé d'une
793
799
  flotte se juge sur son pire membre, pas sur sa moyenne. `buildRealtimeHealth()`
794
- (`RealtimeAdminApi.ts:74`) choisit ensuite la vue agrégée si elle existe, la per-instance
800
+ (`RealtimeAdminApi.ts#buildRealtimeHealth`) choisit ensuite la vue agrégée si elle existe, la per-instance
795
801
  sinon.
796
802
 
797
803
  Sonde désactivée ? Elle n'est pas muette : **elle n'existe pas**. Aucun client, donc aucun
798
804
  minuteur, aucun écouteur, aucun message IPC
799
- (`src/packages/@nodefony/realtime/index.ts:364`) — coupable par la configuration ou par une
805
+ (`src/packages/@nodefony/realtime/index.ts#Realtime.#startWithTimeout`) — coupable par la configuration ou par une
800
806
  variable d'environnement. Enfin, le maître peut demander à un worker précis d'enrichir sa
801
- remontée (`requestEnrich()`, `ClusterProbeClient.ts:259`) : la sonde riche n'est allouée que
807
+ remontée (`requestEnrich()`, `ClusterProbeClient.ts#ClusterProbeClient.requestEnrich`) : la sonde riche n'est allouée que
802
808
  pendant l'inspection, puis libérée — on paie ce qu'on regarde.
803
809
 
804
810
  ## ⚡ Performance & mémoire
@@ -807,10 +813,10 @@ Le module suit une discipline stricte, lisible dans presque chaque champ privé
807
813
 
808
814
  **Allocation paresseuse partout.** Table des canaux, registre des connexions, registre des
809
815
  révocables, authentificateurs, politiques de canal, association peer→jeton : tout démarre à
810
- `null`. Un process sans abonné n'alloue **rien** (`RealtimeHub.ts:142` et suivants).
816
+ `null`. Un process sans abonné n'alloue **rien** (`RealtimeHub.ts#IRevocableConnection` et suivants).
811
817
 
812
818
  **Aucun minuteur au repos.** Le tick de révocation démarre au premier inscrit et s'arrête
813
- dès que le registre se vide — `RealtimeHub.unregisterRevocable()` (`RealtimeHub.ts:804`).
819
+ dès que le registre se vide — `RealtimeHub.unregisterRevocable()` (`RealtimeHub.ts#RealtimeHub.unregisterRevocable`).
814
820
  Les minuteurs sont détachés de la boucle d'événements : ils ne retiennent jamais l'arrêt du
815
821
  process.
816
822