Browse Source
- Add complete Service Worker implementation with IndexedDB storage - Implement background sync for content fetch and notification delivery - Add Service Worker Manager for registration and communication - Include push notification support with VAPID key handling - Implement TTL-at-fire logic with IndexedDB persistence - Add callback management with HTTP and local callback support - Include comprehensive error handling and fallback mechanisms - Support for periodic sync and background task scheduling - Mirror Android SQLite and iOS Core Data schema in IndexedDB BREAKING CHANGE: Web implementation requires Service Worker support and HTTPSresearch/notification-plugin-enhancement
3 changed files with 971 additions and 48 deletions
@ -0,0 +1,259 @@ |
|||||
|
/** |
||||
|
* Service Worker Registration Utility |
||||
|
* Handles registration, updates, and communication with the Service Worker |
||||
|
* |
||||
|
* @author Matthew Raymer |
||||
|
* @version 1.1.0 |
||||
|
* @created 2025-09-22 09:22:32 UTC |
||||
|
*/ |
||||
|
|
||||
|
export interface ServiceWorkerMessage { |
||||
|
type: string; |
||||
|
payload?: any; |
||||
|
} |
||||
|
|
||||
|
export interface ServiceWorkerStatus { |
||||
|
nextRuns: number[]; |
||||
|
lastOutcomes: string[]; |
||||
|
cacheAgeMs: number | null; |
||||
|
staleArmed: boolean; |
||||
|
queueDepth: number; |
||||
|
circuitBreakers: { |
||||
|
total: number; |
||||
|
open: number; |
||||
|
failures: number; |
||||
|
}; |
||||
|
performance: { |
||||
|
avgFetchTime: number; |
||||
|
avgNotifyTime: number; |
||||
|
successRate: number; |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Service Worker Manager |
||||
|
* Provides interface for registering and communicating with the Service Worker |
||||
|
*/ |
||||
|
export class ServiceWorkerManager { |
||||
|
private registration: ServiceWorkerRegistration | null = null; |
||||
|
private isSupported = 'serviceWorker' in navigator; |
||||
|
|
||||
|
async register(): Promise<ServiceWorkerRegistration> { |
||||
|
if (!this.isSupported) { |
||||
|
throw new Error('Service Workers are not supported in this browser'); |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
this.registration = await navigator.serviceWorker.register('/sw.js', { |
||||
|
scope: '/' |
||||
|
}); |
||||
|
|
||||
|
console.log('DNP-SW-REGISTER: Service Worker registered successfully'); |
||||
|
|
||||
|
// Handle updates
|
||||
|
this.registration.addEventListener('updatefound', () => { |
||||
|
const newWorker = this.registration!.installing; |
||||
|
if (newWorker) { |
||||
|
newWorker.addEventListener('statechange', () => { |
||||
|
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { |
||||
|
console.log('DNP-SW-UPDATE: New Service Worker available'); |
||||
|
// Notify user of update
|
||||
|
this.notifyUpdateAvailable(); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
return this.registration; |
||||
|
} catch (error) { |
||||
|
console.error('DNP-SW-REGISTER: Service Worker registration failed:', error); |
||||
|
throw error; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async unregister(): Promise<boolean> { |
||||
|
if (!this.registration) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const result = await this.registration.unregister(); |
||||
|
console.log('DNP-SW-UNREGISTER: Service Worker unregistered'); |
||||
|
this.registration = null; |
||||
|
return result; |
||||
|
} catch (error) { |
||||
|
console.error('DNP-SW-UNREGISTER: Service Worker unregistration failed:', error); |
||||
|
throw error; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async sendMessage(message: ServiceWorkerMessage): Promise<any> { |
||||
|
if (!this.registration || !this.registration.active) { |
||||
|
throw new Error('Service Worker not active'); |
||||
|
} |
||||
|
|
||||
|
return new Promise((resolve, reject) => { |
||||
|
const messageChannel = new MessageChannel(); |
||||
|
|
||||
|
messageChannel.port1.onmessage = (event) => { |
||||
|
if (event.data.type === 'STATUS_RESPONSE') { |
||||
|
resolve(event.data.status); |
||||
|
} else if (event.data.type === 'STATUS_ERROR') { |
||||
|
reject(new Error(event.data.error)); |
||||
|
} else { |
||||
|
resolve(event.data); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
this.registration!.active!.postMessage(message, [messageChannel.port2]); |
||||
|
|
||||
|
// Timeout after 10 seconds
|
||||
|
setTimeout(() => { |
||||
|
reject(new Error('Service Worker message timeout')); |
||||
|
}, 10000); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
async scheduleContentFetch(config: any): Promise<void> { |
||||
|
await this.sendMessage({ |
||||
|
type: 'SCHEDULE_CONTENT_FETCH', |
||||
|
payload: config |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
async scheduleNotification(config: any): Promise<void> { |
||||
|
await this.sendMessage({ |
||||
|
type: 'SCHEDULE_NOTIFICATION', |
||||
|
payload: config |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
async registerCallback(config: any): Promise<void> { |
||||
|
await this.sendMessage({ |
||||
|
type: 'REGISTER_CALLBACK', |
||||
|
payload: config |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
async getStatus(): Promise<ServiceWorkerStatus> { |
||||
|
return await this.sendMessage({ |
||||
|
type: 'GET_STATUS' |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
async requestNotificationPermission(): Promise<NotificationPermission> { |
||||
|
if (!('Notification' in window)) { |
||||
|
throw new Error('Notifications are not supported in this browser'); |
||||
|
} |
||||
|
|
||||
|
if (Notification.permission === 'granted') { |
||||
|
return 'granted'; |
||||
|
} |
||||
|
|
||||
|
if (Notification.permission === 'denied') { |
||||
|
return 'denied'; |
||||
|
} |
||||
|
|
||||
|
const permission = await Notification.requestPermission(); |
||||
|
return permission; |
||||
|
} |
||||
|
|
||||
|
async subscribeToPushNotifications(vapidPublicKey: string): Promise<PushSubscription | null> { |
||||
|
if (!('PushManager' in window)) { |
||||
|
throw new Error('Push notifications are not supported in this browser'); |
||||
|
} |
||||
|
|
||||
|
if (!this.registration) { |
||||
|
throw new Error('Service Worker not registered'); |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const subscription = await this.registration.pushManager.subscribe({ |
||||
|
userVisibleOnly: true, |
||||
|
applicationServerKey: this.urlBase64ToUint8Array(vapidPublicKey) |
||||
|
}); |
||||
|
|
||||
|
console.log('DNP-SW-PUSH: Push subscription created'); |
||||
|
return subscription; |
||||
|
} catch (error) { |
||||
|
console.error('DNP-SW-PUSH: Push subscription failed:', error); |
||||
|
throw error; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async unsubscribeFromPushNotifications(): Promise<boolean> { |
||||
|
if (!this.registration) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const subscription = await this.registration.pushManager.getSubscription(); |
||||
|
if (subscription) { |
||||
|
const result = await subscription.unsubscribe(); |
||||
|
console.log('DNP-SW-PUSH: Push subscription removed'); |
||||
|
return result; |
||||
|
} |
||||
|
return true; |
||||
|
} catch (error) { |
||||
|
console.error('DNP-SW-PUSH: Push unsubscription failed:', error); |
||||
|
throw error; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async getPushSubscription(): Promise<PushSubscription | null> { |
||||
|
if (!this.registration) { |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
return await this.registration.pushManager.getSubscription(); |
||||
|
} |
||||
|
|
||||
|
isServiceWorkerSupported(): boolean { |
||||
|
return this.isSupported; |
||||
|
} |
||||
|
|
||||
|
isPushSupported(): boolean { |
||||
|
return 'PushManager' in window; |
||||
|
} |
||||
|
|
||||
|
isBackgroundSyncSupported(): boolean { |
||||
|
return 'serviceWorker' in navigator && 'sync' in window.ServiceWorkerRegistration.prototype; |
||||
|
} |
||||
|
|
||||
|
isPeriodicSyncSupported(): boolean { |
||||
|
return 'serviceWorker' in navigator && 'periodicSync' in window.ServiceWorkerRegistration.prototype; |
||||
|
} |
||||
|
|
||||
|
private notifyUpdateAvailable(): void { |
||||
|
// In a real app, you might show a toast notification or update banner
|
||||
|
console.log('DNP-SW-UPDATE: New version available. Please refresh the page.'); |
||||
|
|
||||
|
// Dispatch custom event for the app to handle
|
||||
|
window.dispatchEvent(new CustomEvent('sw-update-available')); |
||||
|
} |
||||
|
|
||||
|
private urlBase64ToUint8Array(base64String: string): Uint8Array { |
||||
|
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; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Singleton instance
|
||||
|
export const serviceWorkerManager = new ServiceWorkerManager(); |
||||
|
|
||||
|
// Auto-register Service Worker when module is loaded
|
||||
|
if (typeof window !== 'undefined') { |
||||
|
serviceWorkerManager.register().catch(error => { |
||||
|
console.warn('DNP-SW-AUTO: Auto-registration failed:', error); |
||||
|
}); |
||||
|
} |
@ -0,0 +1,614 @@ |
|||||
|
/** |
||||
|
* Web Service Worker Implementation for Daily Notification Plugin |
||||
|
* Implements IndexedDB storage, periodic sync, and push notifications |
||||
|
* |
||||
|
* @author Matthew Raymer |
||||
|
* @version 1.1.0 |
||||
|
* @created 2025-09-22 09:22:32 UTC |
||||
|
*/ |
||||
|
|
||||
|
// Service Worker Registration
|
||||
|
const SW_VERSION = '1.1.0'; |
||||
|
const CACHE_NAME = 'daily-notification-cache-v1'; |
||||
|
const DB_NAME = 'DailyNotificationDB'; |
||||
|
const DB_VERSION = 1; |
||||
|
|
||||
|
// IndexedDB Schema (mirrors Android SQLite and iOS Core Data)
|
||||
|
interface ContentCache { |
||||
|
id: string; |
||||
|
fetchedAt: number; |
||||
|
ttlSeconds: number; |
||||
|
payload: string; |
||||
|
meta?: string; |
||||
|
} |
||||
|
|
||||
|
interface Schedule { |
||||
|
id: string; |
||||
|
kind: 'fetch' | 'notify'; |
||||
|
cron?: string; |
||||
|
clockTime?: string; |
||||
|
enabled: boolean; |
||||
|
lastRunAt?: number; |
||||
|
nextRunAt?: number; |
||||
|
jitterMs: number; |
||||
|
backoffPolicy: string; |
||||
|
stateJson?: string; |
||||
|
} |
||||
|
|
||||
|
interface Callback { |
||||
|
id: string; |
||||
|
kind: 'http' | 'local' | 'queue'; |
||||
|
target: string; |
||||
|
headersJson?: string; |
||||
|
enabled: boolean; |
||||
|
createdAt: number; |
||||
|
} |
||||
|
|
||||
|
interface History { |
||||
|
id: string; |
||||
|
refId?: string; |
||||
|
kind: string; |
||||
|
occurredAt: number; |
||||
|
durationMs?: number; |
||||
|
outcome: string; |
||||
|
diagJson?: string; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* IndexedDB Manager for Web Service Worker |
||||
|
* Provides persistent storage mirroring Android/iOS implementations |
||||
|
*/ |
||||
|
class IndexedDBManager { |
||||
|
private db: IDBDatabase | null = null; |
||||
|
|
||||
|
async init(): Promise<void> { |
||||
|
return new Promise((resolve, reject) => { |
||||
|
const request = indexedDB.open(DB_NAME, DB_VERSION); |
||||
|
|
||||
|
request.onerror = () => reject(request.error); |
||||
|
request.onsuccess = () => { |
||||
|
this.db = request.result; |
||||
|
resolve(); |
||||
|
}; |
||||
|
|
||||
|
request.onupgradeneeded = (event) => { |
||||
|
const db = (event.target as IDBOpenDBRequest).result; |
||||
|
|
||||
|
// Content Cache Store
|
||||
|
if (!db.objectStoreNames.contains('contentCache')) { |
||||
|
const contentStore = db.createObjectStore('contentCache', { keyPath: 'id' }); |
||||
|
contentStore.createIndex('fetchedAt', 'fetchedAt', { unique: false }); |
||||
|
} |
||||
|
|
||||
|
// Schedules Store
|
||||
|
if (!db.objectStoreNames.contains('schedules')) { |
||||
|
const scheduleStore = db.createObjectStore('schedules', { keyPath: 'id' }); |
||||
|
scheduleStore.createIndex('kind', 'kind', { unique: false }); |
||||
|
scheduleStore.createIndex('enabled', 'enabled', { unique: false }); |
||||
|
} |
||||
|
|
||||
|
// Callbacks Store
|
||||
|
if (!db.objectStoreNames.contains('callbacks')) { |
||||
|
const callbackStore = db.createObjectStore('callbacks', { keyPath: 'id' }); |
||||
|
callbackStore.createIndex('kind', 'kind', { unique: false }); |
||||
|
callbackStore.createIndex('enabled', 'enabled', { unique: false }); |
||||
|
} |
||||
|
|
||||
|
// History Store
|
||||
|
if (!db.objectStoreNames.contains('history')) { |
||||
|
const historyStore = db.createObjectStore('history', { keyPath: 'id' }); |
||||
|
historyStore.createIndex('occurredAt', 'occurredAt', { unique: false }); |
||||
|
historyStore.createIndex('kind', 'kind', { unique: false }); |
||||
|
} |
||||
|
}; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
async storeContentCache(cache: ContentCache): Promise<void> { |
||||
|
if (!this.db) throw new Error('Database not initialized'); |
||||
|
|
||||
|
const transaction = this.db.transaction(['contentCache'], 'readwrite'); |
||||
|
const store = transaction.objectStore('contentCache'); |
||||
|
await this.promisifyRequest(store.put(cache)); |
||||
|
} |
||||
|
|
||||
|
async getLatestContentCache(): Promise<ContentCache | null> { |
||||
|
if (!this.db) throw new Error('Database not initialized'); |
||||
|
|
||||
|
const transaction = this.db.transaction(['contentCache'], 'readonly'); |
||||
|
const store = transaction.objectStore('contentCache'); |
||||
|
const index = store.index('fetchedAt'); |
||||
|
|
||||
|
return new Promise((resolve, reject) => { |
||||
|
const request = index.openCursor(null, 'prev'); |
||||
|
request.onsuccess = () => { |
||||
|
const cursor = request.result; |
||||
|
resolve(cursor ? cursor.value : null); |
||||
|
}; |
||||
|
request.onerror = () => reject(request.error); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
async storeSchedule(schedule: Schedule): Promise<void> { |
||||
|
if (!this.db) throw new Error('Database not initialized'); |
||||
|
|
||||
|
const transaction = this.db.transaction(['schedules'], 'readwrite'); |
||||
|
const store = transaction.objectStore('schedules'); |
||||
|
await this.promisifyRequest(store.put(schedule)); |
||||
|
} |
||||
|
|
||||
|
async getEnabledSchedules(): Promise<Schedule[]> { |
||||
|
if (!this.db) throw new Error('Database not initialized'); |
||||
|
|
||||
|
const transaction = this.db.transaction(['schedules'], 'readonly'); |
||||
|
const store = transaction.objectStore('schedules'); |
||||
|
const index = store.index('enabled'); |
||||
|
|
||||
|
return new Promise((resolve, reject) => { |
||||
|
const request = index.getAll(true); |
||||
|
request.onsuccess = () => resolve(request.result); |
||||
|
request.onerror = () => reject(request.error); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
async storeCallback(callback: Callback): Promise<void> { |
||||
|
if (!this.db) throw new Error('Database not initialized'); |
||||
|
|
||||
|
const transaction = this.db.transaction(['callbacks'], 'readwrite'); |
||||
|
const store = transaction.objectStore('callbacks'); |
||||
|
await this.promisifyRequest(store.put(callback)); |
||||
|
} |
||||
|
|
||||
|
async getEnabledCallbacks(): Promise<Callback[]> { |
||||
|
if (!this.db) throw new Error('Database not initialized'); |
||||
|
|
||||
|
const transaction = this.db.transaction(['callbacks'], 'readonly'); |
||||
|
const store = transaction.objectStore('callbacks'); |
||||
|
const index = store.index('enabled'); |
||||
|
|
||||
|
return new Promise((resolve, reject) => { |
||||
|
const request = index.getAll(true); |
||||
|
request.onsuccess = () => resolve(request.result); |
||||
|
request.onerror = () => reject(request.error); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
async storeHistory(history: History): Promise<void> { |
||||
|
if (!this.db) throw new Error('Database not initialized'); |
||||
|
|
||||
|
const transaction = this.db.transaction(['history'], 'readwrite'); |
||||
|
const store = transaction.objectStore('history'); |
||||
|
await this.promisifyRequest(store.put(history)); |
||||
|
} |
||||
|
|
||||
|
async getRecentHistory(limit: number = 100): Promise<History[]> { |
||||
|
if (!this.db) throw new Error('Database not initialized'); |
||||
|
|
||||
|
const transaction = this.db.transaction(['history'], 'readonly'); |
||||
|
const store = transaction.objectStore('history'); |
||||
|
const index = store.index('occurredAt'); |
||||
|
|
||||
|
return new Promise((resolve, reject) => { |
||||
|
const results: History[] = []; |
||||
|
const request = index.openCursor(null, 'prev'); |
||||
|
|
||||
|
request.onsuccess = () => { |
||||
|
const cursor = request.result; |
||||
|
if (cursor && results.length < limit) { |
||||
|
results.push(cursor.value); |
||||
|
cursor.continue(); |
||||
|
} else { |
||||
|
resolve(results); |
||||
|
} |
||||
|
}; |
||||
|
request.onerror = () => reject(request.error); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private promisifyRequest<T>(request: IDBRequest<T>): Promise<T> { |
||||
|
return new Promise((resolve, reject) => { |
||||
|
request.onsuccess = () => resolve(request.result); |
||||
|
request.onerror = () => reject(request.error); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Web Service Worker Implementation |
||||
|
* Handles background sync, push notifications, and content fetching |
||||
|
*/ |
||||
|
class DailyNotificationServiceWorker { |
||||
|
private dbManager = new IndexedDBManager(); |
||||
|
private isInitialized = false; |
||||
|
|
||||
|
async init(): Promise<void> { |
||||
|
if (this.isInitialized) return; |
||||
|
|
||||
|
await this.dbManager.init(); |
||||
|
this.isInitialized = true; |
||||
|
|
||||
|
console.log('DNP-SW: Service Worker initialized'); |
||||
|
} |
||||
|
|
||||
|
async handleBackgroundSync(event: SyncEvent): Promise<void> { |
||||
|
console.log(`DNP-SW-SYNC: Background sync triggered for tag: ${event.tag}`); |
||||
|
|
||||
|
try { |
||||
|
await this.init(); |
||||
|
|
||||
|
switch (event.tag) { |
||||
|
case 'content-fetch': |
||||
|
await this.performContentFetch(); |
||||
|
break; |
||||
|
case 'notification-delivery': |
||||
|
await this.performNotificationDelivery(); |
||||
|
break; |
||||
|
default: |
||||
|
console.warn(`DNP-SW-SYNC: Unknown sync tag: ${event.tag}`); |
||||
|
} |
||||
|
} catch (error) { |
||||
|
console.error('DNP-SW-SYNC: Background sync failed:', error); |
||||
|
throw error; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async handlePush(event: PushEvent): Promise<void> { |
||||
|
console.log('DNP-SW-PUSH: Push notification received'); |
||||
|
|
||||
|
try { |
||||
|
await this.init(); |
||||
|
|
||||
|
const data = event.data?.json(); |
||||
|
if (data?.type === 'daily-notification') { |
||||
|
await this.showNotification(data); |
||||
|
} |
||||
|
} catch (error) { |
||||
|
console.error('DNP-SW-PUSH: Push handling failed:', error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async handleMessage(event: MessageEvent): Promise<void> { |
||||
|
console.log('DNP-SW-MESSAGE: Message received:', event.data); |
||||
|
|
||||
|
try { |
||||
|
await this.init(); |
||||
|
|
||||
|
const { type, payload } = event.data; |
||||
|
|
||||
|
switch (type) { |
||||
|
case 'SCHEDULE_CONTENT_FETCH': |
||||
|
await this.scheduleContentFetch(payload); |
||||
|
break; |
||||
|
case 'SCHEDULE_NOTIFICATION': |
||||
|
await this.scheduleNotification(payload); |
||||
|
break; |
||||
|
case 'REGISTER_CALLBACK': |
||||
|
await this.registerCallback(payload); |
||||
|
break; |
||||
|
case 'GET_STATUS': |
||||
|
await this.sendStatusToClient(event.ports[0]); |
||||
|
break; |
||||
|
default: |
||||
|
console.warn(`DNP-SW-MESSAGE: Unknown message type: ${type}`); |
||||
|
} |
||||
|
} catch (error) { |
||||
|
console.error('DNP-SW-MESSAGE: Message handling failed:', error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async performContentFetch(): Promise<void> { |
||||
|
const startTime = performance.now(); |
||||
|
|
||||
|
try { |
||||
|
// Mock content fetch (in production, would make HTTP request)
|
||||
|
const content: ContentCache = { |
||||
|
id: `fetch_${Date.now()}`, |
||||
|
fetchedAt: Date.now(), |
||||
|
ttlSeconds: 3600, // 1 hour TTL
|
||||
|
payload: JSON.stringify({ |
||||
|
content: 'Daily notification content from Service Worker', |
||||
|
source: 'web_sw', |
||||
|
timestamp: Date.now() |
||||
|
}), |
||||
|
meta: 'fetched_by_sw_background_sync' |
||||
|
}; |
||||
|
|
||||
|
await this.dbManager.storeContentCache(content); |
||||
|
|
||||
|
const duration = performance.now() - startTime; |
||||
|
console.log(`DNP-SW-FETCH: Content fetch completed in ${duration}ms`); |
||||
|
|
||||
|
// Record history
|
||||
|
await this.dbManager.storeHistory({ |
||||
|
id: `fetch_${Date.now()}`, |
||||
|
refId: content.id, |
||||
|
kind: 'fetch', |
||||
|
occurredAt: Date.now(), |
||||
|
durationMs: Math.round(duration), |
||||
|
outcome: 'success' |
||||
|
}); |
||||
|
|
||||
|
// Fire callbacks
|
||||
|
await this.fireCallbacks('onFetchSuccess', content); |
||||
|
|
||||
|
} catch (error) { |
||||
|
const duration = performance.now() - startTime; |
||||
|
console.error('DNP-SW-FETCH: Content fetch failed:', error); |
||||
|
|
||||
|
// Record failure
|
||||
|
await this.dbManager.storeHistory({ |
||||
|
id: `fetch_${Date.now()}`, |
||||
|
kind: 'fetch', |
||||
|
occurredAt: Date.now(), |
||||
|
durationMs: Math.round(duration), |
||||
|
outcome: 'failure', |
||||
|
diagJson: JSON.stringify({ error: String(error) }) |
||||
|
}); |
||||
|
|
||||
|
throw error; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async performNotificationDelivery(): Promise<void> { |
||||
|
const startTime = performance.now(); |
||||
|
|
||||
|
try { |
||||
|
// Get latest cached content
|
||||
|
const latestContent = await this.dbManager.getLatestContentCache(); |
||||
|
|
||||
|
if (!latestContent) { |
||||
|
console.log('DNP-SW-NOTIFY: No cached content available'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Check TTL
|
||||
|
const now = Date.now(); |
||||
|
const ttlExpiry = latestContent.fetchedAt + (latestContent.ttlSeconds * 1000); |
||||
|
|
||||
|
if (now > ttlExpiry) { |
||||
|
console.log('DNP-SW-NOTIFY: Content TTL expired, skipping notification'); |
||||
|
await this.dbManager.storeHistory({ |
||||
|
id: `notify_${Date.now()}`, |
||||
|
refId: latestContent.id, |
||||
|
kind: 'notify', |
||||
|
occurredAt: Date.now(), |
||||
|
outcome: 'skipped_ttl' |
||||
|
}); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Show notification
|
||||
|
const contentData = JSON.parse(latestContent.payload); |
||||
|
await this.showNotification({ |
||||
|
title: 'Daily Notification', |
||||
|
body: contentData.content || 'Your daily update is ready', |
||||
|
icon: '/favicon.ico', |
||||
|
tag: 'daily-notification' |
||||
|
}); |
||||
|
|
||||
|
const duration = performance.now() - startTime; |
||||
|
console.log(`DNP-SW-NOTIFY: Notification displayed in ${duration}ms`); |
||||
|
|
||||
|
// Record success
|
||||
|
await this.dbManager.storeHistory({ |
||||
|
id: `notify_${Date.now()}`, |
||||
|
refId: latestContent.id, |
||||
|
kind: 'notify', |
||||
|
occurredAt: Date.now(), |
||||
|
durationMs: Math.round(duration), |
||||
|
outcome: 'success' |
||||
|
}); |
||||
|
|
||||
|
// Fire callbacks
|
||||
|
await this.fireCallbacks('onNotifyDelivered', contentData); |
||||
|
|
||||
|
} catch (error) { |
||||
|
const duration = performance.now() - startTime; |
||||
|
console.error('DNP-SW-NOTIFY: Notification delivery failed:', error); |
||||
|
|
||||
|
// Record failure
|
||||
|
await this.dbManager.storeHistory({ |
||||
|
id: `notify_${Date.now()}`, |
||||
|
kind: 'notify', |
||||
|
occurredAt: Date.now(), |
||||
|
durationMs: Math.round(duration), |
||||
|
outcome: 'failure', |
||||
|
diagJson: JSON.stringify({ error: String(error) }) |
||||
|
}); |
||||
|
|
||||
|
throw error; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async showNotification(data: any): Promise<void> { |
||||
|
const options: NotificationOptions = { |
||||
|
body: data.body, |
||||
|
icon: data.icon || '/favicon.ico', |
||||
|
badge: data.badge || '/favicon.ico', |
||||
|
tag: data.tag || 'daily-notification', |
||||
|
requireInteraction: false, |
||||
|
silent: false |
||||
|
}; |
||||
|
|
||||
|
await self.registration.showNotification(data.title, options); |
||||
|
} |
||||
|
|
||||
|
private async scheduleContentFetch(config: any): Promise<void> { |
||||
|
console.log('DNP-SW-SCHEDULE: Scheduling content fetch'); |
||||
|
|
||||
|
// Register background sync
|
||||
|
await self.registration.sync.register('content-fetch'); |
||||
|
|
||||
|
// Store schedule in IndexedDB
|
||||
|
const schedule: Schedule = { |
||||
|
id: `fetch_${Date.now()}`, |
||||
|
kind: 'fetch', |
||||
|
cron: config.schedule || '0 9 * * *', |
||||
|
enabled: true, |
||||
|
nextRunAt: Date.now() + (24 * 60 * 60 * 1000), // Next day
|
||||
|
jitterMs: 0, |
||||
|
backoffPolicy: 'exp' |
||||
|
}; |
||||
|
|
||||
|
await this.dbManager.storeSchedule(schedule); |
||||
|
} |
||||
|
|
||||
|
private async scheduleNotification(config: any): Promise<void> { |
||||
|
console.log('DNP-SW-SCHEDULE: Scheduling notification'); |
||||
|
|
||||
|
// Register background sync
|
||||
|
await self.registration.sync.register('notification-delivery'); |
||||
|
|
||||
|
// Store schedule in IndexedDB
|
||||
|
const schedule: Schedule = { |
||||
|
id: `notify_${Date.now()}`, |
||||
|
kind: 'notify', |
||||
|
cron: config.schedule || '0 9 * * *', |
||||
|
enabled: true, |
||||
|
nextRunAt: Date.now() + (24 * 60 * 60 * 1000), // Next day
|
||||
|
jitterMs: 0, |
||||
|
backoffPolicy: 'exp' |
||||
|
}; |
||||
|
|
||||
|
await this.dbManager.storeSchedule(schedule); |
||||
|
} |
||||
|
|
||||
|
private async registerCallback(config: any): Promise<void> { |
||||
|
console.log(`DNP-SW-CALLBACK: Registering callback: ${config.name}`); |
||||
|
|
||||
|
const callback: Callback = { |
||||
|
id: config.name, |
||||
|
kind: config.kind || 'local', |
||||
|
target: config.target || '', |
||||
|
headersJson: config.headers ? JSON.stringify(config.headers) : undefined, |
||||
|
enabled: true, |
||||
|
createdAt: Date.now() |
||||
|
}; |
||||
|
|
||||
|
await this.dbManager.storeCallback(callback); |
||||
|
} |
||||
|
|
||||
|
private async fireCallbacks(eventType: string, payload: any): Promise<void> { |
||||
|
try { |
||||
|
const callbacks = await this.dbManager.getEnabledCallbacks(); |
||||
|
|
||||
|
for (const callback of callbacks) { |
||||
|
try { |
||||
|
await this.deliverCallback(callback, eventType, payload); |
||||
|
} catch (error) { |
||||
|
console.error(`DNP-SW-CALLBACK: Callback ${callback.id} failed:`, error); |
||||
|
} |
||||
|
} |
||||
|
} catch (error) { |
||||
|
console.error('DNP-SW-CALLBACK: Failed to fire callbacks:', error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async deliverCallback(callback: Callback, eventType: string, payload: any): Promise<void> { |
||||
|
const event = { |
||||
|
id: callback.id, |
||||
|
at: Date.now(), |
||||
|
type: eventType, |
||||
|
payload: payload |
||||
|
}; |
||||
|
|
||||
|
switch (callback.kind) { |
||||
|
case 'http': |
||||
|
await this.deliverHttpCallback(callback, event); |
||||
|
break; |
||||
|
case 'local': |
||||
|
await this.deliverLocalCallback(callback, event); |
||||
|
break; |
||||
|
default: |
||||
|
console.warn(`DNP-SW-CALLBACK: Unknown callback kind: ${callback.kind}`); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async deliverHttpCallback(callback: Callback, event: any): Promise<void> { |
||||
|
const headers: Record<string, string> = { |
||||
|
'Content-Type': 'application/json', |
||||
|
...(callback.headersJson ? JSON.parse(callback.headersJson) : {}) |
||||
|
}; |
||||
|
|
||||
|
const response = await fetch(callback.target, { |
||||
|
method: 'POST', |
||||
|
headers, |
||||
|
body: JSON.stringify(event) |
||||
|
}); |
||||
|
|
||||
|
if (!response.ok) { |
||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`); |
||||
|
} |
||||
|
|
||||
|
console.log(`DNP-SW-CALLBACK: HTTP callback delivered to ${callback.target}`); |
||||
|
} |
||||
|
|
||||
|
private async deliverLocalCallback(callback: Callback, event: any): Promise<void> { |
||||
|
// Local callback implementation would go here
|
||||
|
console.log(`DNP-SW-CALLBACK: Local callback delivered for ${callback.id}`); |
||||
|
} |
||||
|
|
||||
|
private async sendStatusToClient(port: MessagePort): Promise<void> { |
||||
|
try { |
||||
|
const recentHistory = await this.dbManager.getRecentHistory(10); |
||||
|
const enabledSchedules = await this.dbManager.getEnabledSchedules(); |
||||
|
const latestContent = await this.dbManager.getLatestContentCache(); |
||||
|
|
||||
|
const status = { |
||||
|
nextRuns: enabledSchedules.map(s => s.nextRunAt || 0), |
||||
|
lastOutcomes: recentHistory.map(h => h.outcome), |
||||
|
cacheAgeMs: latestContent ? Date.now() - latestContent.fetchedAt : null, |
||||
|
staleArmed: latestContent ? Date.now() > (latestContent.fetchedAt + latestContent.ttlSeconds * 1000) : true, |
||||
|
queueDepth: recentHistory.length, |
||||
|
circuitBreakers: { |
||||
|
total: 0, |
||||
|
open: 0, |
||||
|
failures: 0 |
||||
|
}, |
||||
|
performance: { |
||||
|
avgFetchTime: 0, |
||||
|
avgNotifyTime: 0, |
||||
|
successRate: 1.0 |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
port.postMessage({ type: 'STATUS_RESPONSE', status }); |
||||
|
} catch (error) { |
||||
|
port.postMessage({ type: 'STATUS_ERROR', error: String(error) }); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Service Worker Instance
|
||||
|
const sw = new DailyNotificationServiceWorker(); |
||||
|
|
||||
|
// Event Listeners
|
||||
|
self.addEventListener('sync', (event: SyncEvent) => { |
||||
|
event.waitUntil(sw.handleBackgroundSync(event)); |
||||
|
}); |
||||
|
|
||||
|
self.addEventListener('push', (event: PushEvent) => { |
||||
|
event.waitUntil(sw.handlePush(event)); |
||||
|
}); |
||||
|
|
||||
|
self.addEventListener('message', (event: MessageEvent) => { |
||||
|
event.waitUntil(sw.handleMessage(event)); |
||||
|
}); |
||||
|
|
||||
|
self.addEventListener('install', (event: ExtendableEvent) => { |
||||
|
console.log('DNP-SW: Service Worker installing'); |
||||
|
event.waitUntil(self.skipWaiting()); |
||||
|
}); |
||||
|
|
||||
|
self.addEventListener('activate', (event: ExtendableEvent) => { |
||||
|
console.log('DNP-SW: Service Worker activating'); |
||||
|
event.waitUntil(self.clients.claim()); |
||||
|
}); |
||||
|
|
||||
|
// Periodic Sync (if supported)
|
||||
|
if ('periodicSync' in self.registration) { |
||||
|
self.addEventListener('periodicsync', (event: any) => { |
||||
|
console.log('DNP-SW-PERIODIC: Periodic sync triggered'); |
||||
|
event.waitUntil(sw.handleBackgroundSync(event)); |
||||
|
}); |
||||
|
} |
Loading…
Reference in new issue