Repository navigation
ServerEvents fr
🌐 Cette page en : English · Português · Español · Français · Deutsch
Ce que le serveur publie, entendu par un composant pendant que la personne regarde : un prix qui bouge, un message dans une salle, une commande qui a changé d'état. Le composant s'abonne à un topic typé, n'importe quel code sur le serveur publie vers le même topic, et le SDK le porte sur une connexion par page et remet au composant chaque charge sous le type C# qu'elle était.
Depuis 0.2.0-preview.61
public sealed record RoomMessage(string Author, string Text, long Sequence);
public static class Topics
{
public static ServerTopic<RoomMessage> Room(string id) => new($"room:{id}");
}Un topic est une valeur. Son nom est celui de l'app, sans convention imposée, et son paramètre de
type est le contrat : Subscribe remet au composant un Action<RoomMessage>, PublishAsync prend
un RoomMessage, donc une charge du mauvais type est une erreur de compilation plutôt qu'un
undefined dans le navigateur. Déclarez-le une fois, dans le C# de l'app, et utilisez la même valeur
des deux côtés.
Construisez un topic là où son type de charge est concret, comme ci-dessus. Un topic construit là où
ce type est un paramètre de type, dans une aide comme static ServerTopic<T> Topic<T>(string name),
casse la compilation avec EQ2013 : JavaScript efface le type là, et le topic ne
pourrait pas ranimer ce qu'il reçoit. Un topic que la page reçoit du serveur, le résultat d'une
Server Action ou l'état d'une page, garde le type de sa charge.
IServerEvents est une capacité, prise par le constructeur comme INetworkStatus :
public sealed class RoomScreen(IServerEvents? events) : StatefulComponent
{
private readonly List<RoomMessage> _messages = [];
private IDisposable? _room;
private IDisposable? _connection;
private ServerConnectionState _state;
private bool _refused;
protected override void OnMount()
{
_room = events?.Subscribe(Topics.Room("lobby"),
message => SetState(() => _messages.Add(message)),
refusal => SetState(() => _refused = true));
_connection = events?.OnConnectionChanged(connection => SetState(() => _state = connection.State));
}
protected override void OnUnmount()
{
_room?.Dispose();
_connection?.Dispose();
}
public override VisualNode Build(ComponentContext context) => _refused
? Text("This room is not open to you.", TypeRole.BodyM)
: Column(gap: Space.S2, children: [
Text(_state == ServerConnectionState.Connected ? "Live" : "Catching up", TypeRole.Caption),
.. _messages.Select(message => Text(message.Text, TypeRole.BodyM)),
]);
}-
Abonnez-vous dans
OnMount, libérez dansOnUnmount. Libérer deux fois ne pose pas de problème. Une page qui se rend sur le serveur ne s'abonne à rien là-bas : l'IServerEventsdu serveur ne se connecte jamais, donc le même composant se rend, et celui du navigateur s'abonne dès qu'il hydrate. - Une connexion par page. Le premier abonnement l'ouvre et le dernier la ferme, et chaque topic que la page tient voyage dessus, quel que soit le composant qui s'est abonné.
-
La charge arrive ranimée. Elle est écrite dans le format de câble du résultat d'une Server
Action et ranimée de la même façon, donc un record, un enum, un
decimalet unlongse lisent comme ils se lisent en C#, et de même l'argument de type d'un record générique (leValuedeBox<long>arrive comme unlong). -
Un refus dit pourquoi.
onRefusedreçoit unServerTopicRefusal(Topic, Reason):Forbidden(une règle a correspondu et a refusé cette requête),Unknown(aucune règle ne correspond au topic),LimitReached(la page tient autant de topics que le serveur permet) ouFailed(le serveur, ou le réseau, n'a pas répondu, demandé quatre fois). L'abonnement ne reçoit alors rien. Sans gestionnaire, la console du navigateur dit quel topic et pourquoi.
Connection répond où elle en est maintenant, comme un ServerConnection(State, LastEventId), et
OnConnectionChanged entend chaque changement d'état : Connecting, Connected, Reconnecting,
Disconnected.
Quand la connexion tombe (un réseau qui a cligné, un serveur qui a redémarré, un déploiement) elle
est rouverte d'elle-même et chaque abonnement vivant est relié. Connected est signalé une fois
qu'ils sont reliés, avec l'id du dernier événement que la page a reçu, donc une page qui demande au
serveur ce qu'elle a manqué, par une Server Action, demande quand rien de plus
ne peut être manqué entre-temps. La livraison est au plus une fois ; l'id est la façon dont une page
se rattrape.
Un lien pour lequel le serveur ne peut pas répondre est redemandé avant que la page en soit informée : un flux qui vient de se terminer est inconnu du serveur un instant avant que le navigateur le voie finir, et un proxy répond pour une instance qu'un déploiement emporte. La page redemande après une demi-seconde, une seconde et deux secondes, sur la connexion qu'elle a alors, et seul un quatrième échec d'affilée refuse le topic. Une libération restée sans réponse est redemandée de la même façon, et au dernier échec la page rouvre son flux, ce qui libère tout ce que l'ancien tenait. Chaque requête a dix secondes, sa réponse comprise, et se termine aussitôt que son flux tombe. Un lien resté sans réponse a peut-être tout de même relié le topic, donc la page libère aussi celui-là quand elle le lâche.
public sealed class RoomService(IServerEventPublisher events)
{
public ValueTask PostAsync(string room, RoomMessage message, CancellationToken cancellationToken) =>
events.PublishAsync(Topics.Room(room), message,
new ServerEventPublishOptions { Id = message.Sequence.ToString(CultureInfo.InvariantCulture) },
cancellationToken);
}N'importe quel code sur le serveur publie : une Server Action, un service hébergé, le gestionnaire
d'un webhook. L'id est optionnel et voyage comme l'id de l'événement (LastEventId sur la page) ; il
ne peut pas contenir de saut de ligne. Une charge plus grande que la limite lève là où elle est
publiée, en nommant le topic et le réglage.
Une classe du projet de l'app qui ne tourne jamais que sur le serveur, y compris un service hébergé
qui publie, est marquée [ServerOnly], ou le compilateur essaie de la construire pour le navigateur
et le dit (EQ2004).
Rien n'est entendu avant que l'app dise qui peut l'entendre :
builder.Services.AddUI(options => options
.ScanAssembly(typeof(Program).Assembly)
.UseServerEvents(events => events
.Topic("prices", rule => rule.AllowAnonymous())
.Topic("room:{roomId}", rule => rule.RequireAuthorization("RoomMember"))
.Topic("user:{userId}", rule => rule.Authorize(topic =>
topic.Values["userId"] == topic.HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier)))));-
Les modèles sont des routes. Un topic est comparé à chaque modèle avec la syntaxe de routes
d'ASP.NET Core, et c'est le modèle dont le texte littéral fixe le plus du topic qui l'emporte :
room:{roomId}etroom:{roomId}:participant:{participantId}correspondent tous deux àroom:a:participant:7, et le second décide. Les modèles qui fixent autant l'emportent tous, et chacun doit permettre l'abonnement. La valeur par défaut d'un paramètre remplit un topic qui l'omet :room/{roomId=lobby}correspond àroom. Un topic auquel aucun modèle ne correspond est refusé commeUnknown: fermé par défaut. -
RequireAuthorization()sans politique demande la politique par défaut de l'app, un utilisateur authentifié sauf si l'app l'a changée. Avec des politiques, elles sont évaluées comme le middleware d'autorisation d'ASP.NET Core évalue celles d'un point de terminaison : combinées, leurs schémas d'authentification authentifiés d'abord, donc une politique restreinte à un jeton bearer n'est satisfaite que par l'utilisateur de ce jeton, jamais par celui du cookie. Le topic est leur ressource : unAuthorizationHandler<TRequirement, ServerTopicContext>lit les valeurs du modèle (topic.Values["roomId"]), le nom du topic, l'id de la connexion et la requête. -
Authorize(…)prend un délégué, synchrone ou asynchrone, pour une vérification pour laquelle une politique serait lourde. -
Au démarrage, une règle qui ne dit rien échoue, et de même un paramètre contraint
(
{id:int}) : une contrainte refuserait en silence un topic que l'app a écrit elle-même. - Les points de terminaison des événements admettent les requêtes anonymes, donc la politique d'autorisation de repli d'une app ne refuse pas la connexion elle-même : la règle de chaque topic décide, et une connexion ne porte rien jusqu'à ce qu'un topic lui soit relié.
-
UseServerEventspeut être appelé plus d'une fois. Une bibliothèque et l'app peuvent chacune déclarer leurs topics : chaque appel s'ajoute à une seule configuration, servie par un seul ensemble de points de terminaison. Un topic que les deux déclarent est régi par les deux, chaque règle le permettant ou non, donc l'app resserre un topic qu'une bibliothèque a déclaré, et rien ne le desserre. -
Une app qui n'appelle jamais
UseServerEventsle dit à ses pages. Un abonnement là est refusé aussitôt commeUnknown, et la console du navigateur nommeUseServerEvents, plutôt que la page n'ouvre un flux que rien ne répond.
IServerEventHandler entend une connexion s'ouvrir et se fermer et un topic se relier et se libérer,
chacun avec la requête en main et les valeurs du modèle. La présence, l'occupation et l'audit se
construisent dessus :
public sealed class RoomPresence(IRoomRegistry rooms) : IServerEventHandler
{
public ValueTask OnSubscribedAsync(ServerTopicContext topic) =>
rooms.JoinAsync(topic.Values["roomId"]!, topic.ConnectionId);
public ValueTask OnReleasedAsync(ServerTopicContext topic) =>
rooms.LeaveAsync(topic.Values["roomId"]!, topic.ConnectionId);
}
// …
.UseServerEvents(events => events
.Topic("room:{roomId}", rule => rule.RequireAuthorization())
.AddHandler<RoomPresence>())Un topic libéré par la page, et chaque topic d'une connexion qui se termine, est entendu comme libéré. Un gestionnaire qui lève est journalisé, et les autres l'entendent quand même. Les gestionnaires d'une connexion entendent ses transitions dans l'ordre : une entrée encore attendue quand le flux se ferme est entendue avant la libération de ce topic et la déconnexion, donc un gestionnaire qui ne retourne jamais retient la fermeture de sa connexion.
IServerEventBackplane porte chaque événement publié vers chaque instance de l'app ; celui par
défaut les garde dans le processus, ce qui est tout ce dont une instance a besoin. Une app qui en
fait tourner plusieurs enregistre le sien, sur du pub/sub Redis ou un bus de services, avec
UseBackplane<T>() : il publie un ServerEventEnvelope (le topic, la charge déjà écrite, l'id) et
remet à l'instance chacun qu'il reçoit.
Une page relie ses topics avec des requêtes qui nomment sa connexion, donc derrière un répartiteur de
charge elles doivent atteindre l'instance qui tient son flux : l'affinité de session, comme
SignalR le demande. Un lien qui continue d'atteindre une autre instance est signalé comme Failed
après quatre tentatives, et la console du navigateur nomme la cause.
Liées depuis EQuantic:ServerEvents dans appsettings.json, ou posées avec Configure(…) sur le
builder :
| Clé | Par défaut | Ce qu'elle borne |
|---|---|---|
HeartbeatInterval |
00:00:15 |
Combien de temps une connexion reste silencieuse avant qu'un battement empêche les proxys de la fermer |
MaxTopicsPerConnection |
64 |
Les topics qu'une page peut tenir à la fois |
MaxPayloadBytes |
65536 |
La taille d'une charge, vérifiée là où elle est publiée |
MaxQueuedEventsPerConnection |
256 |
Les événements qui attendent une page qui lit lentement ; au-delà la connexion se ferme, et la page se reconnecte |
{
"EQuantic": {
"ServerEvents": {
"MaxTopicsPerConnection": 16
}
}
}Une valeur avec laquelle les connexions ne peuvent pas fonctionner, un battement de zéro ou moins ou une limite sous un, empêche l'app de démarrer, et l'erreur nomme la clé et la valeur qu'elle a reçue.
-
GET /_equantic/eventsest un flux Server-Sent Events : son premier événement remet à la page l'id de sa connexion, chaque charge est un événementmessageportant{ "topic", "payload" }, et une ligne de commentaire est le battement. Il n'est jamais mis en cache, jamais compressé, et demande à un proxy inverse de ne pas le mettre en tampon (X-Accel-Buffering: no). -
POST /_equantic/events/{connection}/subscribeet…/release, avec{ "topic": "…" }, relient et libèrent un topic :204quand c'est fait,403avec la raison,404pour une connexion que le serveur ne connaît pas. Rien de ce qu'une page envoie ne publie ; le chemin de la page vers le serveur est une Server Action. - Leur corps doit être du JSON (
415sinon) de 8 Ko au plus (413). Un topic est autorisé comme la requête qui le relie, cookies compris, donc un corps qu'un autre site pourrait envoyer, celui d'un formulaire ou d'un fetchno-cors, laisserait ce site relier les topics de ses visiteurs à un flux qu'il a ouvert ; un corps JSON depuis une autre origine a besoin du préliminaire du navigateur, que la politique CORS de l'app refuse. - Sur HTTP/1.1 un navigateur ouvre six connexions par origine, et chaque onglet tient un flux. HTTP/2, celui par défaut de Kestrel sur TLS, les porte tous sur une seule.
- Quand l'app s'arrête, chaque flux se termine, et chaque page se reconnecte : à cette instance dès qu'elle est revenue, ou à une autre dans un déploiement progressif.
- Le client fait partie du runtime que l'app sert déjà. Aucun script ne vient d'ailleurs.
Une Server Action qui diffuse (IAsyncEnumerable<T>), la présence comme fonctionnalité à part
entière, la relecture depuis l'id du dernier événement, un transport WebSocket, un paquet de
backplane Redis, et la réalisation sur Photon. Voir la Feuille de route.
🏁 Commencer ici
🏗️ Architecture
- Vue d'ensemble de l'architecture
- Composants write-once
- Surface déclarative
- Architecture des paquets
- Composants
- Styles
- Localisation
- Analytics et GTM
📱 Write-once
- Moteur Photon
- Design System
- Capacités
- Stockage
- Formulaires
- Éditeur de code
- Markdown
- Mermaid
- Rendu des e-mails
⚙️ Compilation
- Compilateur
- Fonctionnalités C# prises en charge
- Résolution des types externes
- Flux de build
- Diagnostics
⚡ Runtime
🔌 Serveur
🎨 Écosystème
- Image · Graphiques · Icônes · Lottie
🚀 Développement