- name
- shiny-extensions-push
- description
- Generate code using Shiny.Extensions.Push, a server-side push notification dispatch library for .NET with a provider-agnostic core and transports for APNs (.p8/ES256), FCM (HTTP v1), Web Push (VAPID + RFC 8291) and WNS (Windows App SDK / Entra auth), plus Shiny.DocumentDb persistence, structured targeting, topics, interceptors, dead-token pruning, multi-app keyed registration, runtime static/dynamic (multi-tenant) configuration via IPushConfigurationProvider, and System.Diagnostics.Metrics + tracing — fully AOT/trim-safe.
- auto_invoke
- true
- triggers
- ["AddPushNotifications","IPushManager","IPushProvider","IPushBatchProvider","IPushRepository","IPushInterceptor","IPushEventReceiver","AddEventReceiver","PushNotification","DeviceRegistration","PushFilter","PushSendResult","PushDeliveryResult","AddApns","ApnsProvider","ApnsOptions","AddFcm","FcmProvider","FcmOptions","AddWebPush","WebPushProvider","WebPushOptions","AddWns","WnsProvider","WnsOptions","WindowsPushOptions","WnsNotificationType","IPushConfigurationProvider","PushConfiguration","UsePushConfiguration","multi-tenant push","multi-tenant configuration","[Truncated]"]
# Shiny.Extensions.Push Skill
You are an expert in **Shiny.Extensions.Push**, a server-side push notification dispatch library for
.NET. It has a provider-agnostic core, a direct **APNs** transport (token-based `.p8` / ES256 over
HTTP/2 — no FCM dependency for Apple), a **Shiny.DocumentDb** persistence option, structured targeting,
an interceptor pipeline, automatic dead-token pruning, multi-app keyed registration, and
`System.Diagnostics.Metrics` telemetry. The whole library is AOT/trim-safe.
## When to Use This Skill
Invoke this skill when the user wants to:
- Send push notifications from a .NET **server/backend** (ASP.NET Core, worker, Aspire) to mobile/desktop devices
- Register/unregister device tokens and store them (in-memory or Shiny.DocumentDb)
- Send to a user across all their devices, to tags/segments, to explicit tokens, or broadcast
- Talk to **APNs directly** with a `.p8` auth key (iOS/macOS), **FCM** (HTTP v1) for Android, **Web Push** (VAPID) for browsers, or **WNS** (Windows App SDK / Entra auth) for Windows
- Subscribe devices to **topics** and send to a topic
- Mutate, localize, personalize, or suppress notifications per-device via interceptors
- Observe the send lifecycle (batch start/finish, per-device sent/failed) for telemetry, receipts or dead-letter capture via event receivers
- Automatically prune expired/invalid device tokens and apply rotated tokens
- Serve **multiple apps** from one server (keyed registrations per provider)
- Emit push delivery metrics + traces for OpenTelemetry
- Send silent/background (content-available) pushes
> This is the **server** counterpart. For the on-device client (registering for push, receiving), use
> `Shiny.Push` (the MAUI client library) — a different package.
## Library Overview
- **Core namespace**: `Shiny.Extensions.Push`
- **NuGet packages**:
- `Shiny.Extensions.Push` — core **plus all four transports**: models,
`IPushManager`/`IPushProvider`/`IPushRepository`/`IPushInterceptor`, builder/DI, and the APNs, FCM,
Web Push and WNS (Windows) providers. The implementation types (`PushManager`, `InMemoryPushRepository`,
`DebugPushProvider`, `PushMetrics`) live in the `Shiny.Extensions.Push.Infrastructure` namespace; each
transport keeps its own namespace (`Shiny.Extensions.Push.Apns` / `.Fcm` / `.WebPush` / `.Wns`).
- `Shiny.Extensions.Push.DocumentDb` — `IPushRepository` backed by Shiny.DocumentDb (any provider)
- **Namespaces**: inject the **interfaces** from `Shiny.Extensions.Push` (`IPushManager`, etc.). Add a
transport with `using Shiny.Extensions.Push.Apns;` (or `.Fcm` / `.WebPush` / `.Wns`) to surface its
`AddApns` extension. You only need `using Shiny.Extensions.Push.Infrastructure;` to name a concrete impl
directly (e.g. `AddProvider<DebugPushProvider>()` or casting a repo in tests) — normal app code shouldn't.
- **Transports are no longer separate packages.** Do **not** `dotnet add package Shiny.Extensions.Push.Apns`
(or `.Fcm` / `.WebPush` / `.Wns`) — they ship inside `Shiny.Extensions.Push`.
- **Target**: `net10.0`. AOT/trim-safe.
## Setup (Dependency Injection)
```csharp
using Shiny.Extensions.Push;
using Shiny.Extensions.Push.Apns;
using Shiny.Extensions.Push.DocumentDb;
using Shiny.DocumentDb.Sqlite;
services.AddPushNotifications(push =>
{
// Direct APNs (single app)
push.AddApns(o =>
{
o.TeamId = "ABCDE12345";
o.KeyId = "KEY1234567";
o.BundleId = "com.example.app";
o.PrivateKeyPath = "AuthKey_KEY1234567.p8"; // or o.PrivateKey = "<PEM contents>"
// o.ForceEnvironment = PushEnvironment.Sandbox; // default: honour each registration
});
// FCM (Android) — Google service-account JSON
push.AddFcm(o => o.ServiceAccountJsonPath = "firebase-service-account.json");
// Web Push (browsers) — VAPID keys in base64url (web-push format)
push.AddWebPush(o =>
{
o.PublicKey = "<vapid-public-key>";
o.PrivateKey = "<vapid-private-key>";
o.Subject = "mailto:you@example.com";
});
// WNS (Windows) — modern Windows App SDK / Entra (Azure AD) app registration
push.AddWns(o =>
{
o.TenantId = "<entra-tenant-id>";
o.ClientId = "<app-registration-client-id>";
o.ClientSecret = "<client-secret>";
});
// Persistence (defaults to in-memory if omitted)
push.UseDocumentDb(o => o.DatabaseProvider = new SqliteDatabaseProvider("Data Source=push.db"));
// Optional
// push.AddInterceptor<LocalizationInterceptor>();
// push.Configure(m => { m.MaxDegreeOfParallelism = 25; m.AutoPruneDeadTokens = true; });
});
```
Defaults when not configured: an in-memory `IPushRepository` and the built-in `PushManager`.
`IMeterFactory`/metrics are wired automatically.
## Registering Devices
Resolve `IPushManager` and register the device when your client app reports its token:
```csharp
await pushManager.RegisterDevice(new DeviceRegistration
{
DeviceToken = "<apns-device-token>",
Platform = DevicePlatform.iOS, // iOS, MacOS, Android, Windows, WebBrowser
DeviceId = "install-guid", // stable identity across token rotation (recommended)
UserIdentifier = "user-42", // enables "send to user across all devices"
Tags = ["beta", "sports"],
Locale = "en-US",
Environment = PushEnvironment.Production, // APNs sandbox vs production (tokens are env-specific!)
AppId = null // set when using multi-app keyed providers
});
await pushManager.UnregisterDevice("<apns-device-token>", DevicePlatform.iOS);
```
## Sending
`IPushManager` returns a `PushSendResult` (`BatchId`, `Sent`, `Failed`, `TokensRemoved`, `Skipped`, `Results`).
One device failing never aborts the batch.
```csharp
var result = await pushManager.SendToUser("user-42", new PushNotification
{
Title = "Goal!",
Message = "Your team just scored",
Badge = 1,
Sound = "default",
DeepLink = "app://match/123",
Priority = PushPriority.High,
Data = new Dictionary<string, string> { ["matchId"] = "123" }
});
await pushManager.SendToTags(["sports"], notification, TagMatch.Any);
await pushManager.SendToTokens(["tokenA", "tokenB"], notification);
await pushManager.Broadcast(notification);
// Full structured targeting (all set clauses are AND-combined):
await pushManager.Send(notification, new PushFilter
{
Platforms = [DevicePlatform.iOS],
Tags = ["beta"],
TagMatch = TagMatch.All,
Environment = PushEnvironment.Production
});
```
### Apple-specific options & silent push
```csharp
new PushNotification
{
Title = "Title",
Apple = new ApplePushOptions
{
Subtitle = "Subtitle",
Category = "MESSAGE_CATEGORY",
ThreadId = "thread-1",
MutableContent = true, // for a Notification Service Extension
// ContentAvailable = true, // background push
// TopicOverride / PushTypeOverride for VoIP/complication topics
},
CollapseId = "score-update", // apns-collapse-id
TimeToLive = TimeSpan.FromHours(1) // apns-expiration
};
// Silent / background push (no visible alert):
new PushNotification
{
Apple = new ApplePushOptions { ContentAvailable = true },
Data = new Dictionary<string, string> { ["sync"] = "inbox" }
};
```
## Interceptors (localize / personalize / suppress / report)
Implement `IPushInterceptor` and register with `push.AddInterceptor<T>()` (additive; run in order).
Mutate by replacing `context.Notification`; return `InterceptorResult.Skip` to drop a device.
```csharp
public sealed class LocalizationInterceptor : IPushInterceptor
{
public Task<InterceptorResult> BeforeSend(PushSendContext context, CancellationToken ct = default)
{
if (context.Registration.Tags.Contains("opted-out"))
return Task.FromResult(InterceptorResult.Skip);
var title = Localize(context.Notification.Title, context.Registration.Locale);
context.Notification = context.Notification with { Title = title };
return Task.FromResult(InterceptorResult.Continue);
}
public Task OnSent(PushSendContext c, PushDeliveryResult r, CancellationToken ct = default) => Task.CompletedTask;
public Task OnFailed(PushSendContext c, PushDeliveryResult r, CancellationToken ct = default) => Task.CompletedTask;
}
```
## Event receivers (observe the send lifecycle — telemetry, receipts, dead-letter)
When you just want to *watch* sends — not mutate them — implement `IPushEventReceiver` and register with
`push.AddEventReceiver<T>()` (additive; register zero or more). Unlike interceptors, receivers can't skip
or mutate; they're pure observers with **batch lifecycle** hooks (`OnBatchStarted`/`OnBatchFinished`) plus
per-device `OnSent`/`OnFailed`. A receiver that throws is logged and swallowed — it never breaks a batch.
`OnFailed` fires for **every** failed device, including the normalized failures that never throw
(`TokenExpired`, `InvalidToken`, `RateLimited`, `Error`) — inspect `result.Status`/`result.Reason`.
(Interceptor `Skip` and no-provider outcomes aren't failures; read them from the `OnBatchFinished`
`PushSendResult` counts instead.)
```csharp
public sealed class PushTelemetry : IPushEventReceiver
{
public Task OnBatchStarted(Guid batchId, PushFilter filter, PushNotification n, CancellationToken ct = default) => Task.CompletedTask;
public Task OnSent(Guid batchId, DeviceRegistration reg, PushNotification n, PushDeliveryResult r, CancellationToken ct = default) => Task.CompletedTask;
public Task OnFailed(Guid batchId, DeviceRegistration reg, PushNotification n, PushDeliveryResult r, CancellationToken ct = default)
{
// capture dead-letters, alert on RateLimited, etc.
return Task.CompletedTask;
}
public Task OnBatchFinished(Guid batchId, PushNotification n, PushSendResult result, CancellationToken ct = default)
=> Task.CompletedTask; // result.Sent / .Failed / .TokensRemoved / .Skipped
}
```
## Dead-token pruning & rotation (automatic)
Providers return a normalized `PushDeliveryStatus`. The manager auto-removes tokens reported
`TokenExpired`/`InvalidToken` (APNs `410 Unregistered` / `BadDeviceToken`) and applies rotated tokens
(`PushDeliveryResult.UpdatedToken`) to the repository. Disable via `PushManagerOptions.AutoPruneDeadTokens = false`.
`RateLimited` is surfaced on the result but **not** retried — backoff is the caller's responsibility.
On success, providers set `PushDeliveryResult.ProviderMessageId` (APNs `apns-id`, FCM message `name`,
WebPush `Location`).
## Topics
Topics are server-side subscriptions (work across every provider, independent of FCM-native topics):
```csharp
await pushManager.SubscribeToTopic(token, DevicePlatform.iOS, "sports");
await pushManager.SendToTopic("sports", new PushNotification { Title = "Goal!" });
await pushManager.UnsubscribeFromTopic(token, DevicePlatform.iOS, "sports");
```
## Platform routing (FCM / Web Push / WNS)
The manager routes a registration to the provider that claims its `Platform`: APNs → `iOS`/`MacOS`,
FCM → `Android`, Web Push → `WebBrowser`, WNS → `Windows`. For **WNS**, the registration's `DeviceToken` is
the WNS **channel URI**. For **Web Push**, the `DeviceToken` is the subscription endpoint and the
`p256dh`/`auth` keys go in `Data`:
```csharp
await pushManager.RegisterDevice(new DeviceRegistration
{
DeviceToken = subscription.Endpoint,
Platform = DevicePlatform.WebBrowser,
Data = new Dictionary<string, string>
{
["p256dh"] = subscription.Keys.P256dh,
["auth"] = subscription.Keys.Auth
}
});
```
FCM uses `AndroidPushOptions` on the notification for `android.notification` fields (channel id, icon,
color, image); Web Push uses `WebPushOptions` (icon, urgency); WNS uses `WindowsPushOptions`
(`Type` = Toast/Tile/Badge/Raw, a verbatim `Payload` for tile/badge, and a toast `Launch` arg — default is
a `ToastGeneric` toast built from title/body + `DeepLink`). All providers honour the cross-cutting fields
(title/body, badge, sound, data, deep link, collapse id, TTL, priority).
## Delivery fan-out and custom batching
FCM's multipart `/batch` endpoint has been discontinued. `FcmProvider` and `FcmTenantProvider` therefore use
one supported HTTP v1 `messages:send` request per device; `PushManagerOptions.MaxDegreeOfParallelism` bounds
the concurrent fan-out. Per-device dead-token pruning, token rotation, metrics, and `OnSent`/`OnFailed` are
preserved. Only an explicit FCM `UNREGISTERED` response expires a token; a generic HTTP 404 is an `Error`.
Custom transports with a real bulk endpoint can implement `IPushBatchProvider` (`MaxBatchSize` + `SendBatch`
returning one result per registration, in input order). Set
`push.Configure(m => m.EnableBatching = false)` to force per-device delivery for those providers.
## Multiple apps (keyed registration)
Register one keyed provider per app (works for `AddApns`/`AddFcm`/`AddWebPush`/`AddWns`); devices carry the
matching `AppId`; the manager routes by it.
```csharp
services.AddPushNotifications(push =>
{
push.AddApns("consumer", o => { o.BundleId = "com.example.consumer"; /* … */ });
push.AddApns("driver", o => { o.BundleId = "com.example.driver"; /* … */ });
});
await pushManager.RegisterDevice(new DeviceRegistration
{
DeviceToken = "<token>", Platform = DevicePlatform.iOS, AppId = "driver"
});
await pushManager.Send(notification, new PushFilter { AppId = "driver", Tags = ["on-shift"] });
```
## Runtime configuration provider (dynamic / multi-tenant)
The **static** path is the plain `AddApns(o => …)` / `AddApns("key", o => …)` registration (config baked in).
For a **dynamic** setup — many apps, or tenants/keys that change without a restart — supply credentials at
**send time** via `IPushConfigurationProvider.GetConfiguration(appId)` → `PushConfiguration?` (one record
bundling optional `Apns`/`Fcm`/`WebPush`/`Wns` option objects), keyed by `DeviceRegistration.AppId`. Register
the provider once with `UsePushConfiguration<T>()`, then opt each transport in with its **no-argument**
Voir sur GitHub