Web Push Bildirimleri: VAPID, Angular SwPush ve ASP.NET Core'dan Bildirim Göndermek
Web Push'ta tarayıcı, backend'in VAPID public key'ine bağlı bir subscription verir; backend sipariş kargoya verilince o endpoint'e VAPID ile imzalı, aes128gcm ile şifreli bir mesaj gönderir ve Angular Service Worker bildirimi gösterir. Angular SwPush, ASP.NET Core 10 ve Lib.Net.Http.WebPush ile akışı yerel bir test push service'i üzerinden uçtan uca çalıştırıyor; izin UX'ini, SSRF'e karşı endpoint kontrolünü, 404/410 temizliğini ve anahtar değişince ne olduğunu gösteriyoruz.
Sürüm notu: Bu yazıdaki örnekler Angular 22.2 (TypeScript 6.0), @angular/service-worker 22.2.6, .NET 10 (ASP.NET Core 10), Lib.Net.Http.WebPush 3.3.1, Node.js 24 ve Playwright 1.65 ile denenmiştir; tarayıcı çıktıları Playwright'ın Chromium 158 build'inden alınmıştır. Gerçek bir push service'e (Google, Mozilla, Apple, Microsoft) abone olunmadı: abonelik ve teslim, yazıda anlatılan yerel bir test push service'i üzerinden yapıldı. Destek bilgileri MDN'in (Mozilla'nın web platformu dokümantasyonu) browser-compat-data (BCD) 8.1.9 verisinden okunmuştur; Firefox, Safari ve iOS çalıştırılamadı.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'da (bir online kitapçı) müşteri "siparişim kargoya verilince haber ver" dedi ve tarayıcı bir push subscription oluşturdu. Backend'in VAPID (Voluntary Application Server Identification; uygulama sunucusunun push service'e kendini imzalı bir token'la tanıtması) anahtar çifti şimdilik uygulama her başladığında yeniden üretiliyor; "nasılsa public key'i istemci her seferinde API'den alıyor" diye düşünülmüş. Ertesi gün yeni bir deploy çıkıyor ve sipariş kargoya veriliyor. Bildirim kullanıcıya ulaşır mı?
Bir önceki yazıda Bookstore'u kurulabilir yaptık ve okunmamış sipariş güncellemelerini uygulama icon'unda gösterdik, ama sayı yalnız uygulama açıkken güncelleniyordu. Bu yazıda Web Push'u uçtan uca kuruyoruz: izin, PushManager.subscribe, subscription'ı ASP.NET Core'da saklamak, VAPID ile imzalı ve şifreli mesajı push service'e göndermek, service worker'da bildirimi göstermek ve tıklamayı karşılamak.
Web Push'ta Kim Kime Ne Gönderir?

Akışta dört taraf var. Sayfa, backend'in VAPID public key'ini alıp tarayıcıdan subscription istiyor (applicationServerKey). Tarayıcı izni soruyor ve kendi push service'ine kayıt oluyor: Chrome'da Google'ın FCM'i (Firebase Cloud Messaging; Google'ın mesaj iletim servisi), Firefox'ta Mozilla'nın servisi, Safari'de Apple'ınki, Edge'de Microsoft'un WNS'i (Windows Push Notification Services; Microsoft'un bildirim servisi). Sonra sayfaya bir subscription veriyor: bir endpoint URL'i ve iki anahtar (p256dh, auth). Sayfa bunu backend'e kaydediyor. Sipariş kargoya verildiğinde backend o endpoint'e bir POST yapıyor; gövde tarayıcının anahtarlarıyla şifreli (RFC 8291, aes128gcm), istek VAPID private key ile imzalı (RFC 8292). Push service mesajı saklayıp tarayıcı çevrimiçi olunca iletiyor; tarayıcı service worker'ı uyandırıp push event'ini veriyor. Push service mesajın içeriğini göremiyor, sadece taşıyor (RFC 8030).
Bu yazının deneylerinde gerçek bir push service kullanmadım. Chromium'un PushManager.subscribe'ı Google'ın servisine kayıt olurdu; onun yerine Playwright testinde subscribe'ı, yerel bir test push service'ine kayıt olan küçük bir stub ile değiştirdim. Bu servis (tools/local-push-service.ts) RFC'lerin push service'ten beklediğini yapıyor: VAPID imzasını ve aud, exp claim'lerini kontrol ediyor, aes128gcm gövdeyi tarayıcı tarafının private key'iyle çözüyor ve mesajı saklıyor. Teslim için Chromium'un DevTools protokolündeki ServiceWorker.deliverPushMessage'ı kullandım; DevTools'un Application panelindeki "Push" butonu da bunu yapıyor. Böylece Angular kodu, ASP.NET Core kodu ve Angular'ın service worker'ı gerçek; arada yalnız push service'in yeri değişiyor.
Bookstore.Notifications.Api: VAPID Anahtarları ve Subscription'lar
Anahtarları Bir Kez Üret, Secret Olarak Sakla
VAPID anahtarı P-256 eğrisinde bir ECDSA (Elliptic Curve Digital Signature Algorithm; eliptik eğri tabanlı imza algoritması) anahtar çifti. Public key tarayıcıya applicationServerKey olarak verilir ve subscription bu anahtara bağlanır; private key yalnız backend'de durur. Bookstore'un push tarafı ayrı küçük bir ASP.NET Core projesi, Bookstore.Notifications.Api. Anahtar çiftini üreten bir komutu var:
using Bookstore.Notifications.Api;
using Lib.Net.Http.WebPush;
using Microsoft.Extensions.Options;
if (args is ["generate-vapid-keys"])
{
// her environment için bir kez çalıştır, çifti bir secret store'da sakla
VapidKeys keys = VapidKeys.Generate();
Console.WriteLine($"Push__PublicKey={keys.PublicKey}");
Console.WriteLine($"Push__PrivateKey={keys.PrivateKey}");
return;
}
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddOptions<PushOptions>()
.Bind(builder.Configuration.GetSection(PushOptions.Section))
.ValidateDataAnnotations()
.ValidateOnStart();
builder.Services.AddSingleton<PushSubscriptionStore>();
builder.Services.AddHttpClient<PushServiceClient>();
builder.Services.AddTransient<OrderNotifier>();
WebApplication app = builder.Build();
Push__PublicKey=<87 characters>
Push__PrivateKey=<43 characters>
Değerleri burada göstermiyorum; bu iki satır bir kez üretilip environment'ın secret store'una konuyor (Azure Key Vault, environment variable, Kubernetes secret). appsettings.json'da ve repo'da durmuyorlar. Bunun nerede ve nasıl saklanacağı Configuration ve Secret Management serisinin konusu. Uygulama anahtarlar olmadan başlamamalı; ValidateOnStart bunu sağlıyor:
using System.ComponentModel.DataAnnotations;
namespace Bookstore.Notifications.Api;
public sealed class PushOptions
{
public const string Section = "Push";
// asla appsettings.json'da değil: environment variable ya da secret store (Push__PublicKey, Push__PrivateKey)
[Required] public string PublicKey { get; set; } = "";
[Required] public string PrivateKey { get; set; } = "";
[Required] public string Subject { get; set; } = "";
// istek yapmayı kabul ettiğimiz push service'ler; tam host ya da ".sonek"
public string[] AllowedEndpointHosts { get; set; } = [];
public bool IsAllowed(Uri endpoint) =>
(endpoint.Scheme == Uri.UriSchemeHttps || endpoint.IsLoopback)
&& AllowedEndpointHosts.Any(h => h.StartsWith('.') ? endpoint.Host.EndsWith(h) : endpoint.Host == h);
}
fail: Microsoft.Extensions.Hosting.Internal.Host[11]
Hosting failed to start
Microsoft.Extensions.Options.OptionsValidationException: DataAnnotation validation failed for 'PushOptions' members: 'PublicKey' with the error: 'The PublicKey field is required.'.; DataAnnotation validation failed for 'PushOptions' members: 'PrivateKey' with the error: 'The PrivateKey field is required.'.
Kütüphane seçimi için yazım günü NuGet'e baktım. En çok indirilen WebPush paketi 1.0.13 sürümüyle Nisan 2026'da güncellenmiş ve net10.0 hedefliyor, ama denediğimde istekleri eski taslak şifreleme biçimi Content-Encoding: aesgcm ve Authorization: WebPush … başlığıyla gönderdi. Lib.Net.Http.WebPush 3.3.1 (MIT lisanslı, son sürüm Mart 2025) RFC 8291'deki aes128gcm'i ve RFC 8292'deki vapid t=…, k=… biçimini kullanıyor. Bookstore ikincisini kullanıyor.
Subscription'ı Kaydetmek: endpoint'e Güvenme
// bu seride oturum açma yok: her istek müşteri Ada'ya ait
const string customer = "ada";
app.MapGet("/api/push/public-key", (IOptions<PushOptions> options) =>
TypedResults.Ok(new { publicKey = options.Value.PublicKey }));
app.MapPost("/api/push/subscriptions", (PushSubscription subscription, PushSubscriptionStore store, IOptions<PushOptions> options) =>
{
// endpoint istemciden geliyor: rastgele bir URL'e asla POST yapma (SSRF)
if (!Uri.TryCreate(subscription.Endpoint, UriKind.Absolute, out Uri? endpoint) || !options.Value.IsAllowed(endpoint))
return Results.Problem("Unknown push service.", statusCode: StatusCodes.Status400BadRequest);
if (subscription.Keys is null || !subscription.Keys.ContainsKey("p256dh") || !subscription.Keys.ContainsKey("auth"))
return Results.Problem("The subscription has no keys.", statusCode: StatusCodes.Status400BadRequest);
store.Save(customer, subscription);
return Results.Created();
});
app.MapDelete("/api/push/subscriptions", (string endpoint, PushSubscriptionStore store) =>
store.Remove(endpoint) ? Results.NoContent() : Results.NotFound());
// Bookstore'da bunu Shipping module'ü tetikler; burada onun yerine bir istek var
app.MapPost("/api/orders/{orderId:int}/shipped", async (int orderId, OrderNotifier notifier, CancellationToken ct) =>
TypedResults.Ok(await notifier.OrderShippedAsync(customer, orderId, ct)));
app.Run();
Tarayıcının verdiği subscription JSON'u (endpoint, expirationTime, keys) doğrudan kütüphanenin PushSubscription tipine bind ediliyor. Dikkat edilecek nokta endpoint: bu URL istemciden geliyor ve backend daha sonra ona POST yapacak. Kontrol etmezsen kötü niyetli biri endpoint olarak iç ağdaki bir adresi kaydedip sunucuna oraya istek attırabilir; bu bir SSRF (Server-Side Request Forgery; sunucuya istemcinin seçtiği bir adrese istek yaptırmak) açığı. Bookstore yalnız bilinen push service host'larına izin veriyor (appsettings.json'daki AllowedEndpointHosts; Development'ta yerel test servisi için 127.0.0.1 ekleniyor). Production ayarlarıyla üç kayıt denemesi:
http://169.254.169.254/latest/meta-data -> 400
https://fcm.googleapis.com/fcm/send/abc -> 201
https://evil.example.com/push -> 400
Subscription'lar bu yazıda bellekte duruyor. Gerçek uygulamada endpoint'i anahtar alan bir tabloya, müşteri kimliği ve oluşturulma tarihiyle yazılır; aynı müşterinin her tarayıcısı ve cihazı ayrı bir satırdır.
Angular Tarafı: SwPush ve İzin UX'i
Angular'ın SwPush service'i bir önceki yazılardaki NGSW (Angular Service Worker) kaydını kullanıyor. Bookstore'da bütün push mantığı tek bir service'te:
import { HttpClient } from '@angular/common/http';
import { inject, Service, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { Router } from '@angular/router';
import { SwPush } from '@angular/service-worker';
import { firstValueFrom, map } from 'rxjs';
import { OrderBadge } from './order-badge';
@Service()
export class PushNotifications {
private readonly swPush = inject(SwPush);
private readonly http = inject(HttpClient);
// service worker yok (ng serve) ya da bu tarayıcıda Push API yok
readonly supported = this.swPush.isEnabled && 'PushManager' in window;
readonly permission = signal(
typeof Notification === 'undefined' ? 'denied' : Notification.permission,
);
readonly subscribed = toSignal(this.swPush.subscription.pipe(map((s) => s !== null)), {
initialValue: false,
});
constructor() {
if (!this.swPush.isEnabled) return;
const badge = inject(OrderBadge);
const router = inject(Router);
// bir sekme açıkken push geldi: header'daki ve icon'daki sayı değişir
this.swPush.messages.subscribe((message) => {
console.log(`push message: ${JSON.stringify(message)}`);
void badge.refresh();
});
// service worker pencereyi zaten öne aldı ya da açtı; ne gösterileceğine uygulama karar verir
this.swPush.notificationClicks.subscribe(({ action, notification }) => {
console.log(`notification click: "${notification.title}" action="${action}"`);
void router.navigateByUrl('/orders');
});
}
// yalnız bir tıklamadan çağır: izin penceresi kullanıcı etkileşimi ister
async enable(): Promise<string> {
try {
const { publicKey } = await firstValueFrom(
this.http.get<{ publicKey: string }>('/api/push/public-key'),
);
const subscription = await this.swPush.requestSubscription({ serverPublicKey: publicKey });
await firstValueFrom(this.http.post('/api/push/subscriptions', subscription));
return 'subscribed';
} catch (err) {
return `failed: ${(err as Error).message}`; // izin reddedildi ya da push service'e ulaşılamıyor
} finally {
this.permission.set(Notification.permission);
}
}
async disable() {
const subscription = await firstValueFrom(this.swPush.subscription);
if (!subscription) return;
await this.swPush.unsubscribe();
await firstValueFrom(
this.http.delete('/api/push/subscriptions', { params: { endpoint: subscription.endpoint } }),
);
}
}
requestSubscription tarayıcıdan izni ve subscription'ı istiyor, biz de sonucu backend'e gönderiyoruz. messages uygulama açıkken gelen her push'u, notificationClicks kullanıcının bildirime tıklamasını yayıyor. İzni ne zaman soracağın en az kod kadar önemli. Sayfa açılır açılmaz çıkan bir izin penceresi kullanıcının bağlamı olmadığı bir anda geliyor ve çoğunlukla reddediliyor; reddedilen izni sayfa bir daha soramıyor, kullanıcının tarayıcı ayarlarına girmesi gerekiyor. Bookstore izni yalnız "My orders" sayfasında, değeri açıkça anlatan bir butonla ve yalnız tıklamadan sonra istiyor:
<!-- yalnız burada, değerin açık olduğu yerde ve yalnız bir tıklamadan sonra sor -->
@if (push.supported && !push.subscribed() && push.permission() !== 'denied') {
<button class="notify" (click)="enablePush()">Notify me when my orders ship</button>
}
@if (push.subscribed()) {
<p class="push">
Shipping notifications are on. <button (click)="push.disable()">Turn off</button>
</p>
}
@if (push.permission() === 'denied') {
<p class="push">Notifications are blocked for Bookstore in your browser settings.</p>
}
Headless Chromium'da önce izin vermeyen, sonra izin veren bir kullanıcıyı denedim:
--- 1. the user has not decided yet
[chromium] Notification.permission = default, button visible = true
[chromium] push: failed: Registration failed - permission denied
[chromium] after the click: Notification.permission = denied, Notifications are blocked for Bookstore in your browser settings.
--- 2. the user allows notifications
[chromium] push: subscribed
[chromium] Shipping notifications are on. Turn off
Headless Chromium izin penceresini kendiliğinden reddetti; Notification.permission denied oldu ve sayfa butonu kaldırıp nasıl açılacağını söyledi. İzin verilen context'te subscription oluştu ve backend'e kaydedildi. Playwright'ın varsayılan headless shell'inde Notification.permission daha sayfa açılırken denied geliyordu; bu testi tam Chromium ile çalıştırmam gerekti.
Bildirimi Göndermek: Lib.Net.Http.WebPush
Bookstore'da sipariş kargoya verilince bu event Shipping module'ünden gelir; burada bir endpoint onun yerini tutuyor. Gönderen class:
public async Task<NotifyResult> OrderShippedAsync(string customer, int orderId, CancellationToken ct)
{
// Angular service worker'ın anladığı payload biçimi
string payload = JsonSerializer.Serialize(new
{
notification = new
{
title = "Your order has shipped",
body = $"Order {orderId} is on its way.",
icon = "icons/icon-192.png",
tag = $"order-{orderId}",
data = new
{
onActionClick = new
{
@default = new { operation = "navigateLastFocusedOrOpen", url = "/orders" },
},
},
},
});
PushMessage message = new(payload)
{
Topic = $"order-{orderId}", // aynı topic'li yeni mesaj, teslim edilmemiş eskisinin yerini alır
Urgency = PushMessageUrgency.Normal,
TimeToLive = 24 * 60 * 60, // saniye; bir gün sonra haber eskir
};
PushOptions push = options.Value;
VapidAuthentication vapid = new(push.PublicKey, push.PrivateKey) { Subject = push.Subject };
int sent = 0, removed = 0, failed = 0;
foreach (PushSubscription subscription in store.For(customer))
{
try
{
await pushClient.RequestPushMessageDeliveryAsync(subscription, message, vapid, ct);
sent++;
}
catch (PushServiceClientException ex) when (ex.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Gone)
{
// tarayıcı aboneliği bıraktı ya da subscription'ın süresi doldu: bir daha çalışmaz
store.Remove(subscription.Endpoint);
removed++;
logger.LogInformation("Removed subscription {Endpoint}: {Status}", subscription.Endpoint, (int)ex.StatusCode);
}
catch (PushServiceClientException ex)
{
// 400/413: isteğimiz hatalı; 429/5xx: sonra yeniden dene (bu istek değil, bir background job)
failed++;
logger.LogWarning("Push to {Endpoint} failed: {Status} {Reason}", subscription.Endpoint, (int)ex.StatusCode, ex.Message);
}
}
logger.LogInformation("Order {OrderId} shipped: {Sent} sent, {Removed} removed, {Failed} failed", orderId, sent, removed, failed);
return new NotifyResult(sent, removed, failed);
}
Payload'un biçimi NGSW'nin beklediği biçim: bir notification object'i ve içinde title, body, icon, tag gibi showNotification seçenekleri. data.onActionClick tıklamada ne olacağını söylüyor. Header'ların her birinin işi var: TTL (Time To Live; mesajın geçerlilik süresi) header'ı zorunlu ve tarayıcı çevrimdışıyken push service'in mesajı kaç saniye saklayacağını belirliyor; Topic aynı konudaki teslim edilmemiş eski mesajın yenisiyle değiştirilmesini sağlıyor; Urgency (very-low, low, normal, high) cihazın pil durumuna göre teslimi etkiliyor. Kargoya verildi, 24 saat sonra artık eski bir haber; TTL bu yüzden bir gün. Gönderimi tetikleyip sonucu izledim. Yerel push service'in log'u:
[push] #1 POST /subscriptions -> 201 subscription 1
[push] #2 POST /push/1 vapid ok (aud=http://127.0.0.1 without the port, exp in 12 h, sub=mailto:push@bookstore.example.com), aes128gcm 331 bytes, TTL=86400, Topic=order-1042, Urgency=- -> 201
İmza doğrulandı, JWT'nin (JSON Web Token; imzalı, JSON tabanlı token) exp'i 12 saat sonrası (RFC 8292 en fazla 24 saat izin veriyor), gövde aes128gcm ve çözüldü. Urgency header'ı yok çünkü kütüphane varsayılan değer olan normal'i göndermiyor. Bir ayrıntı daha yakaladım: test servisi ilk hâlinde aud'u tam origin olarak kontrol ediyordu ve istek reddedildi:
[push] #2 POST /push/1 -> 400 aud http://127.0.0.1 is not http://127.0.0.1:5291
Lib.Net.Http.WebPush 3.3.1 aud claim'ine endpoint'in port'unu yazmıyor. Gerçek push service'ler 443'te çalıştığı için bunun pratikte etkisi yok; test servisi port'suz aud'u not düşerek kabul ediyor.
Service Worker'da push ve notificationclick
Mesaj teslim edilince NGSW iki şey yapıyor: payload'u açık sekmelere PUSH mesajı olarak yayıyor (SwPush.messages) ve notification.title varsa showNotification çağırıyor. Kullanıcı o sırada katalog sayfasındaydı:
POST /api/orders/1042/shipped -> 200 {"sent":1,"removed":0,"failed":0}
[chromium] push message: {"notification":{"title":"Your order has shipped","body":"Order 1042 is on its way.","icon":"icons/icon-192.png","tag":"order-1042","data":{"onActionClick":{"default":{"operation":"navigateLastFocusedOrOpen","url":"/orders"}}}}}
notifications shown: [{"title":"Your order has shipped","body":"Order 1042 is on its way.","tag":"order-1042"}]
[chromium] nav "Catalog Order report Covers My orders (1) Settings"
Bildirim gösterildi, uygulama mesajı aldı ve OrderBadge.refresh() header'daki sayıyı 1 yaptı (sahte API kargoya verilen her siparişi okunmamış bir güncelleme sayıyor). Uygulama kapalı olsaydı yalnız bildirim görünürdü. Chrome userVisibleOnly: true olmadan subscription vermiyor; görünür bildirim göstermeyen push'larda Chrome'un dokümanına göre kendi genel bildirimini gösteriyor. Payload'da notification olmayan bir "sessiz" push bu yüzden Chrome'da işe yaramaz.
Bildirime gerçek bir tıklamayı headless tarayıcıda yapamadım. Service worker'ın içinde, Playwright'ın worker'da kod çalıştırma desteğiyle sentetik bir notificationclick event'i gönderdim:
dispatch: dispatched
[chromium] page after the click: /orders
service worker errors: ["Uncaught InvalidStateError: Failed to execute 'waitUntil' on 'ExtendableEvent': Can not call waitUntil on a script constructed ExtendableEvent.","InvalidAccessError: Not allowed to focus a window."]
NGSW'nin navigateLastFocusedOrOpen işlemi açık sekmeyi /orders'a götürdü. İki hata da öğretici: script ile oluşturulan bir event'te waitUntil çağrılamıyor ve kullanıcı tıklamasının verdiği izin olmadan pencereye focus verilemiyor. Focus hata verdiği için NGSW'nin ardından gönderdiği NOTIFICATION_CLICK mesajı, yani SwPush.notificationClicks, bu denemede sayfaya ulaşmadı; gerçek tıklamadaki davranışı doğrulayamadım. onActionClick'in diğer işlemleri openWindow, focusLastFocusedOrOpen ve sendRequest. NGSW push handler'ına kendi kodunu eklemene izin vermiyor; örneğin push gelince service worker içinden setAppBadge çağırmak istersen ngsw-worker.js'i importScripts ile içeren kendi service worker dosyanı yazman gerekir.
Subscription Ölünce ve Anahtar Değişince
Tahminin cevabı: hayır. Subscription oluşturulduğu andaki applicationServerKey'e bağlı. Yeni anahtarla çalışan ikinci bir backend instance'ı aynı subscription'a gönderim yaptı:
POST /api/orders/1042/shipped (new keys) -> 200 {"sent":0,"removed":0,"failed":1}
[push] #4 POST /push/1 -> 400 k is not the applicationServerKey of this subscription
Push to http://127.0.0.1:5291/push/1 failed: 400 Bad Request
Test servisi isteği k uyuşmadığı için reddetti; gerçek push service'ler de bu durumda isteği reddediyor, durum kodu servise göre değişiyor. Anahtar değişince bütün mevcut subscription'lar çalışmaz olur ve tarayıcılar yeni anahtarla yeniden abone olana kadar kimseye bildirim gitmez. Anahtarı gerçekten değiştirmen gerekiyorsa uygulama açılışında SwPush.subscription'ı kontrol edip anahtarı eskiyse unsubscribe ve yeniden requestSubscription yapan bir geçiş planı gerekir.
İkinci durum, push service'in subscription'ı artık tanımaması: kullanıcı izni geri aldı, tarayıcı verisini sildi ya da subscription süresi doldu. Push service bu durumda 404 ya da 410 dönüyor:
POST /api/orders/1043/shipped -> 200 {"sent":0,"removed":1,"failed":0}
[push] #5 POST /subscriptions/1/expire -> 204 (the subscription is now gone)
[push] #6 POST /push/1 -> 410 Gone
Removed subscription http://127.0.0.1:5291/push/1: 410
Order 1043 shipped: 0 sent, 1 removed, 0 failed
OrderNotifier bu subscription'ı sildi. Silmezsen her gönderimde ölü endpoint'lere istek yapmaya devam edersin ve gerçek hata oranı bu gürültünün içinde kaybolur.
iOS, Background Sync ve Periodic Sync
web-features verisine göre Push API Chrome 50, Firefox 44, Safari 16 (macOS) ve iOS 16.4'ten beri var ve Mart 2023'ten beri Baseline, yani tüm büyük tarayıcılarda. iOS'ta bir şart var: WebKit'in iOS 16.4 duyurusuna göre Web Push yalnız ana ekrana eklenmiş web app'lerde çalışıyor ve izin isteği doğrudan bir kullanıcı etkileşiminden (bir butona dokunma) gelmeli. Safari sekmesinde açılan Bookstore'da butonu göstermek bu yüzden yanlış; önce bir önceki yazıdaki ana ekrana ekleme talimatı gelmeli. iOS'ta uygulama icon'undaki badge de bildirim izniyle görünür oluyor.
Push'a yakın iki API daha var ve ikisi de yalnız Chromium'da. Background Sync (SyncManager, Chrome 49) bağlantı geri geldiğinde service worker'a bir sync event'i veriyor; Periodic Background Sync (PeriodicSyncManager, Chrome 80) kurulu bir uygulamaya belirli aralıklarla arka planda çalışma fırsatı veriyor. Playwright'ın WebKit'inde ikisi de tanımlı değildi. Bu serinin IndexedDB yazısındaki outbox'ın online event'i ve uygulama açılışında boşaltılması bu yüzden doğru fallback: Background Sync varsa ek bir iyileştirme, yoksa sayfa açıkken aynı iş yapılıyor.
Üretimde Dikkat
Gönderimi HTTP isteğinin içinde yapma. Bu yazıdaki endpoint senkron gönderiyor çünkü sonucu göstermek istedik; gerçek uygulamada "kargoya verildi" event'i bir background job'a ya da message queue'ya gider, push gönderimi orada yapılır ve 429 ile 5xx cevaplarında Retry-After'a uyularak yeniden denenir. Bir müşterinin birden çok cihazı olduğu için gönderimler paralel yapılabilir, ama push service'lerin hız sınırlarını hesaba kat.
Payload'u küçük tut. RFC 8030 push service'lerin en az 4096 byte'lık payload'u kabul etmesini istiyor; pratikte güvenli sınır bu. Bildirimde kişisel veri (adres, ödeme bilgisi) gönderme: payload şifreli ama bildirim kilit ekranında görünür. Sipariş numarası ve "detay için uygulamayı aç" yeterli.
Ölç: gönderilen, push service'in kabul ettiği (201), silinen (404/410) ve başarısız olan mesaj sayıları ayrı metrikler olmalı. Bildirim tıklama oranı notificationClicks ile ölçülebilir. Bildirim izni bir kez reddedilince geri gelmediği için izin isteme oranını ve kabul oranını da izle.
Sık Düşülen Tuzaklar
- VAPID private key'i frontend'e,
environment.ts'e ya da repo'ya koymak. Tarayıcı yalnız public key'i görür; private key secret store'da durur. - Her deploy'da ya da her uygulama başlangıcında yeni anahtar üretmek. Bütün subscription'lar o anda çalışmaz olur.
- İstemciden gelen
endpoint'e doğrulamadan POST yapmak. Bilinen push service host'larına izin veren bir liste olmadan bu bir SSRF açığı. - 404 ve 410 dönen subscription'ları silmemek. Ölü endpoint'ler her gönderimde zaman ve kota harcar, metrikleri bozar.
- İzni sayfa açılır açılmaz istemek. Reddedilen izni sayfa bir daha isteyemez; değer anlaşıldıktan sonra ve bir tıklamayla sor.
- iOS'ta Safari sekmesinde push butonu göstermek. Push yalnız ana ekrana eklenmiş web app'te çalışır.
Küçük Bir Deneme
★☆☆ OrderNotifier'daki TTL'i 60 saniyeye indir ve Topic'i kaldır. Aynı sipariş için iki kez "shipped" gönderip yerel push service'in log'unda iki mesajın da saklandığını gör; Topic ile gerçek bir push service'in ne yapacağını RFC 8030'un 5.4. bölümünden oku.
★★☆ Payload'a actions ekle: "Track" ve "Dismiss". onActionClick'te "Track" için /orders'ı açan bir işlem tanımla ve SwPush.notificationClicks'te action değerine göre farklı davran.
★★★ Anahtar rotasyonu için bir geçiş planı yaz: backend iki public key'i de bilsin, uygulama açılışında SwPush.subscription'ın options.applicationServerKey'ini yeni anahtarla karşılaştırıp gerekirse yeniden abone olsun. Bu yazıdaki ikinci backend instance'ı ile eski subscription'ın silinip yenisinin kaydedildiğini doğrula.
Push bildirimi bir sekme açık olmasa da geliyor; ama birden çok sekme açıkken her biri messages'ı ayrı ayrı alıyor ve her biri badge'i ayrı ayrı yeniliyor. Sekmeler arasında işi paylaşmaya geçmeden önce bir sonraki yazıda başka bir yüke bakıyoruz: ağır işleri main thread'den alıp Web Worker'a taşımak.