@nodefony/realtime 10.0.0-beta.5 → 10.0.0-beta.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
- package/dist/index.js +91 -12
- package/dist/nodefony/config/config.js +7 -0
- package/dist/nodefony/decorators/realtimeDecorators.js +341 -43
- package/dist/nodefony/interfaces/IRealtimeConnection.js +1 -0
- package/dist/nodefony/src/backplane/ClusterBackplane.js +32 -4
- package/dist/nodefony/src/backplane/RedisBackplane.js +146 -41
- package/dist/nodefony/src/backplane/controlOrder.js +86 -0
- package/dist/nodefony/src/backplane/envelope.js +505 -27
- package/dist/nodefony/src/backplane/ingress.js +93 -0
- package/dist/nodefony/src/backplane/presenceMessage.js +205 -0
- package/dist/nodefony/src/backplane/publishQueue.js +97 -3
- 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 +135 -0
- package/dist/nodefony/src/server/RealtimeController.js +409 -107
- package/dist/nodefony/src/server/RealtimeHub.js +1158 -111
- 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 +95 -0
- package/dist/nodefony/src/server/channelPolicyResolution.js +191 -0
- package/dist/nodefony/src/server/channelPresence.js +488 -0
- package/dist/nodefony/src/server/connectionAdmission.js +242 -0
- package/dist/nodefony/src/server/contractRoutes.js +75 -0
- package/dist/nodefony/src/server/deniedDetail.js +10 -3
- package/dist/nodefony/src/server/frameAdmission.js +160 -0
- package/dist/nodefony/src/server/presenceBus.js +486 -0
- package/dist/nodefony/src/server/realtimeShutdown.js +31 -0
- package/dist/nodefony/src/server/remotePresence.js +541 -0
- package/dist/nodefony/src/server/subscribedAck.js +13 -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 +51 -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 +347 -0
- package/dist/nodefony/testing/index.js +43 -12
- package/dist/nodefony/testing/presenceClient.js +54 -0
- package/dist/types/index.d.ts +39 -7
- package/dist/types/nodefony/config/config.d.ts +9 -0
- package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +198 -17
- package/dist/types/nodefony/interfaces/IBackplane.d.ts +46 -51
- 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 +89 -18
- package/dist/types/nodefony/src/backplane/controlOrder.d.ts +75 -0
- package/dist/types/nodefony/src/backplane/envelope.d.ts +77 -0
- package/dist/types/nodefony/src/backplane/ingress.d.ts +48 -0
- package/dist/types/nodefony/src/backplane/presenceMessage.d.ts +150 -0
- package/dist/types/nodefony/src/backplane/publishQueue.d.ts +25 -1
- 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 +20 -0
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +110 -12
- package/dist/types/nodefony/src/server/RealtimeHub.d.ts +448 -42
- 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 +41 -0
- package/dist/types/nodefony/src/server/channelPolicyResolution.d.ts +112 -0
- package/dist/types/nodefony/src/server/channelPresence.d.ts +155 -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 +66 -0
- package/dist/types/nodefony/src/server/presenceBus.d.ts +112 -0
- package/dist/types/nodefony/src/server/realtimeShutdown.d.ts +21 -0
- package/dist/types/nodefony/src/server/remotePresence.d.ts +151 -0
- package/dist/types/nodefony/src/server/subscribedAck.d.ts +10 -0
- package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
- package/dist/types/nodefony/src/service/RealtimeService.d.ts +17 -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 +78 -0
- package/dist/types/nodefony/testing/index.d.ts +13 -2
- package/dist/types/nodefony/testing/presenceClient.d.ts +20 -0
- package/docs/actions.md +420 -18
- package/docs/architecture.md +101 -11
- package/docs/cadence-et-contre-pression.md +87 -11
- package/docs/canaux-a-membres.md +277 -0
- package/docs/cas-d-usage.md +1703 -0
- package/docs/configuration.md +175 -19
- package/docs/cookbook-chat.md +701 -224
- package/docs/index.md +11 -1
- package/docs/observabilite.md +97 -21
- package/docs/protocole.md +142 -33
- package/docs/securite.md +347 -41
- package/docs/vocabulaire.md +13 -12
- package/package.json +16 -13
package/docs/actions.md
CHANGED
|
@@ -274,10 +274,11 @@ Le trajet complet d'une requête, des étapes qu'elle traverse aux branches par
|
|
|
274
274
|
|
|
275
275
|
### Deux voies, un même registre
|
|
276
276
|
|
|
277
|
-
| Voie | Quand la choisir
|
|
278
|
-
| -------------------------------------------------------------------------------- |
|
|
279
|
-
| `@RealtimeAction("nom")` (`realtimeDecorators.ts#RealtimeAction`) | le cas courant — un nom fixe, une méthode, déclaratif
|
|
280
|
-
| `realtimeActions()` (`RealtimeController.ts#RealtimeController.realtimeActions`) | la table est **calculée** (noms dynamiques, boucle, config)
|
|
277
|
+
| Voie | Quand la choisir |
|
|
278
|
+
| -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
279
|
+
| `@RealtimeAction("nom")` (`realtimeDecorators.ts#RealtimeAction`) | le cas courant — un nom fixe, une méthode, déclaratif |
|
|
280
|
+
| `realtimeActions()` (`RealtimeController.ts#RealtimeController.realtimeActions`) | la table est **calculée** (noms dynamiques, boucle, config) |
|
|
281
|
+
| `realtimeActions()` **complète** d'un `ContractRealtimeController<C>` | le contrôleur implémente un **contrat** : une méthode oubliée ne compile pas (voir « Un contrat pour les deux bouts ») |
|
|
281
282
|
|
|
282
283
|
Les deux sont fusionnées au handshake, et **l'override gagne** en cas de conflit de nom : une
|
|
283
284
|
classe peut ainsi remplacer une action héritée sans toucher au parent
|
|
@@ -304,6 +305,347 @@ Trust stricte, appliquée dans `JsonRpcPeer.handleRequest()` (`JsonRpcPeer.ts#Js
|
|
|
304
305
|
> chercher d'erreur maison dans `@nodefony/realtime` : il n'y en a pas, et
|
|
305
306
|
> `tests/unit/errorSurface.test.ts` refuse d'en exporter une que personne ne lève.
|
|
306
307
|
|
|
308
|
+
## 🤝 Un contrat pour les deux bouts
|
|
309
|
+
|
|
310
|
+
Sans contrat, chaque côté type ce qu'il **envoie**, jamais ce que l'autre **reçoit** ou
|
|
311
|
+
**implémente**. Le navigateur appelle `request("orders:quote", …)` ; le serveur peut n'avoir
|
|
312
|
+
aucune action de ce nom, ou lui donner une autre forme — tout compile, et l'erreur sort en
|
|
313
|
+
production (`-32601`, ou une réponse de forme fausse). Un **contrat** se déclare UNE fois, et le
|
|
314
|
+
compilateur vérifie les deux bouts.
|
|
315
|
+
|
|
316
|
+
### Le contrat, les deux portes, le flux SSE
|
|
317
|
+
|
|
318
|
+
Le contrat est une interface de **types seuls** : son fichier, partagé par les deux côtés,
|
|
319
|
+
compile en zéro octet, et le navigateur qui l'importe ne tire aucun code serveur. Chaque côté
|
|
320
|
+
entre ensuite par **sa** porte, et la direction (ce que j'envoie, ce que je reçois) est
|
|
321
|
+
**calculée** — jamais réécrite à la main.
|
|
322
|
+
|
|
323
|
+
```ts
|
|
324
|
+
// ── shared/orders.ts — le contrat, déclaré UNE fois (types seuls) ──────────
|
|
325
|
+
import type { IRealtimeContract } from "nodefony/client";
|
|
326
|
+
|
|
327
|
+
export interface OrdersContract extends IRealtimeContract {
|
|
328
|
+
// ce que le serveur pousse (socket, flux SSE, webhook)
|
|
329
|
+
serverToClient: {
|
|
330
|
+
"orders:status": { orderId: string; at: Date };
|
|
331
|
+
[channel: `orders:user:${string}`]: { unread: number };
|
|
332
|
+
};
|
|
333
|
+
// ce que le client envoie sur un canal
|
|
334
|
+
clientToServer: { "orders:typing": { on: boolean } };
|
|
335
|
+
// les méthodes du serveur : le client appelle, le serveur répond
|
|
336
|
+
serverMethods: {
|
|
337
|
+
"orders:quote": { in: { orderId: string }; out: { total: number } };
|
|
338
|
+
};
|
|
339
|
+
// les méthodes du client : le serveur appelle, le client répond
|
|
340
|
+
clientMethods: {
|
|
341
|
+
"ui:confirm": { in: { text: string }; out: { ok: boolean } };
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ── frontend/src/orders.ts — la porte du CLIENT ─────────────────────────────
|
|
346
|
+
import { NodefonySocket } from "nodefony/client";
|
|
347
|
+
|
|
348
|
+
const socket = NodefonySocket.fromContract<OrdersContract>({
|
|
349
|
+
url: "/orders/realtime",
|
|
350
|
+
});
|
|
351
|
+
socket.on("orders:status", (m) => console.log(m.orderId, m.at)); // m.at : string (JSON)
|
|
352
|
+
const quote = await socket.request("orders:quote", { orderId: "A-1" }); // { total: number }
|
|
353
|
+
socket.register("ui:confirm", (p) => ({ ok: p.text.length > 0 }));
|
|
354
|
+
void quote;
|
|
355
|
+
|
|
356
|
+
// ── nodefony/controllers/OrdersRealtime.ts — la porte du SERVEUR ────────────
|
|
357
|
+
import type { ContextType } from "@nodefony/http";
|
|
358
|
+
import type { ContractServerEmit } from "nodefony";
|
|
359
|
+
import { ContractRealtimeController, RealtimePolicy } from "@nodefony/realtime";
|
|
360
|
+
|
|
361
|
+
@RealtimePolicy({ "orders:quote": { roles: ["ROLE_USER"] } })
|
|
362
|
+
export class OrdersRealtime extends ContractRealtimeController<OrdersContract> {
|
|
363
|
+
constructor(context: ContextType) {
|
|
364
|
+
super("orders", context);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// TOUTES les méthodes du contrat, et cette table SEULE les sert.
|
|
368
|
+
protected realtimeActions() {
|
|
369
|
+
return {
|
|
370
|
+
"orders:quote": (p: { orderId: string }) => ({
|
|
371
|
+
total: p.orderId.length * 100,
|
|
372
|
+
}),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Facultatif, canal par canal : accepter une entrée est une permission.
|
|
377
|
+
protected override realtimeInbound() {
|
|
378
|
+
return {
|
|
379
|
+
"orders:typing": (p: { on: boolean }) => {
|
|
380
|
+
void p.on; // NON FIABLE : le type n'est pas une validation
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Le flux SSE lit la MÊME carte que la socket.
|
|
386
|
+
async statusStream(): Promise<void> {
|
|
387
|
+
const sse = await this.renderSse<ContractServerEmit<OrdersContract>>();
|
|
388
|
+
await sse.send(
|
|
389
|
+
{ orderId: "A-1", at: new Date() },
|
|
390
|
+
{ event: "orders:status" },
|
|
391
|
+
);
|
|
392
|
+
sse.close();
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
| Porte | Ce qu'elle dérive du contrat |
|
|
398
|
+
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
|
399
|
+
| `NodefonySocket.fromContract<C>()` (client) | émet `clientToServer` · reçoit `serverToClient` · appelle `serverMethods` · implémente `clientMethods` |
|
|
400
|
+
| `extends ContractRealtimeController<C>` (serveur) | émet `serverToClient` · reçoit `clientToServer` · implémente `serverMethods` · appelle `clientMethods` (`requestClient`) |
|
|
401
|
+
| `getRealtimeHub<C>()` (service, tâche, jeu) | `publish` n'accepte que les canaux de `serverToClient`, avec leur charge |
|
|
402
|
+
| `ContractRealtimePublish<C>` (fabrique de canal) | le `publish` reçu par `@RealtimeChannel` / `createRealtimeChannel`, même règle |
|
|
403
|
+
| `renderSse<ContractServerEmit<C>>()` (serveur) | `event:` = un canal de `serverToClient`, la charge est celle du canal |
|
|
404
|
+
| `@RealtimePolicy({ … })` (serveur) | chaque nom doit être une méthode ou un canal du contrat |
|
|
405
|
+
|
|
406
|
+
Les quatre clés sont **facultatives** : un tableau de bord ne déclare que `serverToClient`, et son
|
|
407
|
+
contrôleur n'écrit rien de plus que `extends ContractRealtimeController<C> {}` — la table des
|
|
408
|
+
méthodes n'est due que si le contrat déclare des `serverMethods`. Une direction non déclarée reste
|
|
409
|
+
libre.
|
|
410
|
+
|
|
411
|
+
### Diffuser depuis un service — le même contrat
|
|
412
|
+
|
|
413
|
+
Le cas le plus courant ne passe par aucun contrôleur : un service, une tâche, une boucle de jeu
|
|
414
|
+
publient. Le hub se lit à travers le contrat, et une faute de frappe ne compile plus :
|
|
415
|
+
|
|
416
|
+
```ts
|
|
417
|
+
import { getRealtimeHub } from "@nodefony/realtime";
|
|
418
|
+
import type { ChatContract } from "../shared/chat";
|
|
419
|
+
|
|
420
|
+
const chat = getRealtimeHub<ChatContract>(); // même objet hub : 0 octet, 0 ns de plus
|
|
421
|
+
chat.publish("chat:room", message); // charge vérifiée par le contrat
|
|
422
|
+
getRealtimeHub().publish("n'importe:quoi", { x: 1 }); // sans paramètre : libre, comme avant
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
Une fabrique de canal déclare son `publish` de la même façon :
|
|
426
|
+
`presence(_channel: string, publish: ContractRealtimePublish<ChatContract>)`. Le contrat est choisi
|
|
427
|
+
par l'appelant : c'est le fichier partagé qui fait foi.
|
|
428
|
+
|
|
429
|
+
> [!NOTE]
|
|
430
|
+
> **Lire les noms.** `Contract…<C>` est la même chose que sa forme libre, lue à travers le contrat :
|
|
431
|
+
> `RealtimeController` → `ContractRealtimeController<C>`, `RealtimePublish` →
|
|
432
|
+
> `ContractRealtimePublish<C>`, `RealtimeHub` → `IContractRealtimeHub<C>`. `Typed…` type un handler
|
|
433
|
+
> par ses types directs ; `…Of<X>` lit ce que porte une classe (`RealtimeContractOf`).
|
|
434
|
+
|
|
435
|
+
### Deux cas réels — l'atelier et la partie
|
|
436
|
+
|
|
437
|
+
**L'atelier : une mesure typée une fois, trois lecteurs.** Chaque machine pousse sa télémétrie
|
|
438
|
+
sur son propre canal (`machine:<id>`). Le tableau de bord la lit par la socket, l'écran d'atelier
|
|
439
|
+
— un simple navigateur, sans socket — par un flux SSE, et l'opérateur peut arrêter une machine.
|
|
440
|
+
Un seul contrat, et la charge est la MÊME aux trois endroits.
|
|
441
|
+
|
|
442
|
+
```ts
|
|
443
|
+
// ── shared/plant.ts ─────────────────────────────────────────────────────────
|
|
444
|
+
import type { IRealtimeContract } from "nodefony/client";
|
|
445
|
+
|
|
446
|
+
export interface PlantContract extends IRealtimeContract {
|
|
447
|
+
serverToClient: {
|
|
448
|
+
[machine: `machine:${string}`]: {
|
|
449
|
+
temperature: number;
|
|
450
|
+
rpm: number;
|
|
451
|
+
at: Date;
|
|
452
|
+
};
|
|
453
|
+
};
|
|
454
|
+
serverMethods: {
|
|
455
|
+
"machine:stop": {
|
|
456
|
+
in: { machineId: string; reason: string };
|
|
457
|
+
out: { stopped: boolean };
|
|
458
|
+
};
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ── frontend/src/dashboard.ts — le tableau de bord ──────────────────────────
|
|
463
|
+
import { NodefonySocket } from "nodefony/client";
|
|
464
|
+
|
|
465
|
+
const plant = NodefonySocket.fromContract<PlantContract>({
|
|
466
|
+
url: "/plant/realtime",
|
|
467
|
+
});
|
|
468
|
+
plant.on("machine:press-07", (m) => {
|
|
469
|
+
if (m.temperature > 90) {
|
|
470
|
+
void plant.request("machine:stop", {
|
|
471
|
+
machineId: "press-07",
|
|
472
|
+
reason: "surchauffe",
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// ── nodefony/controllers/PlantRealtime.ts — le serveur ──────────────────────
|
|
478
|
+
import type { ContextType } from "@nodefony/http";
|
|
479
|
+
import type { ContractServerEmit } from "nodefony";
|
|
480
|
+
import { ContractRealtimeController, RealtimePolicy } from "@nodefony/realtime";
|
|
481
|
+
|
|
482
|
+
@RealtimePolicy({
|
|
483
|
+
"machine:stop": { roles: ["ROLE_OPERATOR"] }, // arrêter une machine : un rôle
|
|
484
|
+
"machine:*": { authenticated: true }, // lire la télémétrie : être connecté
|
|
485
|
+
})
|
|
486
|
+
export class PlantRealtime extends ContractRealtimeController<PlantContract> {
|
|
487
|
+
constructor(context: ContextType) {
|
|
488
|
+
super("plant", context);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
protected realtimeActions() {
|
|
492
|
+
return {
|
|
493
|
+
"machine:stop": (p: { machineId: string; reason: string }) => ({
|
|
494
|
+
stopped: p.machineId.length > 0 && p.reason.length > 0,
|
|
495
|
+
}),
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// L'écran d'atelier : UN flux = UN canal, sans champ `event:`.
|
|
500
|
+
async machineStream(machineId: string): Promise<void> {
|
|
501
|
+
const sse =
|
|
502
|
+
await this.renderSse<
|
|
503
|
+
Pick<ContractServerEmit<PlantContract>, `machine:${string}`>
|
|
504
|
+
>();
|
|
505
|
+
await sse.send({ temperature: 71, rpm: 1450, at: new Date() });
|
|
506
|
+
void machineId;
|
|
507
|
+
sse.close();
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
**La partie : les entrées du joueur, typées jusque dans le serveur.** Le client envoie ses
|
|
513
|
+
déplacements soixante fois par seconde ; le serveur les reçoit typés, sans jamais les croire, et
|
|
514
|
+
pousse l'état de la partie. Aucune méthode n'est demandée au serveur : la table est vide.
|
|
515
|
+
|
|
516
|
+
```ts
|
|
517
|
+
// ── shared/arena.ts ─────────────────────────────────────────────────────────
|
|
518
|
+
import type { IRealtimeContract } from "nodefony/client";
|
|
519
|
+
|
|
520
|
+
export interface ArenaContract extends IRealtimeContract {
|
|
521
|
+
clientToServer: { "arena:input": { dx: number; dy: number; seq: number } };
|
|
522
|
+
serverToClient: {
|
|
523
|
+
"arena:state": {
|
|
524
|
+
tick: number;
|
|
525
|
+
players: Array<{ id: string; x: number; y: number }>;
|
|
526
|
+
};
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// ── frontend/src/arena.ts — le joueur ───────────────────────────────────────
|
|
531
|
+
import { NodefonySocket } from "nodefony/client";
|
|
532
|
+
|
|
533
|
+
const arena = NodefonySocket.fromContract<ArenaContract>({
|
|
534
|
+
url: "/arena/realtime",
|
|
535
|
+
});
|
|
536
|
+
let seq = 0;
|
|
537
|
+
arena.publish("arena:input", { dx: 1, dy: 0, seq: ++seq });
|
|
538
|
+
arena.on("arena:state", (s) => console.log(s.tick, s.players.length));
|
|
539
|
+
|
|
540
|
+
// ── nodefony/controllers/ArenaRealtime.ts — le serveur ──────────────────────
|
|
541
|
+
import type { ContextType } from "@nodefony/http";
|
|
542
|
+
import { ContractRealtimeController } from "@nodefony/realtime";
|
|
543
|
+
|
|
544
|
+
const MAX_STEP = 1;
|
|
545
|
+
|
|
546
|
+
export class ArenaRealtime extends ContractRealtimeController<ArenaContract> {
|
|
547
|
+
constructor(context: ContextType) {
|
|
548
|
+
super("arena", context);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Aucune méthode serveur dans ce contrat : aucune table à écrire.
|
|
552
|
+
|
|
553
|
+
// L'entrée est TYPÉE, mais vient du réseau : bornée avant d'être appliquée.
|
|
554
|
+
protected override realtimeInbound() {
|
|
555
|
+
return {
|
|
556
|
+
"arena:input": (input: { dx: number; dy: number; seq: number }) => {
|
|
557
|
+
const dx = Math.max(-MAX_STEP, Math.min(MAX_STEP, input.dx));
|
|
558
|
+
const dy = Math.max(-MAX_STEP, Math.min(MAX_STEP, input.dy));
|
|
559
|
+
this.notifyClient("arena:state", {
|
|
560
|
+
tick: input.seq,
|
|
561
|
+
players: [{ id: "p1", x: dx, y: dy }],
|
|
562
|
+
});
|
|
563
|
+
},
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
Le serveur d'une vraie partie ne pousse évidemment pas l'état à chaque entrée : il les accumule et
|
|
570
|
+
diffuse l'état à cadence fixe, par un canal partagé — voir
|
|
571
|
+
[`cadence-et-contre-pression.md`](./cadence-et-contre-pression.md).
|
|
572
|
+
|
|
573
|
+
### Ce que chaque bout reçoit — ce que JSON en fait
|
|
574
|
+
|
|
575
|
+
Ce que j'**envoie** est le type déclaré ; ce que je **reçois** est `Jsonify<T>` : ce que JSON rend
|
|
576
|
+
de ce type, quel que soit le codec. Une `Date` arrive en `string`, un membre fonction ou
|
|
577
|
+
`undefined` disparaît, `bigint` n'a pas de forme JSON. Seul `Uint8Array` reste `Uint8Array` — en
|
|
578
|
+
chaîne d'octets native sous le codec CBOR, sous la convention `{"$bytes":"<base64url>"}` sur une
|
|
579
|
+
connexion texte dont l'application a activé le binaire, annoncée par le champ `bytes` de l'accueil :
|
|
580
|
+
un client sans codec les relit aussi en `Uint8Array` (ADR-0022 §2).
|
|
581
|
+
|
|
582
|
+
| Qui reçoit | Quoi | Type reçu |
|
|
583
|
+
| -------------------------------------------------------- | ---------------- | ------------ |
|
|
584
|
+
| le client, `on(…)` | `serverToClient` | `Jsonify<T>` |
|
|
585
|
+
| le serveur, entrée d'un canal | `clientToServer` | `Jsonify<T>` |
|
|
586
|
+
| qui **implémente** une méthode | son `in` | `Jsonify<T>` |
|
|
587
|
+
| qui **appelle** une méthode (`request`, `requestClient`) | son `out` | `Jsonify<T>` |
|
|
588
|
+
| un composant, hook de canal (socket en tête) | `serverToClient` | `Jsonify<T>` |
|
|
589
|
+
|
|
590
|
+
`serverToClient: { meeting: { start: Date } }` : sans cette règle, `m.start.getTime()` compilait
|
|
591
|
+
et plantait au navigateur ; avec elle, `m.start` est une `string` et l'éditeur le souligne.
|
|
592
|
+
|
|
593
|
+
### Lire dans une vue — le hook prend la socket du contrat
|
|
594
|
+
|
|
595
|
+
Dans React, Vue, Svelte ou Angular, la lecture passe par la **même** socket typée, donnée en
|
|
596
|
+
premier argument du hook de canal. Le nom est contraint aux canaux de `serverToClient`, la charge
|
|
597
|
+
est déduite — aucun générique écrit à la main, aucune carte recopiée.
|
|
598
|
+
|
|
599
|
+
```ts
|
|
600
|
+
export const chat = NodefonySocket.fromContract<ChatContract>({
|
|
601
|
+
url: "/chat/realtime",
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
const presence = useNodefonyChannelData(chat, "chat:presence", { present: [] }); // React, Vue
|
|
605
|
+
useNodefonyChannel(chat, "chat:room", (m) => add(m)); // m : Jsonify<ChatMessage>
|
|
606
|
+
nodefonyChannelData(chat, "chat:presence"); // Svelte
|
|
607
|
+
injectNodefonyChannelData(chat, "chat:presence"); // Angular
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
Les hooks lisent les cartes de `NodefonySocket.on` (`client/realtime/observe.ts#ChannelPayload`) :
|
|
611
|
+
une seule règle, quatre liaisons. La socket reste celle de l'application — le hook ne l'ouvre ni
|
|
612
|
+
ne la ferme. La forme historique `useNodefonyChannelData<T>("canal")` reste valide, mais son `<T>`
|
|
613
|
+
n'est vérifié par rien : une charge qui change côté serveur n'y casse aucune compilation.
|
|
614
|
+
|
|
615
|
+
### Ce que le compilateur refuse
|
|
616
|
+
|
|
617
|
+
| Faute | Où elle est refusée |
|
|
618
|
+
| -------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
|
619
|
+
| une méthode de `serverMethods` **oubliée** | `realtimeActions()` du contrôleur |
|
|
620
|
+
| un handler de **forme fausse** (paramètres ou résultat) | `realtimeActions()` / `realtimeInbound()` |
|
|
621
|
+
| un nom de **politique** hors contrat (`"orders:qoute"`) | `@RealtimePolicy` — l'erreur affiche `notInContract: "orders:qoute"` |
|
|
622
|
+
| un canal émis **hors** `serverToClient` | `notifyClient`, `realtimeChannels()`, `renderSse` |
|
|
623
|
+
| une entrée acceptée sur un canal que le **serveur** émet (inversion) | `realtimeInbound()` |
|
|
624
|
+
| une charge fausse sur un canal ou une méthode connus | client et serveur |
|
|
625
|
+
| une diffusion de service hors contrat (nom ou charge) | `getRealtimeHub<C>().publish`, `ContractRealtimePublish<C>` |
|
|
626
|
+
| une charge **scalaire** (`3`, `"ok"`) — refusée aussi à l'exécution | `getRealtimeHub<C>().publish`, `ContractRealtimePublish<C>` |
|
|
627
|
+
| un canal lu **hors** `serverToClient` dans une vue | hooks de canal React, Vue, Svelte, Angular (socket en tête) |
|
|
628
|
+
|
|
629
|
+
> [!IMPORTANT]
|
|
630
|
+
> **La table sert SEULE les méthodes d'un contrat — c'est une règle de performance.** N'ajoute pas
|
|
631
|
+
> `@RealtimeAction` à une méthode déjà servie par la table : au handshake, le décorateur fabrique
|
|
632
|
+
> un handler que la table écrase aussitôt, à **chaque connexion**, avec un appel de plus par
|
|
633
|
+
> requête, et l'injection des paramètres décorés (`@Body`…) saute sans un mot. La politique d'une
|
|
634
|
+
> méthode se pose sur la classe, par `@RealtimePolicy`, écrite une seule fois. Des handlers qui
|
|
635
|
+
> n'ont pas besoin de `this` se rangent dans une table **constante** du module, rendue telle
|
|
636
|
+
> quelle : aucune allocation par connexion.
|
|
637
|
+
|
|
638
|
+
À savoir encore :
|
|
639
|
+
|
|
640
|
+
- **Le type n'est pas une validation.** Les paramètres reçus restent NON FIABLES ; la garde de
|
|
641
|
+
canal (politique, `allow`) s'applique à toutes les livraisons.
|
|
642
|
+
- **Côté client, les noms de canaux restent ouverts** (canaux dynamiques, de plateforme) :
|
|
643
|
+
`publish` et `on` acceptent tout nom, et typent la charge d'un nom déclaré.
|
|
644
|
+
- **`realtimeChannels()` surchargée** : TypeScript ne donne pas son type de retour à une méthode
|
|
645
|
+
qui surcharge ; écrire `["orders:status" as const]` ou annoter le retour.
|
|
646
|
+
- **Un `renderSse()` sans paramètre** reste un flux libre (la porte MCP en est un).
|
|
647
|
+
- **Le webhook** lira la même carte quand l'émission applicative existera (en 10.1, #70).
|
|
648
|
+
|
|
307
649
|
## 🔐 Autorisation — qui peut appeler quoi
|
|
308
650
|
|
|
309
651
|
> [!IMPORTANT]
|
|
@@ -339,7 +681,24 @@ browse() { … }
|
|
|
339
681
|
```
|
|
340
682
|
|
|
341
683
|
Ouvrir une action reste donc possible — mais c'est désormais un acte **délibéré et lisible à la
|
|
342
|
-
déclaration**, au lieu d'un silence.
|
|
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
|
|
|
@@ -565,6 +966,7 @@ passe par le pont pour tout ce qui est déjà une route. Le détail du pont vit
|
|
|
565
966
|
| `-32601 method not found` | nom mal orthographié, ou action déclarée sur un **autre** endpoint | vérifier `socket.serverMethods` — c'est la liste réelle de CETTE connexion |
|
|
566
967
|
| `-32603 internal error` sans détail | un `throw` ordinaire est rendu opaque au client (Zero Trust) | lever une `RpcError` avec un code et un message publiables |
|
|
567
968
|
| `api.request` rejeté avec `data.status: 422` | la route invoquée valide son entrée (`schema`, ou une `ZodError` de l'action) | lire `data.fields` : chaque champ fautif, sa partie (`in`) et son message |
|
|
969
|
+
| `api.request` rejeté avec `data.status: 429` | la route borne le débit de l'appelant | attendre `data.retryAfter` secondes (l'en-tête `Retry-After` de la même route en HTTP) |
|
|
568
970
|
| `-32001 unauthorized` sur une action légitime | le nom commence par `nodefony:`, le namespace réservé à la plateforme | renommer hors de `nodefony:`, ou obtenir `ROLE_NODEFONY_ADMIN` |
|
|
569
971
|
| Une action est refusée à un anonyme alors qu'on la croyait publique | une action est fermée par défaut (`{ authenticated: true }`) | déclarer `{ authenticated: false }` pour l'ouvrir ; un rôle se pose par une règle de préfixe |
|
|
570
972
|
| Un travail relancé crée deux jobs | action non idempotente rejouée après une reconnexion | action compagnon d'annulation, ou identifiant fourni par l'appelant + mémorisation |
|