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
- Le modèle mental
- Mise en place
- Configuration
- Envoyer une notification
- Gérer les appareils
- Émettre un jeton temps réel
- Erreurs et exceptions
- Tester votre code
- Référence de l'API
- Pièges
- 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
- Guide d'intégration — le parcours complet
- Contrat des jetons — ce que le service vérifie
- Client front — React et Angular
- Le service
No packages depend on Wolikhai.Realtime.Sdk.
.NET 10.0
- Confluent.Kafka (>= 2.15.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Options (>= 10.0.0)
- Microsoft.IdentityModel.JsonWebTokens (>= 8.19.1)