Wolikhai.Realtime.Sdk 1.0.0

Wolikhai.Realtime.Sdk

Intégration des backends métier .NET au service de notification centralisé Wolikhai.

Deux responsabilités, et rien d'autre :

  • publier — demandes de notification et enregistrements d'appareils, vers Kafka ;
  • émettre — les jetons courts qui autorisent vos fronts à ouvrir une connexion temps réel.

Les applications qui reçoivent les notifications temps réel sont en React et Angular : elles utilisent @wolikhai/realtime-client. Ce paquet n'ouvre jamais de connexion SignalR — il se contente de signer les jetons qui permettent aux fronts de le faire.

Installation

Le paquet est publié sur Azure Artifacts, pas sur nuget.org. Il faut donc déclarer le flux et s'authentifier — une fois par machine.

# Fournisseur d'identifiants Azure Artifacts : il gère le renouvellement des
# jetons, contrairement à un jeton d'accès personnel écrit à la main.
iex "& { $(irm https://aka.ms/install-artifacts-credprovider.ps1) }"

NuGet.config à la racine de votre solution :

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <!--
      <clear /> est important : sans lui, les sources héritées de la
      configuration globale de la machine s'ajoutent — dont d'autres flux privés
      qui feront échouer la restauration avec un 401 sans aucun rapport avec ce
      paquet.
    -->
    <clear />
    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
    <add key="wolikhai" value="https://pkgs.dev.azure.com/clartic/_packaging/{FLUX}/nuget/v3/index.json" />
  </packageSources>
</configuration>

⚠️ Ne pas ajouter de <packageSourceCredentials> à la main. Le fournisseur s'en charge, et une section malformée fait échouer la restauration de tous les projets de la solution avec « Un élément d'informations d'identification doit avoir un nom d'utilisateur et un mot de passe ».

dotnet add package Wolikhai.Realtime.Sdk

En intégration continue, poser VSS_NUGET_EXTERNAL_FEED_ENDPOINTS avec un jeton d'accès personnel plutôt que d'attendre une fenêtre d'authentification.

net10.0. Dépend de Confluent.Kafka et de Microsoft.IdentityModel.JsonWebTokens.


Table des matières

  1. Le modèle mental
  2. Mise en place
  3. Configuration
  4. Envoyer une notification
  5. Gérer les appareils
  6. Émettre un jeton temps réel
  7. Erreurs et exceptions
  8. Tester votre code
  9. Référence de l'API
  10. Pièges
  11. Diagnostic

Le modèle mental

Votre backend décide quoi dire et à qui. Le service achemine. Vous ne connaissez ni FCM, ni APNs, ni SignalR.

                     ┌─── SendAsync ─────────┐
votre backend ───────┤                       ├──▶ Kafka ──▶ service ──▶ FCM / APNs
                     └─── Register/Revoke ───┘                      └──▶ Hub SignalR
                                                                            ▲
                     ┌─── Issue ─────────────┐                              │
                     │  jeton court signé    ├──────── votre front ─────────┘
                     └───────────────────────┘

Trois idées structurent tout le reste :

Vous ne détenez que votre secret. Il vaut la capacité d'émettre un jeton pour n'importe lequel de vos utilisateurs — pas ceux des autres produits. Chaque backend a le sien.

Publier n'est pas remettre. L'appel retourne quand le courtier a accusé réception. La remise dépend ensuite de FCM, d'APNs et de l'état de l'appareil.

L'identité vient toujours de votre session authentifiée. Ni le userId d'un envoi, ni celui d'un jeton ne doivent jamais provenir du corps d'une requête cliente.


Mise en place

builder.Services.AddWolikhaiNotifications(options =>
    builder.Configuration
        .GetSection(WolikhaiRealtimeBackendOptions.SectionName)
        .Bind(options));

Cela enregistre deux services :

Service Durée de vie Rôle
IWolikhaiNotifications Singleton Publication
IRealtimeTokenIssuer Singleton Émission de jetons

Pourquoi singleton. IWolikhaiNotifications détient un producteur Kafka, qui gère son propre pool de connexions, ses tampons et son fil d'arrière-plan. En créer un par requête épuiserait les sockets et perdrait tout le bénéfice du regroupement. L'injecter dans un service à portée de requête reste correct — c'est l'usage normal.

L'enregistrement utilise TryAdd : une application ayant déjà substitué sa propre implémentation — un faux pour ses tests — la conserve.


Configuration

Section conseillée : WolikhaiRealtime.

{
  "WolikhaiRealtime": {
    "AppId": "amagn",
    "BootstrapServers": "kafka:9092",

    // ⚠️ JAMAIS dans un fichier versionné.
    "RealtimeSigningSecret": "…",

    "RealtimeAudience": "wolikhai-realtime",
    "RealtimeTokenLifetimeMinutes": 30,
    "PublishTimeoutSeconds": 10
  }
}
Option Défaut Rôle
AppId — Requis. Votre identifiant d'application.
BootstrapServers — Requis pour publier. Courtiers Kafka, séparés par des virgules.
RealtimeSigningSecret — Requis pour émettre des jetons. 32 caractères minimum.
DeviceTokenEventsTopic device-token-events Topic des appareils (§7.1).
NotificationRequestsTopic notification-requests Topic des demandes (§7.2).
RealtimeAudience wolikhai-realtime Audience placée dans les jetons.
RealtimeTokenLifetimeMinutes 30 Durée de vie des jetons du Hub.
PublishTimeoutSeconds 10 Attente d'un accusé, et vidage des tampons à l'arrêt.

AppId sert à trois choses à la fois

Cloisonnement de vos données côté service, émetteur (iss) de vos jetons, et clé de partition Kafka. Il doit correspondre exactement à celui déclaré dans la console, à la casse près. Une divergence ne produit pas d'erreur à la publication : les messages partent, et le service les traite pour une application qui n'existe pas.

Les deux modes fonctionnent séparément

Un backend qui n'envoie que des push n'a pas besoin de RealtimeSigningSecret — la publication fonctionne sans. Symétriquement, l'émission de jetons ne demande pas BootstrapServers. La validation est faite au bon endroit : le producteur échoue au démarrage si BootstrapServers manque, l'émetteur échoue à l'émission si le secret manque.


Envoyer une notification

public sealed class OrderService(IWolikhaiNotifications notifications)
{
    public async Task ConfirmAsync(Order order, CancellationToken ct)
    {
        // … logique métier …

        await notifications.SendAsync(new NotificationRequest
        {
            UserId = order.CustomerId,
            Title = "Commande confirmée",
            Body = $"Votre commande {order.Reference} est en préparation.",
            CorrelationId = $"order-confirmed:{order.Id}",
            Data = new Dictionary<string, string>
            {
                ["screen"] = "order-detail",
                ["orderId"] = order.Id,
            },
            Priority = NotificationPriorities.High,
        }, ct);
    }
}

CorrelationId — le champ qui mérite trente secondes

C'est une clé d'idempotence, pas un identifiant de trace. Deux publications portant la même valeur pour la même application ne produisent qu'un seul envoi.

Valeur Conséquence
$"order-confirmed:{order.Id}" Une réémission après incident est inoffensive
Guid.NewGuid().ToString() Chaque réémission renvoie la notification

La seconde forme ne produit aucune erreur. Elle annule simplement la protection — et personne ne s'en aperçoit avant le jour où un incident déclenche trois envois chez un client.

Il sert aussi de fil conducteur : c'est par lui que la console regroupe les traces d'un même envoi, et par lui qu'on reconstitue un parcours dans les journaux du service.

Priority

NotificationPriorities.High réveille l'appareil et contourne les regroupements d'économie d'énergie. À réserver à ce qui justifie d'interrompre quelqu'un : en abuser fait dégrader la priorité de votre application entière par le système d'exploitation.

Data

Sert à la navigation contextuelle — un identifiant, un nom d'écran. Ces valeurs transitent par FCM et APNs, donc par des infrastructures tierces : rien de sensible n'a sa place dedans.

OccurredAt

Par défaut l'instant de l'appel. À renseigner explicitement pour une publication en différé : le service écarte les demandes trop anciennes, et lui donner l'heure de publication plutôt que celle de l'événement contournerait ce contrôle.

Envoyer à plusieurs destinataires

await notifications.SendManyAsync(
    subOrders.Select(so => new NotificationRequest
    {
        UserId = so.StoreOwnerId,
        Title = "Nouvelle commande",
        Body = $"Sous-commande {so.Reference}.",
        CorrelationId = $"suborder-created:{so.Id}",
    }), ct);

Ce n'est pas qu'une boucle. Enchaîner des SendAsync séquentiels paie un aller-retour réseau par destinataire : notifier trente boutiques prendrait trente fois la latence du courtier. Ici, les messages sont remis au producteur d'un bloc, qui les regroupe, et l'attente porte sur l'ensemble des accusés.

Ce n'est pas une transaction. Chaque message est indépendant : un échec partiel laisse les autres publiés. Mais chaque demande gardant son propre CorrelationId, donc sa propre garde d'idempotence, republier le lot est sans danger — ce qui était déjà parti ne repartira pas.

Les demandes sont toutes validées avant qu'aucune ne parte : une faute de saisie en milieu de lot ne laisse pas la moitié des messages publiés.

Ce que « envoyé » veut dire

L'appel retourne quand le courtier a accusé réception, pas quand l'utilisateur a reçu. Un sent dans l'historique de la console signifie que FCM ou APNs a accepté la notification — aucun des deux n'accuse la réception réelle.


Gérer les appareils

Sans cette étape, aucune notification push n'atteint qui que ce soit : le service ne connaît pas les appareils.

// À l'ouverture de session, ET à chaque démarrage de l'application cliente.
await notifications.RegisterDeviceAsync(userId, RealtimePlatform.Android, fcmToken, ct);

// À la déconnexion.
await notifications.RevokeDeviceAsync(userId, fcmToken, ct);

Pourquoi « à chaque démarrage ». Les jetons FCM et APNs tournent d'eux-mêmes, sans prévenir. Un enregistrement fait une fois à l'inscription se périme silencieusement. L'opération est idempotente côté service : réenregistrer le même jeton le réactive et met sa date à jour, sans créer de doublon.

Pourquoi la révocation compte. L'omettre laisse l'ancien utilisateur recevoir les notifications du suivant sur un appareil partagé. C'est le genre d'incident qui se découvre par une réclamation client.


Émettre un jeton temps réel

[HttpPost("realtime/token")]
[Authorize]
public IActionResult IssueRealtimeToken([FromServices] IRealtimeTokenIssuer issuer)
{
    // L'identifiant vient de la session authentifiée, JAMAIS du corps de la requête.
    RealtimeAccessToken token = issuer.Issue(User.GetUserId());

    return Ok(new { token.Token, token.ExpiresAt });
}

Ce type ne vérifie rien. Il signe l'identifiant qu'on lui donne. C'est à vous de n'appeler Issue qu'après avoir authentifié la personne. Reprendre un userId fourni par le client permettrait à n'importe qui de s'abonner au flux de n'importe qui.

Renvoyez ExpiresAt à votre front. Il pourra renouveler avant d'être coupé plutôt qu'après.

Le jeton n'est pas une session. Il n'autorise qu'une chose : ouvrir une connexion au Hub et recevoir les notifications d'un utilisateur précis. Court, jetable, sans autre pouvoir.

La durée de vie doit rester sous la borne configurée pour votre backend (60 minutes par défaut). Une valeur supérieure ne produit pas d'erreur à l'émission : elle fait refuser la connexion côté Hub, ce qui est bien plus difficile à diagnostiquer.

Contrat détaillé : realtime-token-contract.md.


Erreurs et exceptions

Situation Comportement
BootstrapServers ou AppId manquant InvalidOperationException au démarrage
Publication en échec ProduceException<string, string> propagée
userId ou correlationId vide ArgumentException
Secret absent ou trop court InvalidOperationException à l'émission

Pourquoi l'échec de publication est propagé. Vous seul savez si une notification justifie de faire échouer votre transaction, de la mettre en attente ou de l'abandonner. L'avaler ici ferait disparaître des notifications en silence.

try
{
    await notifications.SendAsync(request, ct);
}
catch (ProduceException<string, string> ex)
{
    // Le message métier a-t-il plus de valeur que la transaction ?
    // Une confirmation de paiement : oui, à réessayer.
    // Une suggestion marketing : non, on passe.
    logger.LogError(ex, "Notification non publiée pour {UserId}.", request.UserId);
}

Pourquoi l'échec de configuration est levé au démarrage. Une configuration incomplète découverte en production, au moment où un client attend sa confirmation de commande, est le pire moment possible.


Tester votre code

IWolikhaiNotifications est une interface, et l'enregistrement utilise TryAdd : il suffit d'enregistrer un faux avant d'appeler AddWolikhaiNotifications.

public sealed class FakeNotifications : IWolikhaiNotifications
{
    public List<NotificationRequest> Sent { get; } = [];

    public Task SendAsync(NotificationRequest request, CancellationToken ct = default)
    {
        Sent.Add(request);
        return Task.CompletedTask;
    }

    public Task RegisterDeviceAsync(
        string userId, RealtimePlatform platform, string deviceToken, CancellationToken ct = default)
        => Task.CompletedTask;

    public Task RevokeDeviceAsync(string userId, string deviceToken, CancellationToken ct = default)
        => Task.CompletedTask;
}
services.AddSingleton<IWolikhaiNotifications, FakeNotifications>();
services.AddWolikhaiNotifications(o => { /* ignoré : TryAdd */ });

Un test utile vérifie que le CorrelationId est stable : appeler deux fois la même opération métier doit produire deux fois la même valeur. C'est précisément ce qu'un Guid.NewGuid() casserait sans que rien d'autre ne le révèle.


Référence de l'API

IWolikhaiNotifications

Task RegisterDeviceAsync(string userId, RealtimePlatform platform, string deviceToken, CancellationToken ct = default);
Task RevokeDeviceAsync(string userId, string deviceToken, CancellationToken ct = default);
Task SendAsync(NotificationRequest request, CancellationToken ct = default);
Task SendManyAsync(IEnumerable<NotificationRequest> requests, CancellationToken ct = default);

NotificationRequest

Membre Type Requis Rôle
UserId string ✅ Destinataire
Title string ✅ Titre, déjà localisé
Body string ✅ Corps, déjà localisé
CorrelationId string ✅ Clé d'idempotence
Data IReadOnlyDictionary<string,string>? Données métier
Priority string high ou normal (défaut)
OccurredAt DateTime? Défaut : maintenant

IRealtimeTokenIssuer

RealtimeAccessToken Issue(string userId);

RealtimeAccessToken : Token (string), ExpiresAt (DateTime UTC).

RealtimePlatform

Android, Ios, Web. Extension ToWireValue() → "android", "ios", "web".

Ne jamais sérialiser l'énumération directement : System.Text.Json produirait des nombres, et le service attend des chaînes.

NotificationPriorities

High ("high"), Normal ("normal").

La corrélation descend jusqu'aux clients

Le service diffuse systématiquement sur les deux canaux, sans chercher à savoir si l'application est ouverte. Un appareil au premier plan reçoit donc le même événement deux fois — une fois par le Hub, une fois par le fournisseur push.

Le CorrelationId que vous fournissez est propagé jusqu'aux clients pour qu'ils puissent les rapprocher :

Canal Où il arrive
Temps réel Champ correlationId de la notification
Push (FCM, APNs) Données, sous la clé wolikhaiCorrelationId

C'est une raison de plus de le dériver de l'événement métier : il sert de clé d'idempotence côté service et de clé de dédoublonnage côté client.

Contrats bruts

DeviceTokenRegisteredEvent, DeviceTokenRevokedEvent, NotificationRequestedEvent, RealtimeNotification, DeviceTokenEventTypes. Exposés pour qui câble son propre transport ou écrit des tests contre le format de fil.

Ces types sont un contrat publié, partagé par tous les produits. Toute évolution doit être additive : renommer un champ casserait tous les backends déjà déployés, sans erreur de compilation chez eux — la rupture n'apparaîtrait qu'à l'exécution, sous forme de messages mis au rebut.


Pièges

CorrelationId aléatoire. Voir plus haut. Silencieux jusqu'au premier incident.

Secret de signature dans un fichier versionné. Il vaut usurpation de n'importe lequel de vos utilisateurs. Une fois dans l'historique git, le déplacer ne suffit plus : il faut le faire tourner depuis la console.

Enregistrement d'appareil fait une seule fois. Les jetons FCM et APNs tournent : l'enregistrement doit être rejoué à chaque démarrage de l'application cliente.

Révocation oubliée à la déconnexion. Appareil partagé, notifications croisées.

userId repris du corps d'une requête. Que ce soit pour un envoi ou pour un jeton : l'identité vient de la session authentifiée.

Durée de jeton supérieure à la borne du service. L'émission réussit, la connexion échoue.

Données sensibles dans Data. Ces valeurs traversent FCM et APNs.

Titres non localisés. Le service transmet ce qu'on lui donne (§3). La traduction est faite en amont, par celui qui connaît la langue de l'utilisateur.


Diagnostic

Symptôme Cause probable
Exception au démarrage sur la configuration AppId ou BootstrapServers absent
ProduceException immédiate Courtiers injoignables, ou topic inexistant avec AutoCreateTopics: false
Publication réussie, rien dans la console AppId différent de celui déclaré
Bilan d'envoi vide Aucun appareil actif, ou pas de configuration push
Le front reçoit 401 sur le Hub Backend non déclaré, secret erroné, ou audience divergente
Le front est refusé après une coupure Jeton mis en cache côté front au lieu d'être redemandé
Une notification arrive en double CorrelationId différent entre les deux publications
Notifications iOS toutes en token_invalid Bascule sandbox/production inversée

Journalisation

Le kit journalise par ILogger de votre application :

  • Debug — chaque publication, avec partition et décalage ;
  • Error — échec de publication, et erreurs du producteur Kafka.

Passer Wolikhai.Realtime.Sdk en Debug montre exactement ce qui part.


Voir aussi

No packages depend on Wolikhai.Realtime.Sdk.

Version Downloads Last updated
1.2.0 1 10/28/2026
1.0.0 1 10/28/2026