* feat: add Web Push API support and PWA integration Add web Push API with subscribe/unsubscribe and visibility endpoints Introduce usePushVisibilityBeacon and useSessionDeepLink hooks Integrate PWA with service worker, registerSW, and VAPID key persistence * feat: add heartbeat visibility beacon for web runtime Add a 10s heartbeat to ping visibility while visible Subscribe to visibilitychange, focus, blur, pageshow, and pagehide events to report state Clear heartbeat interval on unmount to avoid leaks
1134 lines
31 KiB
Markdown
1134 lines
31 KiB
Markdown
# Background Notifications for PWA - Implementation Guide
|
|
|
|
## Architecture Overview
|
|
|
|
This implementation uses **Web Push API** combined with **PWA Service Worker** to deliver background notifications on mobile devices.
|
|
|
|
**Key Components:**
|
|
- **VAPID Keys** (Web Push Application Server Authentication)
|
|
- **Push Service** (web-push library)
|
|
- **Service Worker** (Workbox)
|
|
- **Notification Channels** (Push + SSE fallback)
|
|
- **Frontend Subscription Manager** (React hook)
|
|
|
|
---
|
|
|
|
## 1. Server-Side Implementation
|
|
|
|
### 1.1 VAPID Key Generation
|
|
|
|
**Location:** `server/src/config/vapidKeys.ts`
|
|
|
|
```typescript
|
|
import { generateVAPIDKeys } from 'web-push'
|
|
import { getOrCreateSettingsValue } from './generators'
|
|
import { getSettingsFile } from './settings'
|
|
|
|
export type VapidKeys = {
|
|
publicKey: string
|
|
privateKey: string
|
|
}
|
|
|
|
export async function getOrCreateVapidKeys(dataDir: string): Promise<VapidKeys> {
|
|
const settingsFile = getSettingsFile(dataDir)
|
|
const result = await getOrCreateSettingsValue({
|
|
settingsFile,
|
|
readValue: (settings) => {
|
|
if (settings.vapidKeys?.publicKey && settings.vapidKeys?.privateKey) {
|
|
return { value: settings.vapidKeys }
|
|
}
|
|
return null
|
|
},
|
|
writeValue: (settings, value) => {
|
|
settings.vapidKeys = value
|
|
},
|
|
generate: () => {
|
|
const generated = generateVAPIDKeys()
|
|
return {
|
|
publicKey: generated.publicKey,
|
|
privateKey: generated.privateKey
|
|
}
|
|
}
|
|
})
|
|
|
|
return result.value
|
|
}
|
|
```
|
|
|
|
**Process:**
|
|
- Keys generated once on startup using `web-push`
|
|
- Keys persisted to settings file for later reuse
|
|
- Only public key exposed to frontend
|
|
|
|
---
|
|
|
|
### 1.2 Push Service
|
|
|
|
**Location:** `server/src/push/pushService.ts`
|
|
|
|
```typescript
|
|
import * as webPush from 'web-push'
|
|
|
|
export type PushPayload = {
|
|
title: string
|
|
body: string
|
|
tag?: string
|
|
data?: {
|
|
type: string
|
|
sessionId: string
|
|
url: string
|
|
}
|
|
}
|
|
|
|
export class PushService {
|
|
constructor(
|
|
private readonly vapidKeys: VapidKeys,
|
|
private readonly subject: string,
|
|
private readonly store: Store
|
|
) {
|
|
webPush.setVapidDetails(this.subject, this.vapidKeys.publicKey, this.vapidKeys.privateKey)
|
|
}
|
|
|
|
async sendToNamespace(namespace: string, payload: PushPayload): Promise<void> {
|
|
const subscriptions = this.store.push.getPushSubscriptionsByNamespace(namespace)
|
|
if (subscriptions.length === 0) {
|
|
return
|
|
}
|
|
|
|
const body = JSON.stringify(payload)
|
|
await Promise.all(subscriptions.map((subscription) => {
|
|
return this.sendToSubscription(namespace, subscription, body)
|
|
}))
|
|
}
|
|
|
|
private async sendToSubscription(
|
|
namespace: string,
|
|
subscription: StoredSubscription,
|
|
body: string
|
|
): Promise<void> {
|
|
const pushSubscription: PushSubscription = {
|
|
endpoint: subscription.endpoint,
|
|
keys: {
|
|
p256dh: subscription.p256dh,
|
|
auth: subscription.auth
|
|
}
|
|
}
|
|
|
|
try {
|
|
await webPush.sendNotification(pushSubscription, body)
|
|
} catch (error) {
|
|
const statusCode = typeof (error as { statusCode?: unknown }).statusCode === 'number'
|
|
? (error as { statusCode: number }).statusCode
|
|
: null
|
|
|
|
if (statusCode === 410) {
|
|
// Subscription expired - remove it
|
|
this.store.push.removePushSubscription(namespace, subscription.endpoint)
|
|
return
|
|
}
|
|
|
|
console.error('[PushService] Failed to send notification:', error)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Process:**
|
|
1. Gets subscriptions from database by namespace
|
|
2. Sends notification to all subscribers using web-push
|
|
3. Removes subscriptions that return 410 Gone
|
|
4. Handles other errors gracefully
|
|
|
|
---
|
|
|
|
### 1.3 Push Notification Channel
|
|
|
|
**Location:** `server/src/push/pushNotificationChannel.ts`
|
|
|
|
```typescript
|
|
import type { Session } from '../sync/syncEngine'
|
|
import type { NotificationChannel } from '../notifications/notificationTypes'
|
|
import { getAgentName, getSessionName } from '../notifications/sessionInfo'
|
|
import type { SSEManager } from '../sse/sseManager'
|
|
import type { VisibilityTracker } from '../visibility/visibilityTracker'
|
|
import type { PushPayload, PushService } from './pushService'
|
|
|
|
export class PushNotificationChannel implements NotificationChannel {
|
|
constructor(
|
|
private readonly pushService: PushService,
|
|
private readonly sseManager: SSEManager,
|
|
private readonly visibilityTracker: VisibilityTracker,
|
|
_appUrl: string
|
|
) {}
|
|
|
|
async sendPermissionRequest(session: Session): Promise<void> {
|
|
if (!session.active) {
|
|
return
|
|
}
|
|
|
|
const name = getSessionName(session)
|
|
const request = session.agentState?.requests
|
|
? Object.values(session.agentState.requests)[0]
|
|
: null
|
|
const toolName = request?.tool ? ` (${request.tool})` : ''
|
|
|
|
const payload: PushPayload = {
|
|
title: 'Permission Request',
|
|
body: `${name}${toolName}`,
|
|
tag: `permission-${session.id}`,
|
|
data: {
|
|
type: 'permission-request',
|
|
sessionId: session.id,
|
|
url: this.buildSessionPath(session.id)
|
|
}
|
|
}
|
|
|
|
// Try SSE first (for visible sessions)
|
|
const url = payload.data?.url ?? this.buildSessionPath(session.id)
|
|
if (this.visibilityTracker.hasVisibleConnection(session.namespace)) {
|
|
const delivered = await this.sseManager.sendToast(session.namespace, {
|
|
type: 'toast',
|
|
data: {
|
|
title: payload.title,
|
|
body: payload.body,
|
|
sessionId: session.id,
|
|
url
|
|
}
|
|
})
|
|
if (delivered > 0) {
|
|
return
|
|
}
|
|
}
|
|
|
|
// Fallback to push notification
|
|
await this.pushService.sendToNamespace(session.namespace, payload)
|
|
}
|
|
|
|
async sendReady(session: Session): Promise<void> {
|
|
if (!session.active) {
|
|
return
|
|
}
|
|
|
|
const agentName = getAgentName(session)
|
|
const name = getSessionName(session)
|
|
|
|
const payload: PushPayload = {
|
|
title: 'Ready for input',
|
|
body: `${agentName} is waiting in ${name}`,
|
|
tag: `ready-${session.id}`,
|
|
data: {
|
|
type: 'ready',
|
|
sessionId: session.id,
|
|
url: this.buildSessionPath(session.id)
|
|
}
|
|
}
|
|
|
|
// Try SSE first
|
|
const url = payload.data?.url ?? this.buildSessionPath(session.id)
|
|
if (this.visibilityTracker.hasVisibleConnection(session.namespace)) {
|
|
const delivered = await this.sseManager.sendToast(session.namespace, {
|
|
type: 'toast',
|
|
data: {
|
|
title: payload.title,
|
|
body: payload.body,
|
|
sessionId: session.id,
|
|
url
|
|
}
|
|
})
|
|
if (delivered > 0) {
|
|
return
|
|
}
|
|
}
|
|
|
|
// Fallback to push notification
|
|
await this.pushService.sendToNamespace(session.namespace, payload)
|
|
}
|
|
|
|
private buildSessionPath(sessionId: string): string {
|
|
return `/sessions/${sessionId}`
|
|
}
|
|
}
|
|
```
|
|
|
|
**Strategy:**
|
|
- **SSE Priority**: Checks if session is visible in current tab/window (SSE)
|
|
- **Push Fallback**: If not visible, sends background notification via VAPID
|
|
- **Tagging**: Uses session ID for grouping same-type notifications
|
|
|
|
---
|
|
|
|
### 1.4 Notification Hub
|
|
|
|
**Location:** `server/src/notifications/notificationHub.ts`
|
|
|
|
```typescript
|
|
import type { Session, SyncEvent } from '../sync/syncEngine'
|
|
import type { NotificationChannel, NotificationHubOptions } from './notificationTypes'
|
|
import { extractMessageEventType } from './eventParsing'
|
|
|
|
export class NotificationHub {
|
|
private readonly channels: NotificationChannel[]
|
|
private readonly readyCooldownMs: number
|
|
private readonly permissionDebounceMs: number
|
|
private readonly lastKnownRequests: Map<string, Set<string>> = new Map()
|
|
private readonly notificationDebounce: Map<string, NodeJS.Timeout> = new Map()
|
|
private readonly lastReadyNotificationAt: Map<string, number> = new Map()
|
|
|
|
constructor(
|
|
private readonly syncEngine: SyncEngine,
|
|
channels: NotificationChannel[],
|
|
options?: NotificationHubOptions
|
|
) {
|
|
this.channels = channels
|
|
this.readyCooldownMs = options?.readyCooldownMs ?? 5000
|
|
this.permissionDebounceMs = options?.permissionDebounceMs ?? 500
|
|
this.unsubscribeSyncEvents = this.syncEngine.subscribe((event) => {
|
|
this.handleSyncEvent(event)
|
|
})
|
|
}
|
|
|
|
private handleSyncEvent(event: SyncEvent): void {
|
|
// Permission notifications
|
|
if ((event.type === 'session-updated' || event.type === 'session-added') && event.sessionId) {
|
|
const session = this.syncEngine.getSession(event.sessionId)
|
|
if (!session || !session.active) {
|
|
this.clearSessionState(event.sessionId)
|
|
return
|
|
}
|
|
this.checkForPermissionNotification(session)
|
|
return
|
|
}
|
|
|
|
if (event.type === 'session-removed' && event.sessionId) {
|
|
this.clearSessionState(event.sessionId)
|
|
return
|
|
}
|
|
|
|
// Ready notifications
|
|
if (event.type === 'message-received' && event.sessionId) {
|
|
const eventType = extractMessageEventType(event)
|
|
if (eventType === 'ready') {
|
|
this.sendReadyNotification(event.sessionId).catch((error) => {
|
|
console.error('[NotificationHub] Failed to send ready notification:', error)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
private checkForPermissionNotification(session: Session): void {
|
|
const requests = session.agentState?.requests
|
|
|
|
if (requests == null) {
|
|
return
|
|
}
|
|
|
|
const newRequestIds = new Set(Object.keys(requests))
|
|
const oldRequestIds = this.lastKnownRequests.get(session.id) || new Set()
|
|
|
|
let hasNewRequests = false
|
|
for (const requestId of newRequestIds) {
|
|
if (!oldRequestIds.has(requestId)) {
|
|
hasNewRequests = true
|
|
break
|
|
}
|
|
}
|
|
|
|
this.lastKnownRequests.set(session.id, newRequestIds)
|
|
|
|
if (!hasNewRequests) {
|
|
return
|
|
}
|
|
|
|
// Debounce permission notifications
|
|
const existingTimer = this.notificationDebounce.get(session.id)
|
|
if (existingTimer) {
|
|
clearTimeout(existingTimer)
|
|
}
|
|
|
|
const timer = setTimeout(() => {
|
|
this.notificationDebounce.delete(session.id)
|
|
this.sendPermissionNotification(session.id).catch((error) => {
|
|
console.error('[NotificationHub] Failed to send permission notification:', error)
|
|
})
|
|
}, this.permissionDebounceMs)
|
|
|
|
this.notificationDebounce.set(session.id, timer)
|
|
}
|
|
|
|
private clearSessionState(sessionId: string): void {
|
|
const existingTimer = this.notificationDebounce.get(sessionId)
|
|
if (existingTimer) {
|
|
clearTimeout(existingTimer)
|
|
this.notificationDebounce.delete(sessionId)
|
|
}
|
|
this.lastKnownRequests.delete(sessionId)
|
|
this.lastReadyNotificationAt.delete(sessionId)
|
|
}
|
|
|
|
private getNotifiableSession(sessionId: string): Session | null {
|
|
const session = this.syncEngine.getSession(sessionId)
|
|
if (!session || !session.active) {
|
|
return null
|
|
}
|
|
return session
|
|
}
|
|
}
|
|
```
|
|
|
|
**Features:**
|
|
- **Event-Based**: Listens to sync events from SyncEngine
|
|
- **Debouncing**:
|
|
- Permission requests: 500ms debounce
|
|
- Ready notifications: 5s cooldown
|
|
- **State Tracking**: Remembers last-known requests to detect new ones
|
|
- **Multi-Channel**: Forwards to all registered channels
|
|
|
|
---
|
|
|
|
### 1.5 Subscription Storage
|
|
|
|
**Location:** `server/src/store/pushSubscriptions.ts`
|
|
|
|
```typescript
|
|
import type { Database } from 'bun:sqlite'
|
|
import type { StoredPushSubscription } from './types'
|
|
|
|
type DbPushSubscriptionRow = {
|
|
id: number
|
|
namespace: string
|
|
endpoint: string
|
|
p256dh: string
|
|
auth: string
|
|
created_at: number
|
|
}
|
|
|
|
export function addPushSubscription(
|
|
db: Database,
|
|
namespace: string,
|
|
subscription: { endpoint: string; p256dh: string; auth: string }
|
|
): void {
|
|
const now = Date.now()
|
|
db.prepare(`
|
|
INSERT INTO push_subscriptions (
|
|
namespace, endpoint, p256dh, auth, created_at
|
|
) VALUES (
|
|
@namespace, @endpoint, @p256dh, @auth, @created_at
|
|
)
|
|
ON CONFLICT(namespace, endpoint)
|
|
DO UPDATE SET
|
|
p256dh = excluded.p256dh,
|
|
auth = excluded.auth,
|
|
created_at = excluded.created_at
|
|
`).run({
|
|
namespace,
|
|
endpoint: subscription.endpoint,
|
|
p256dh: subscription.p256dh,
|
|
auth: subscription.auth,
|
|
created_at: now
|
|
})
|
|
}
|
|
|
|
export function removePushSubscription(db: Database, namespace: string, endpoint: string): void {
|
|
db.prepare(
|
|
'DELETE FROM push_subscriptions WHERE namespace = ? AND endpoint = ?'
|
|
).run(namespace, endpoint)
|
|
}
|
|
|
|
export function getPushSubscriptionsByNamespace(
|
|
db: Database,
|
|
namespace: string
|
|
): StoredPushSubscription[] {
|
|
const rows = db.prepare(
|
|
'SELECT * FROM push_subscriptions WHERE namespace = ? ORDER BY created_at DESC'
|
|
).all(namespace) as DbPushSubscriptionRow[]
|
|
return rows.map(toStoredPushSubscription)
|
|
}
|
|
```
|
|
|
|
**Storage Strategy:**
|
|
- SQLite database with namespace-based isolation
|
|
- Upsert on duplicate (updates keys if same endpoint)
|
|
- Timestamps for ordering
|
|
|
|
---
|
|
|
|
### 1.6 Push API Routes
|
|
|
|
**Location:** `server/src/web/routes/push.ts`
|
|
|
|
```typescript
|
|
import { Hono } from 'hono'
|
|
import { z } from 'zod'
|
|
import type { Store } from '../../store'
|
|
import type { WebAppEnv } from '../middleware/auth'
|
|
|
|
const subscriptionSchema = z.object({
|
|
endpoint: z.string().min(1),
|
|
keys: z.object({
|
|
p256dh: z.string().min(1),
|
|
auth: z.string().min(1)
|
|
})
|
|
})
|
|
|
|
const unsubscribeSchema = z.object({
|
|
endpoint: z.string().min(1)
|
|
})
|
|
|
|
export function createPushRoutes(store: Store, vapidPublicKey: string): Hono<WebAppEnv> {
|
|
const app = new Hono<WebAppEnv>()
|
|
|
|
app.get('/push/vapid-public-key', (c) => {
|
|
return c.json({ publicKey: vapidPublicKey })
|
|
})
|
|
|
|
app.post('/push/subscribe', async (c) => {
|
|
const json = await c.req.json().catch(() => null)
|
|
const parsed = subscriptionSchema.safeParse(json)
|
|
if (!parsed.success) {
|
|
return c.json({ error: 'Invalid body' }, 400)
|
|
}
|
|
|
|
const namespace = c.get('namespace')
|
|
const { endpoint, keys } = parsed.data
|
|
store.push.addPushSubscription(namespace, {
|
|
endpoint,
|
|
p256dh: keys.p256dh,
|
|
auth: keys.auth
|
|
})
|
|
|
|
return c.json({ ok: true })
|
|
})
|
|
|
|
app.delete('/push/subscribe', async (c) => {
|
|
const json = await c.req.json().catch(() => null)
|
|
const parsed = unsubscribeSchema.safeParse(json)
|
|
if (!parsed.success) {
|
|
return c.json({ error: 'Invalid body' }, 400)
|
|
}
|
|
|
|
const namespace = c.get('namespace')
|
|
store.push.removePushSubscription(namespace, parsed.data.endpoint)
|
|
return c.json({ ok: true })
|
|
})
|
|
|
|
return app
|
|
}
|
|
```
|
|
|
|
**API Endpoints:**
|
|
- `GET /push/vapid-public-key` - Exposes VAPID public key
|
|
- `POST /push/subscribe` - Register push subscription
|
|
- `DELETE /push/subscribe` - Unsubscribe
|
|
|
|
---
|
|
|
|
### 1.7 Service Worker
|
|
|
|
**Location:** `web/src/sw.ts`
|
|
|
|
```typescript
|
|
/// <reference lib="webworker" />
|
|
import { precacheAndRoute } from 'workbox-precaching'
|
|
import { registerRoute } from 'workbox-routing'
|
|
import { CacheFirst, NetworkFirst } from 'workbox-strategies'
|
|
import { ExpirationPlugin } from 'workbox-expiration'
|
|
|
|
declare const self: ServiceWorkerGlobalScope & {
|
|
__WB_MANIFEST: Array<string | { url: string; revision?: string }>
|
|
}
|
|
|
|
type PushPayload = {
|
|
title: string
|
|
body?: string
|
|
icon?: string
|
|
badge?: string
|
|
tag?: string
|
|
data?: {
|
|
type?: string
|
|
sessionId?: string
|
|
url?: string
|
|
}
|
|
}
|
|
|
|
precacheAndRoute(self.__WB_MANIFEST)
|
|
|
|
// Cache API responses
|
|
registerRoute(
|
|
({ url }) => url.pathname === '/api/sessions',
|
|
new NetworkFirst({
|
|
cacheName: 'api-sessions',
|
|
networkTimeoutSeconds: 10,
|
|
plugins: [
|
|
new ExpirationPlugin({
|
|
maxEntries: 10,
|
|
maxAgeSeconds: 60 * 5
|
|
})
|
|
]
|
|
})
|
|
)
|
|
|
|
// Handle push notifications
|
|
self.addEventListener('push', (event) => {
|
|
const payload = event.data?.json() as PushPayload | undefined
|
|
if (!payload) {
|
|
return
|
|
}
|
|
|
|
const title = payload.title || 'HAPI'
|
|
const body = payload.body ?? ''
|
|
const icon = payload.icon ?? '/pwa-192x192.png'
|
|
const badge = payload.badge ?? '/pwa-64x64.png'
|
|
const data = payload.data
|
|
const tag = payload.tag
|
|
|
|
event.waitUntil(
|
|
self.registration.showNotification(title, {
|
|
body,
|
|
icon,
|
|
badge,
|
|
data,
|
|
tag
|
|
})
|
|
)
|
|
})
|
|
|
|
// Handle notification clicks
|
|
self.addEventListener('notificationclick', (event) => {
|
|
event.notification.close()
|
|
const data = event.notification.data as { url?: string } | undefined
|
|
const url = data?.url ?? '/'
|
|
event.waitUntil(self.clients.openWindow(url))
|
|
})
|
|
```
|
|
|
|
**Features:**
|
|
- Uses Workbox for caching strategies
|
|
- Push event handler: Shows notification with custom payload
|
|
- Click handler: Opens app at specific URL (deep linking)
|
|
|
|
---
|
|
|
|
## 2. Frontend Implementation
|
|
|
|
### 2.1 React Hook for Push Management
|
|
|
|
**Location:** `web/src/hooks/usePushNotifications.ts`
|
|
|
|
```typescript
|
|
import { useCallback, useEffect, useState } from 'react'
|
|
import type { ApiClient } from '@/api/client'
|
|
|
|
function isPushSupported(): boolean {
|
|
return typeof window !== 'undefined'
|
|
&& 'serviceWorker' in navigator
|
|
&& 'PushManager' in window
|
|
&& 'Notification' in window
|
|
}
|
|
|
|
function base64UrlToUint8Array(base64Url: string): Uint8Array {
|
|
const padding = '='.repeat((4 - (base64Url.length % 4)) % 4)
|
|
const base64 = (base64Url + padding)
|
|
.replace(/-/g, '+')
|
|
.replace(/_/g, '/')
|
|
const raw = atob(base64)
|
|
const output = new Uint8Array(raw.length)
|
|
for (let i = 0; i < raw.length; i += 1) {
|
|
output[i] = raw.charCodeAt(i)
|
|
}
|
|
return output
|
|
}
|
|
|
|
export function usePushNotifications(api: ApiClient | null) {
|
|
const [isSupported, setIsSupported] = useState(false)
|
|
const [permission, setPermission] = useState<NotificationPermission>('default')
|
|
const [isSubscribed, setIsSubscribed] = useState(false)
|
|
|
|
const refreshSubscription = useCallback(async () => {
|
|
if (!isPushSupported()) {
|
|
setIsSupported(false)
|
|
setIsSubscribed(false)
|
|
return
|
|
}
|
|
|
|
setIsSupported(true)
|
|
setPermission(Notification.permission)
|
|
|
|
if (Notification.permission !== 'granted') {
|
|
setIsSubscribed(false)
|
|
return
|
|
}
|
|
|
|
const registration = await navigator.serviceWorker.ready
|
|
const subscription = await registration.pushManager.getSubscription()
|
|
setIsSubscribed(Boolean(subscription))
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
void refreshSubscription()
|
|
}, [refreshSubscription])
|
|
|
|
const requestPermission = useCallback(async (): Promise<boolean> => {
|
|
if (!isPushSupported()) {
|
|
return false
|
|
}
|
|
|
|
const result = await Notification.requestPermission()
|
|
setPermission(result)
|
|
if (result !== 'granted') {
|
|
setIsSubscribed(false)
|
|
}
|
|
return result === 'granted'
|
|
}, [])
|
|
|
|
const subscribe = useCallback(async (): Promise<boolean> => {
|
|
if (!api || !isPushSupported()) {
|
|
return false
|
|
}
|
|
|
|
if (Notification.permission !== 'granted') {
|
|
setPermission(Notification.permission)
|
|
return false
|
|
}
|
|
|
|
try {
|
|
const registration = await navigator.serviceWorker.ready
|
|
const existing = await registration.pushManager.getSubscription()
|
|
const { publicKey } = await api.getPushVapidPublicKey()
|
|
const applicationServerKey = base64UrlToUint8Array(publicKey).buffer as ArrayBuffer
|
|
const subscription = existing ?? await registration.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey
|
|
})
|
|
|
|
const json = subscription.toJSON()
|
|
const keys = json.keys
|
|
if (!json.endpoint || !keys?.p256dh || !keys.auth) {
|
|
return false
|
|
}
|
|
|
|
await api.subscribePushNotifications({
|
|
endpoint: json.endpoint,
|
|
keys: {
|
|
p256dh: keys.p256dh,
|
|
auth: keys.auth
|
|
}
|
|
})
|
|
setIsSubscribed(true)
|
|
return true
|
|
} catch (error) {
|
|
console.error('[PushNotifications] Failed to subscribe:', error)
|
|
return false
|
|
}
|
|
}, [api])
|
|
|
|
const unsubscribe = useCallback(async (): Promise<boolean> => {
|
|
if (!api || !isPushSupported()) {
|
|
return false
|
|
}
|
|
|
|
try {
|
|
const registration = await navigator.serviceWorker.ready
|
|
const subscription = await registration.pushManager.getSubscription()
|
|
if (!subscription) {
|
|
setIsSubscribed(false)
|
|
return true
|
|
}
|
|
|
|
const endpoint = subscription.endpoint
|
|
const success = await subscription.unsubscribe()
|
|
await api.unsubscribePushNotifications({ endpoint })
|
|
setIsSubscribed(false)
|
|
return success
|
|
} catch (error) {
|
|
console.error('[PushNotifications] Failed to unsubscribe:', error)
|
|
return false
|
|
}
|
|
}, [api])
|
|
|
|
return {
|
|
isSupported,
|
|
permission,
|
|
isSubscribed,
|
|
requestPermission,
|
|
subscribe,
|
|
unsubscribe
|
|
}
|
|
}
|
|
```
|
|
|
|
**Process:**
|
|
1. Check browser support
|
|
2. Request permission on user action
|
|
3. Subscribe using PushManager with VAPID key
|
|
4. Send subscription details to server
|
|
5. Unsubscribe by removing subscription
|
|
|
|
---
|
|
|
|
### 2.2 PWA Configuration
|
|
|
|
**Location:** `web/vite.config.ts`
|
|
|
|
```typescript
|
|
import { defineConfig } from 'vite'
|
|
import react from '@vitejs/plugin-react'
|
|
import { VitePWA } from 'vite-plugin-pwa'
|
|
import { resolve } from 'node:path'
|
|
|
|
export default defineConfig({
|
|
plugins: [
|
|
react(),
|
|
VitePWA({
|
|
registerType: 'autoUpdate',
|
|
includeAssets: ['favicon.ico', 'apple-touch-icon-180x180.png', 'mask-icon.svg'],
|
|
strategies: 'injectManifest',
|
|
srcDir: 'src',
|
|
filename: 'sw.ts',
|
|
manifest: {
|
|
name: 'HAPI',
|
|
short_name: 'HAPI',
|
|
description: 'AI-powered development assistant',
|
|
theme_color: '#ffffff',
|
|
background_color: '#ffffff',
|
|
display: 'standalone',
|
|
orientation: 'portrait',
|
|
scope: base,
|
|
start_url: base,
|
|
icons: [
|
|
{
|
|
src: 'pwa-64x64.png',
|
|
sizes: '64x64',
|
|
type: 'image/png'
|
|
},
|
|
{
|
|
src: 'pwa-192x192.png',
|
|
sizes: '192x192',
|
|
type: 'image/png'
|
|
},
|
|
{
|
|
src: 'pwa-512x512.png',
|
|
sizes: '512x512',
|
|
type: 'image/png'
|
|
},
|
|
{
|
|
src: 'maskable-icon-512x512.png',
|
|
sizes: '512x512',
|
|
type: 'image/png',
|
|
purpose: 'maskable'
|
|
}
|
|
]
|
|
},
|
|
injectManifest: {
|
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}']
|
|
},
|
|
devOptions: {
|
|
enabled: true,
|
|
type: 'module'
|
|
}
|
|
})
|
|
],
|
|
// ...
|
|
})
|
|
```
|
|
|
|
**Configuration:**
|
|
- **Auto-update**: Service worker auto-updates every load
|
|
- **Manifest**: Defines PWA metadata and icons
|
|
- **Icons**: Multiple sizes including maskable
|
|
|
|
---
|
|
|
|
### 2.3 Service Worker Registration
|
|
|
|
**Location:** `web/src/main.tsx`
|
|
|
|
```typescript
|
|
import { registerSW } from 'virtual:pwa-register'
|
|
|
|
async function bootstrap() {
|
|
// ... other initialization
|
|
|
|
const updateSW = registerSW({
|
|
onNeedRefresh() {
|
|
if (confirm('New version available! Reload to update?')) {
|
|
updateSW(true)
|
|
}
|
|
},
|
|
onOfflineReady() {
|
|
console.log('App ready for offline use')
|
|
},
|
|
onRegistered(registration) {
|
|
if (registration) {
|
|
// Auto-update every hour
|
|
setInterval(() => {
|
|
registration.update()
|
|
}, 60 * 60 * 1000)
|
|
}
|
|
},
|
|
onRegisterError(error) {
|
|
console.error('SW registration error:', error)
|
|
}
|
|
})
|
|
|
|
// ...
|
|
}
|
|
|
|
bootstrap()
|
|
```
|
|
|
|
---
|
|
|
|
### 2.4 App Integration
|
|
|
|
**Location:** `web/src/App.tsx`
|
|
|
|
```typescript
|
|
const { isSupported: isPushSupported, permission: pushPermission, requestPermission, subscribe } = usePushNotifications(api)
|
|
|
|
useEffect(() => {
|
|
if (!api || !token) {
|
|
pushPromptedRef.current = false
|
|
return
|
|
}
|
|
if (isTelegramApp() || !isPushSupported) {
|
|
return
|
|
}
|
|
if (pushPromptedRef.current) {
|
|
return
|
|
}
|
|
pushPromptedRef.current = true
|
|
|
|
const run = async () => {
|
|
if (pushPermission === 'granted') {
|
|
await subscribe()
|
|
return
|
|
}
|
|
if (pushPermission === 'default') {
|
|
const granted = await requestPermission()
|
|
if (granted) {
|
|
await subscribe()
|
|
}
|
|
}
|
|
}
|
|
|
|
void run()
|
|
}, [api, isPushSupported, pushPermission, requestPermission, subscribe, token])
|
|
```
|
|
|
|
**Strategy:**
|
|
- Prompt user for permission once per session
|
|
- Auto-subscribe if permission granted
|
|
- Don't prompt in Telegram environment (uses built-in notifications)
|
|
|
|
---
|
|
|
|
## 3. Full Data Flow
|
|
|
|
### 3.1 Subscription Flow
|
|
|
|
```
|
|
User action (App.tsx)
|
|
↓
|
|
requestPermission() (usePushNotifications.ts)
|
|
↓
|
|
Browser prompt
|
|
↓
|
|
User approves → Notification.permission = 'granted'
|
|
↓
|
|
subscribe() (usePushNotifications.ts)
|
|
↓
|
|
navigator.serviceWorker.ready
|
|
↓
|
|
registration.pushManager.subscribe()
|
|
↓
|
|
Get subscription keys (endpoint, p256dh, auth)
|
|
↓
|
|
POST /api/push/subscribe (api.client.ts)
|
|
↓
|
|
Server stores in database (pushSubscriptions.ts)
|
|
```
|
|
|
|
---
|
|
|
|
### 3.2 Notification Delivery Flow
|
|
|
|
```
|
|
Event occurs (session state change)
|
|
↓
|
|
NotificationHub.handleSyncEvent()
|
|
↓
|
|
checkForPermissionNotification() / sendReadyNotification()
|
|
↓
|
|
NotificationHub.notifyPermission() / notifyReady()
|
|
↓
|
|
PushNotificationChannel.sendPermissionRequest() / sendReady()
|
|
↓
|
|
Check visibility (VisibilityTracker)
|
|
↓
|
|
├─ Visible → SSE toast (immediate)
|
|
↓
|
|
└─ Not visible → Push notification
|
|
↓
|
|
PushService.sendToNamespace()
|
|
↓
|
|
Get subscriptions from DB
|
|
↓
|
|
web-push.sendNotification()
|
|
↓
|
|
Device receives push
|
|
↓
|
|
Service Worker push event
|
|
↓
|
|
showNotification() (sw.ts)
|
|
↓
|
|
User sees notification
|
|
↓
|
|
Click handler opens app
|
|
```
|
|
|
|
---
|
|
|
|
## 4. Key Implementation Details
|
|
|
|
### 4.1 VAPID Authentication
|
|
|
|
```typescript
|
|
// Server
|
|
webPush.setVapidDetails(
|
|
subject, // mailto:admin@hapi.run
|
|
publicKey, // From getOrCreateVapidKeys()
|
|
privateKey // From getOrCreateVapidKeys()
|
|
)
|
|
|
|
// Frontend
|
|
const { publicKey } = await api.getPushVapidPublicKey()
|
|
const applicationServerKey = base64UrlToUint8Array(publicKey).buffer
|
|
```
|
|
|
|
**Why VAPID:**
|
|
- Validates sender (prevents spam)
|
|
- Required for service worker push to work
|
|
- Uses asymmetric crypto (public/private key pair)
|
|
|
|
---
|
|
|
|
### 4.2 Tagging Strategy
|
|
|
|
```typescript
|
|
{
|
|
title: 'Ready for input',
|
|
tag: `ready-${session.id}`, // Group notifications
|
|
data: {
|
|
type: 'ready',
|
|
sessionId: session.id,
|
|
url: '/sessions/123'
|
|
}
|
|
}
|
|
```
|
|
|
|
**Benefits:**
|
|
- Same-type notifications combine into single item
|
|
- User can dismiss all similar notifications at once
|
|
- Deep linking via URL
|
|
|
|
---
|
|
|
|
### 4.3 Error Handling
|
|
|
|
```typescript
|
|
// Service worker
|
|
self.addEventListener('push', (event) => {
|
|
event.waitUntil(
|
|
self.registration.showNotification(title, options)
|
|
)
|
|
})
|
|
|
|
// Push service
|
|
try {
|
|
await webPush.sendNotification(pushSubscription, body)
|
|
} catch (error) {
|
|
if (statusCode === 410) {
|
|
removeSubscription() // Subscription expired
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 4.4 Database Schema
|
|
|
|
```sql
|
|
CREATE TABLE push_subscriptions (
|
|
id INTEGER PRIMARY KEY,
|
|
namespace TEXT NOT NULL,
|
|
endpoint TEXT NOT NULL UNIQUE,
|
|
p256dh TEXT NOT NULL,
|
|
auth TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Testing Checklist
|
|
|
|
### Server-side
|
|
- [ ] VAPID keys generated correctly
|
|
- [ ] Subscription stored in DB
|
|
- [ ] Notification sent to valid subscription
|
|
- [ ] 410 Gone handled (remove expired subscriptions)
|
|
- [ ] Routes return proper error codes
|
|
|
|
### Frontend
|
|
- [ ] Push supported check works
|
|
- [ ] Permission request triggered correctly
|
|
- [ ] Subscription created with correct keys
|
|
- [ ] Keys sent to server
|
|
- [ ] Unsubscribe works
|
|
- [ ] Deep linking opens correct URL
|
|
|
|
### Integration
|
|
- [ ] Permission notification sent when new request appears
|
|
- [ ] Ready notification sent after AI responds
|
|
- [ ] Debouncing prevents spam
|
|
- [ ] Fallback to SSE when visible
|
|
- [ ] Push used when not visible
|
|
|
|
---
|
|
|
|
## 6. Common Pitfalls
|
|
|
|
1. **VAPID Key Mismatch** - Frontend public key must match server private key
|
|
2. **Wrong Content-Type** - Push payload must be string (not JSON object)
|
|
3. **Missing Icon** - Notification needs icon to display correctly
|
|
4. **Service Worker Not Registered** - Hook checks for support but SW may fail to load
|
|
5. **PushManager Not Available** - Some browsers block push in non-HTTPS contexts
|
|
6. **Database Connection** - Store must be initialized before creating subscription routes
|
|
|
|
---
|
|
|
|
## 7. Recommendations for Your Project
|
|
|
|
1. **Add clear permission prompt** - Don't auto-prompt on load, wait for user action
|
|
2. **Include icons** - Notification needs 192x192+ icon and optional badge
|
|
3. **Handle location** - Use `tag` to group notifications
|
|
4. **Test on real device** - Most browsers require HTTPS and device support for push
|
|
5. **Fallback to in-app** - Always try SSE/Socket.IO first, use push as fallback
|
|
6. **Clear error messages** - Show why push failed (permission, support, etc.)
|
|
7. **Debounce events** - Prevent notification spam with 500ms-5s delays
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
This implementation uses a robust, production-ready approach combining:
|
|
|
|
- **VAPID Authentication** for secure push delivery
|
|
- **Workbox Service Worker** for caching and notification handling
|
|
- **Hybrid Delivery** (SSE first, push fallback) for optimal UX
|
|
- **Debouncing & Cooldown** to prevent spam
|
|
- **Database Persistence** for subscription management
|
|
- **Deep Linking** for seamless user experience
|
|
|
|
The architecture is modular and extensible, making it easy to add more notification channels (Telegram, Slack, etc.) without changing core logic.
|