Skip to content

ServerEvents fr

Edgar Mesquita edited this page Oct 14, 2026 · 1 revision

Événements serveur

🌐 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.

Entendre un topic

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 dans OnUnmount. 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'IServerEvents du 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 decimal et un long se lisent comme ils se lisent en C#, et de même l'argument de type d'un record générique (le Value de Box<long> arrive comme un long).
  • Un refus dit pourquoi. onRefused reçoit un ServerTopicRefusal(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) ou Failed (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.

La connexion

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.

Publier

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).

Qui peut entendre un topic

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} et room:{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é comme Unknown : 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 : un AuthorizationHandler<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é.
  • UseServerEvents peut ê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 UseServerEvents le dit à ses pages. Un abonnement là est refusé aussitôt comme Unknown, et la console du navigateur nomme UseServerEvents, plutôt que la page n'ouvre un flux que rien ne répond.

Ce qui se passe autour d'un abonnement

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.

Plusieurs instances

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.

Limites

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.

Sur le câble

  • GET /_equantic/events est un flux Server-Sent Events : son premier événement remet à la page l'id de sa connexion, chaque charge est un événement message portant { "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}/subscribe et …/release, avec { "topic": "…" }, relient et libèrent un topic : 204 quand c'est fait, 403 avec la raison, 404 pour 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 (415 sinon) 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 fetch no-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.

Pas encore là

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.

Clone this wiki locally