Wolikhai.Realtime.Sdk 1.2.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 — ou en Flutter, avec wolikhai_realtime. 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" />
    <!--
      ⚠️ « Wolikhai%20RealTime » — le nom du projet contient un espace, qui DOIT
         être encodé. Le flux est propre au projet : omettre ce segment vise un
         flux d'organisation qui n'existe pas, et rend un 404.
    -->
    <add key="Wolikhai_Realtime_SDK" value="https://pkgs.dev.azure.com/clartic/Wolikhai%20RealTime/_packaging/Wolikhai_Realtime_SDK/nuget/v3/index.json" />
  </packageSources>

  <!--
    Cantonne chaque paquet à sa source.

    Sans ce mappage, NuGet interroge TOUTES les sources pour CHAQUE paquet. Un
    tiers qui publierait « Wolikhai.Realtime.Sdk » sur nuget.org pourrait être
    servi à la place de celui-ci — c'est l'attaque par confusion de dépendances,
    et rien dans la sortie de restauration ne la signalerait.
  -->
  <packageSourceMapping>
    <packageSource key="nuget.org">
      <package pattern="*" />
    </packageSource>
    <packageSource key="Wolikhai_Realtime_SDK">
      <package pattern="Wolikhai.*" />
    </packageSource>
  </packageSourceMapping>

</configuration>

⚠️ Le fournisseur d'identifiants Azure Artifacts est requis avant la première restauration, sans quoi dotnet restore échoue en 401 :

iex "& { $(irm https://aka.ms/install-artifacts-credprovider.ps1) }"

⚠️ 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": "localhost:9092",

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

    "RealtimeAudience": "wolikhai-realtime",
    "RealtimeTokenLifetimeMinutes": 30,
    "PublishTimeoutSeconds": 10
  }
}

En production avec Kafka sur la VM Azure :

{
  "WolikhaiRealtime": {
    "AppId": "amagn",
    "BootstrapServers": "10.20.1.4:9092",
    "SecurityProtocol": "SaslSsl",
    "SaslMechanism": "Plain",
    "SaslUsername": "backend-amagn",
    "SslCaLocation": "/app/certs/kafka-ca.crt",

    // Variables d'environnement ou références Key Vault, jamais versionnées.
    "SaslPassword": "",
    "RealtimeSigningSecret": "",

    "RealtimeAudience": "wolikhai-realtime",
    "RealtimeTokenLifetimeMinutes": 30,
    "PublishTimeoutSeconds": 10
  }
}

Le backend doit disposer d'une route privée vers 10.20.1.4:9092 : intégration au VNet Kafka, ou VNet appairé avec règle NSG correspondante. Le compte SASL est backend-<AppId>. Le certificat kafka-ca.crt est remis par l'équipe plateforme ; sa clé privée ca.key ne doit jamais être distribuée.

WolikhaiRealtime__SaslPassword=<secret kafka-backend-amagn-password>
WolikhaiRealtime__RealtimeSigningSecret=<secret généré dans la console>

Ces deux secrets sont indépendants : le premier autorise la publication Kafka, le second signe les jetons SignalR.

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. Entre 1 et 1440 (24 h) ; le service refuse par défaut au-delà de 60.
SecurityProtocol, SaslMechanism, SaslUsername, SaslPassword vides Authentification auprès du courtier. En production : SaslSsl, Plain, et les identifiants remis par l'équipe plateforme.
SslCaLocation vide Certificat (PEM) de l'autorité du courtier auto-hébergé, remis avec les identifiants.
PublishTimeoutSeconds 10 Durée maximale d'une publication, réémissions comprises : au-delà, l'appel échoue en ProduceException. Sert aussi au vidage à l'arrêt. Strictement positif, au plus int.MaxValue / 1000 (2 147 483).

Les bornes numériques sont validées au démarrage (IValidateOptions enregistré avec ValidateOnStart) : RealtimeTokenLifetimeMinutes ou PublishTimeoutSeconds nul, négatif ou au-delà de sa borne supérieure fait échouer le démarrage de l'hôte en OptionsValidationException, au lieu de laisser le premier jeton ou la première publication révéler l'erreur. Pour librdkafka, un délai de 0 signifierait d'ailleurs « attente infinie ».

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 et le même UserId ne produisent qu'un seul envoi. La clé du service est (appId, userId, correlationId) : le même CorrelationId peut donc servir aux différents destinataires d'un même événement métier — l'acheteur et le vendeur d'une commande reçoivent chacun leur notification.

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.

⚠️ Seules high et normal sont acceptées, sans égard à la casse — la valeur publiée est normalisée en minuscules. Toute autre valeur lève ArgumentException : le service la traiterait comme normal sans rien signaler, et une faute de frappe sur high retirerait l'urgence en silence.

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 sa propre clé — UserId et 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
Priority autre que high / normal ArgumentException
RealtimeTokenLifetimeMinutes ou PublishTimeoutSeconds ≤ 0 ou au-delà de sa borne OptionsValidationException au démarrage
Erreur fatale du producteur Kafka Journalisée en Error ; le producteur est mis à l'écart et reconstruit à la publication suivante (Warning)
Arrêt avec des messages encore en file Warning avec leur nombre au terme de PublishTimeoutSeconds — ils sont perdus
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), casse indifférente ; autre valeur ⇒ ArgumentException
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 ;
  • Warning — producteur reconstruit après une erreur fatale, messages non vidés à l'arrêt ;
  • 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