@nodefony/realtime 10.0.0-beta.5 → 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.
- package/README.md +50 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
- package/dist/index.js +79 -12
- package/dist/nodefony/config/config.js +7 -0
- package/dist/nodefony/decorators/realtimeDecorators.js +125 -19
- package/dist/nodefony/interfaces/IRealtimeConnection.js +1 -0
- package/dist/nodefony/src/backplane/ClusterBackplane.js +32 -4
- package/dist/nodefony/src/backplane/RedisBackplane.js +143 -31
- package/dist/nodefony/src/backplane/controlOrder.js +57 -0
- package/dist/nodefony/src/backplane/envelope.js +113 -12
- package/dist/nodefony/src/cluster/ClusterProbeClient.js +2 -0
- package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
- package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
- package/dist/nodefony/src/server/RealtimeConnection.js +110 -0
- package/dist/nodefony/src/server/RealtimeController.js +340 -81
- package/dist/nodefony/src/server/RealtimeHub.js +604 -61
- package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
- package/dist/nodefony/src/server/admissionBudget.js +176 -0
- package/dist/nodefony/src/server/channelPolicyRegistry.js +73 -0
- package/dist/nodefony/src/server/connectionAdmission.js +242 -0
- package/dist/nodefony/src/server/contractRoutes.js +85 -0
- package/dist/nodefony/src/server/deniedDetail.js +10 -3
- package/dist/nodefony/src/server/frameAdmission.js +168 -0
- package/dist/nodefony/src/server/subscribedAck.js +10 -1
- package/dist/nodefony/src/server/syslogUplink.js +3 -4
- package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
- package/dist/nodefony/src/service/RealtimeService.js +42 -1
- package/dist/nodefony/src/service/publicOrigin.js +40 -0
- package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
- package/dist/nodefony/testing/backplaneConformance.js +271 -0
- package/dist/nodefony/testing/index.js +21 -4
- package/dist/types/index.d.ts +34 -7
- package/dist/types/nodefony/config/config.d.ts +9 -0
- package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +86 -6
- package/dist/types/nodefony/interfaces/IBackplane.d.ts +34 -50
- package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
- package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
- package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
- package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
- package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
- package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
- package/dist/types/nodefony/interfaces/index.d.ts +2 -1
- package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
- package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +79 -16
- package/dist/types/nodefony/src/backplane/controlOrder.d.ts +52 -0
- package/dist/types/nodefony/src/backplane/envelope.d.ts +34 -0
- package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +10 -0
- package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
- package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +17 -0
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +91 -11
- package/dist/types/nodefony/src/server/RealtimeHub.d.ts +321 -24
- package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
- package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
- package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +34 -0
- package/dist/types/nodefony/src/server/connectionAdmission.d.ts +68 -0
- package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
- package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
- package/dist/types/nodefony/src/server/frameAdmission.d.ts +70 -0
- package/dist/types/nodefony/src/server/subscribedAck.d.ts +7 -0
- package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
- package/dist/types/nodefony/src/service/RealtimeService.d.ts +10 -2
- package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
- package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
- package/dist/types/nodefony/testing/backplaneConformance.d.ts +66 -0
- package/dist/types/nodefony/testing/index.d.ts +12 -1
- package/docs/actions.md +419 -18
- package/docs/architecture.md +83 -8
- package/docs/cadence-et-contre-pression.md +87 -11
- package/docs/canaux-a-membres.md +267 -0
- package/docs/cas-d-usage.md +1702 -0
- package/docs/configuration.md +137 -17
- package/docs/cookbook-chat.md +314 -85
- package/docs/index.md +11 -1
- package/docs/observabilite.md +71 -14
- package/docs/protocole.md +133 -24
- package/docs/securite.md +284 -32
- package/docs/vocabulaire.md +13 -12
- 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.
|
|
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.
|
|
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
|
|
838
|
+
| une écriture (`socket.post`) | **non** par nature | oui, **avec une clé d'idempotence** |
|
|
479
839
|
|
|
480
|
-
Pour les
|
|
481
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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.
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
|
package/docs/architecture.md
CHANGED
|
@@ -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#
|
|
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#
|
|
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.
|
|
745
|
-
| Authentificateur réseau | hub | au handshake | `RealtimeHub.ts#RealtimeHub.
|
|
746
|
-
| Verrou de frame | protocole | à chaque frame | `RealtimeHub.ts#RealtimeHub.
|
|
747
|
-
| Politique déclarée par canal | hub | au handshake | `RealtimeHub.ts#RealtimeHub.
|
|
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` (`
|
|
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 :
|