Files
hermes-skills/skills/devops/python-web-service-deployment/references/web-push-notifications.md
T

10 KiB

Web Push Notifications — Full Implementation

Adding web push notifications (VAPID-based) to a FastAPI + PWA game. Covers VAPID key generation, backend subscription management, scoring-triggered push, service worker, and frontend opt-in UI.

Architecture

Browser (SW)  ← push event →  Push Service (Chrome/Firefox)  ← POST →  FastAPI backend
    └─ registers SW + subscribe() ─────────────────────────→ POST /api/push/subscribe
    └─ receives notification → showNotification()            ← scoring event triggers send_push_notification()

VAPID Key Generation

pywebpush no longer exports generate_vapid_keys(). Generate keys using cryptography directly:

from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import serialization
import base64

private_key = ec.generate_private_key(ec.SECP256R1())
public_key = private_key.public_key()

# Public key in X962 uncompressed point format (65 bytes)
public_bytes = public_key.public_bytes(
    encoding=serialization.Encoding.X962,
    format=serialization.PublicFormat.UncompressedPoint
)
public_b64 = base64.urlsafe_b64encode(public_bytes).decode().rstrip('=')

# Private key in PKCS8 DER format
private_bytes = private_key.private_bytes(
    encoding=serialization.Encoding.DER,
    format=serialization.PrivateFormat.PKCS8,
    encryption_algorithm=serialization.NoEncryption()
)
private_b64 = base64.urlsafe_b64encode(private_bytes).decode().rstrip('=')

PITFALL: The public key MUST be in uncompressed X962 format (65 bytes), NOT SubjectPublicKeyInfo (SPKI/91 bytes). The browser's PushManager.subscribe() expects the raw point. If you get a DOMException: Registration failed - invalid key on the client, the key format is wrong.

Backend: Database

Add a push_subscriptions table:

CREATE TABLE IF NOT EXISTS push_subscriptions (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id INTEGER NOT NULL REFERENCES users(id),
    endpoint TEXT NOT NULL,
    p256dh TEXT NOT NULL,
    auth TEXT NOT NULL,
    created_at TEXT DEFAULT (datetime('now')),
    UNIQUE(user_id, endpoint)
);

Add a notifications_enabled column to users:

ALTER TABLE users ADD COLUMN notifications_enabled INTEGER DEFAULT 1;

Backend: VAPID Config

from pywebpush import webpush

VAPID_PRIVATE_KEY = os.environ.get("VAPID_PRIVATE_KEY", "<base64-private>")
VAPID_PUBLIC_KEY = os.environ.get("VAPID_PUBLIC_KEY", "<base64-public>")
VAPID_CLAIMS = {"sub": "mailto:admin@example.com"}

Backend: Send Function

def send_push_notification(user_id, title, body, icon="/icon.png"):
    conn = get_db()
    try:
        subs = conn.execute(
            "SELECT endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = ?",
            (user_id,)
        ).fetchall()

        # Check user preference
        user = conn.execute(
            "SELECT notifications_enabled FROM users WHERE id = ?", (user_id,)
        ).fetchone()
        if user and not user["notifications_enabled"]:
            return

        for sub in subs:
            try:
                webpush(
                    subscription_info={
                        "endpoint": sub["endpoint"],
                        "keys": {"p256dh": sub["p256dh"], "auth": sub["auth"]}
                    },
                    data=json.dumps({
                        "title": title,
                        "body": body,
                        "icon": icon,
                    }),
                    vapid_private_key=VAPID_PRIVATE_KEY,
                    vapid_claims=VAPID_CLAIMS,
                )
            except Exception as e:
                print(f"Push failed for user {user_id}: {e}")
    finally:
        conn.close()

PITFALL: webpush() from pywebpush raises pywebpush.WebPushException on HTTP errors (410 Gone = subscription expired). Catch broadly and log — expired subscriptions should be cleaned up but the function should not crash.

Backend: API Endpoints

GET /api/push/vapid-public-key

Returns {"public_key": "<base64>"} — no auth needed (called before registering SW).

POST /api/push/subscribe (auth required)

{"endpoint": "https://fcm.googleapis.com/...", "p256dh": "...", "auth": "..."}

Uses INSERT OR REPLACE INTO push_subscriptions. PITFALL: The endpoint is unique per registration (not user) — a user with multiple devices will have multiple rows.

POST /api/push/unsubscribe (auth required)

Same body shape. Deletes by user_id + endpoint.

GET /api/push/preferences (auth required)

Returns {"notifications_enabled": bool, "subscription_count": int}.

PATCH /api/push/preferences (auth required)

{"notifications_enabled": false}

Updates the users.notifications_enabled column.

Backend: Triggering Notifications

After a scoring event, find all users who drafted the affected region and notify them:

region_name = conn.execute("SELECT name, emoji FROM regions WHERE id = ?", (req.region_id,)).fetchone()
if region_name:
    event_labels = {"sighting": "Sighting!", "bite": "Bite!", "fatality": "FATALITY!"}
    title = f"🦈 {event_labels[req.event_type]}"
    body = f"+{points} pts — {region_name['emoji']} {region_name['name']}"

    affected = conn.execute(
        "SELECT DISTINCT dp.user_id FROM draft_picks dp WHERE dp.region_id = ?",
        (req.region_id,),
    ).fetchall()
    for au in affected:
        send_push_notification(au["user_id"], title, body)

PITFALL: send_push_notification() opens its own DB connection — call it AFTER closing the scoring transaction's connection, or use a separate connection. Don't keep a conn open across push HTTP calls.

Frontend: Service Worker (sw.js)

self.addEventListener('push', (event) => {
  let data = { title: 'Default', body: '', icon: '/icon.png' };
  if (event.data) {
    try { data = event.data.json(); } catch { data.body = event.data.text(); }
  }
  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: data.icon,
      badge: '/badge.png',
      vibrate: [200, 100, 200],
      data: { url: '/app.html' },
      requireInteraction: true,
    })
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  const url = event.notification.data?.url || '/';
  event.waitUntil(
    clients.matchAll({ type: 'window' }).then((clients) => {
      for (const c of clients) {
        if (c.url.includes(url) && 'focus' in c) return c.focus();
      }
      if (clients.openWindow) return clients.openWindow(url);
    })
  );
});

Frontend: Registration + Subscription

// Register SW
const swReg = await navigator.serviceWorker.register('/sw.js');

// Get VAPID public key from backend
const keyRes = await apiCall('/api/push/vapid-public-key');
const vapidKey = urlBase64ToUint8Array(keyRes.public_key);

// Get permission
const permission = await Notification.requestPermission();
if (permission !== 'granted') return;

// Subscribe
const sub = await swReg.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: vapidKey,
});

// Send to server
await apiCall('/api/push/subscribe', {
  method: 'POST',
  body: JSON.stringify({
    endpoint: sub.endpoint,
    p256dh: arrayBufferToBase64(sub.getKey('p256dh')),
    auth: arrayBufferToBase64(sub.getKey('auth')),
  }),
});

Base64 ↔ Uint8Array Utility Functions

function urlBase64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - base64String.length % 4) % 4);
  const base64 = (base64String + padding)
    .replace(/\-/g, '+')
    .replace(/_/g, '/');
  const rawData = window.atob(base64);
  const outputArray = new Uint8Array(rawData.length);
  for (let i = 0; i < rawData.length; ++i)
    outputArray[i] = rawData.charCodeAt(i);
  return outputArray;
}

function arrayBufferToBase64(buffer) {
  const bytes = new Uint8Array(buffer);
  let binary = '';
  for (let i = 0; i < bytes.byteLength; i++)
    binary += String.fromCharCode(bytes[i]);
  return btoa(binary);
}

PITFALL: The VAPID public key from the server is URL-safe base64 (no padding). atob() requires standard base64. Always convert -+ and _/ before decoding. Add padding with '='.repeat((4 - len % 4) % 4).

Frontend: UX Patterns

Opt-in Banner

Show a subtle in-page banner after login (not a dialog — don't be pushy):

<div id="pushBanner" style="display:none;">
  <div>🔔 Get scoring alerts — Enable push notifications</div>
  <button onclick="requestNotificationPermission()">Enable</button>
  <button onclick="hideNotificationBanner()"></button>
</div>

PITFALL: Call Notification.requestPermission() only on explicit user action (click/tap). Browsers silently ignore requests not triggered by user gesture. The banner's "Enable" button is the gesture.

State UI

Show current notification state in settings:

  • Unsubscribed: show "Enable" button
  • Subscribed: show " Notifications enabled"
  • Blocked: show "🔕 Notifications blocked — enable in browser settings"
  • Unsupported: silently hide the entire block

Preference Toggle (settings page)

A toggle button in user/league settings to globally disable notifications without unsubscribing devices:

PATCH /api/push/preferences  {"notifications_enabled": false}

When disabling, also call sub.unsubscribe() and POST /api/push/unsubscribe to clean up.

Verification

  1. Open the app, log in — banner should show "Get scoring alerts"
  2. Click "Enable" — browser prompts for notification permission
  3. After granting, POST /api/push/subscribe is called with the subscription
  4. Trigger a score event via the scoring endpoint
  5. Browser shows a push notification even if the tab is minimized
  6. Clicking the notification opens (or focuses) the app tab

Test with curl:

curl -s -X POST http://localhost:8083/api/scores/events \
  -H "Content-Type: application/json" \
  -d '{"region_id": 1, "event_type": "sighting", "description": "Test push"}'

References