UNPKG

@jager-ai/holy-pwa

Version:

Progressive Web App (PWA) utilities and templates extracted from Holy Habit project with manifest generation, service worker management, and offline support

581 lines (517 loc) 16.3 kB
/** * Service Worker Generator * * PWA service worker generator with caching strategies * Extracted from Holy Habit service worker implementation */ import { ServiceWorkerConfig, CacheStrategy, ServiceWorkerError } from '../types/PWA'; export class ServiceWorkerGenerator { private config: ServiceWorkerConfig; constructor(config: ServiceWorkerConfig) { this.config = config; this.validateConfig(); } /** * Generate complete service worker JavaScript code * * @returns Service worker JavaScript code as string */ generate(): string { const parts = [ this.generateHeader(), this.generateCacheConfiguration(), this.generateInstallEvent(), this.generateActivateEvent(), this.generateFetchEvent(), this.generateSyncEvent(), this.generatePushNotificationEvents(), this.generateUpdateHandling(), this.generateUtilityFunctions() ]; return parts.join('\n\n'); } /** * Generate service worker header with version and cache configuration */ private generateHeader(): string { return `// Service Worker generated by @mvp-factory/holy-pwa // Version: ${this.config.version} // Cache Name: ${this.config.cacheName} const CACHE_VERSION = '${this.config.version}'; const CACHE_NAME = \`${this.config.cacheName}-v\${CACHE_VERSION}\`;`; } /** * Generate cache configuration */ private generateCacheConfiguration(): string { const urlsToCache = this.config.urlsToCache.map(url => ` '${url}'`).join(',\n'); return `// URLs to cache on install const urlsToCache = [ ${urlsToCache} ];`; } /** * Generate install event handler */ private generateInstallEvent(): string { const skipWaiting = this.config.skipWaiting ? '\n .then(() => self.skipWaiting())' : ''; return `// Install event - cache resources self.addEventListener('install', event => { console.log('Service Worker: Installing...'); event.waitUntil( caches.open(CACHE_NAME) .then(cache => { console.log('Service Worker: Caching files'); return cache.addAll(urlsToCache); })${skipWaiting} .catch(error => { console.error('Service Worker: Install failed', error); }) ); });`; } /** * Generate activate event handler */ private generateActivateEvent(): string { const clientsClaim = this.config.clientsClaim ? '\n }).then(() => self.clients.claim())' : ''; return `// Activate event - clean up old caches self.addEventListener('activate', event => { console.log('Service Worker: Activating...'); event.waitUntil( caches.keys().then(cacheNames => { return Promise.all( cacheNames.map(cacheName => { if (cacheName !== CACHE_NAME) { console.log('Service Worker: Deleting old cache:', cacheName); return caches.delete(cacheName); } }) );${clientsClaim} }) ); });`; } /** * Generate fetch event handler with caching strategies */ private generateFetchEvent(): string { return `// Fetch event - implement caching strategies self.addEventListener('fetch', event => { const requestUrl = new URL(event.request.url); // Skip non-GET requests if (event.request.method !== 'GET') { return; } ${this.generateCacheStrategies()} // Default: Network first, cache fallback event.respondWith( fetch(event.request) .then(response => { // Cache successful responses if (response && response.status === 200 && response.type === 'basic') { const responseToCache = response.clone(); caches.open(CACHE_NAME) .then(cache => { cache.put(event.request, responseToCache); }); } return response; }) .catch(() => { // Network failed, try cache return caches.match(event.request) .then(response => { if (response) { return response; } // No cache match, return offline page for navigation requests if (event.request.mode === 'navigate' && '${this.config.offlinePageUrl || '/offline.html'}') { return caches.match('${this.config.offlinePageUrl || '/offline.html'}'); } // Return empty response for other requests return new Response('', { status: 200 }); }); }) ); });`; } /** * Generate cache strategies based on URL patterns */ private generateCacheStrategies(): string { const strategies: string[] = []; // Network Only (API requests) if (this.config.networkOnly && this.config.networkOnly.length > 0) { const patterns = this.config.networkOnly.map(pattern => `requestUrl.pathname.includes('${pattern}')`).join(' || '); strategies.push(` // Network Only - API requests if (${patterns}) { event.respondWith( fetch(event.request).catch(() => { return new Response( JSON.stringify({ error: 'Network unavailable. Please check your internet connection.' }), { headers: { 'Content-Type': 'application/json' } } ); }) ); return; }`); } // Cache Only if (this.config.cacheOnly && this.config.cacheOnly.length > 0) { const patterns = this.config.cacheOnly.map(pattern => `requestUrl.pathname.includes('${pattern}')`).join(' || '); strategies.push(` // Cache Only if (${patterns}) { event.respondWith(caches.match(event.request)); return; }`); } // Cache First if (this.config.cacheFirst && this.config.cacheFirst.length > 0) { const patterns = this.config.cacheFirst.map(pattern => `requestUrl.pathname.includes('${pattern}')`).join(' || '); strategies.push(` // Cache First - static assets if (${patterns}) { event.respondWith( caches.match(event.request) .then(response => { if (response) { return response; } return fetch(event.request).then(fetchResponse => { if (fetchResponse && fetchResponse.status === 200) { const responseToCache = fetchResponse.clone(); caches.open(CACHE_NAME).then(cache => { cache.put(event.request, responseToCache); }); } return fetchResponse; }); }) ); return; }`); } // Stale While Revalidate if (this.config.staleWhileRevalidate && this.config.staleWhileRevalidate.length > 0) { const patterns = this.config.staleWhileRevalidate.map(pattern => `requestUrl.pathname.includes('${pattern}')`).join(' || '); strategies.push(` // Stale While Revalidate if (${patterns}) { event.respondWith( caches.match(event.request) .then(response => { const fetchPromise = fetch(event.request).then(fetchResponse => { if (fetchResponse && fetchResponse.status === 200) { const responseToCache = fetchResponse.clone(); caches.open(CACHE_NAME).then(cache => { cache.put(event.request, responseToCache); }); } return fetchResponse; }); return response || fetchPromise; }) ); return; }`); } return strategies.join('\n'); } /** * Generate background sync event handler */ private generateSyncEvent(): string { if (!this.config.enableBackgroundSync) { return '// Background sync is disabled'; } return `// Background Sync self.addEventListener('sync', event => { console.log('Service Worker: Background sync triggered'); if (event.tag === 'background-sync') { event.waitUntil(doBackgroundSync()); } }); // Background sync implementation async function doBackgroundSync() { try { // Get offline data from IndexedDB or localStorage const offlineData = await getOfflineData(); if (offlineData && offlineData.length > 0) { for (const item of offlineData) { try { const response = await fetch('/api/sync', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(item) }); if (response.ok) { await removeOfflineData(item.id); } } catch (error) { console.error('Sync failed for item:', item.id, error); } } } } catch (error) { console.error('Background sync failed:', error); } }`; } /** * Generate push notification event handlers */ private generatePushNotificationEvents(): string { if (!this.config.enablePushNotifications) { return '// Push notifications are disabled'; } return `// Push Notifications self.addEventListener('push', event => { console.log('Service Worker: Push notification received'); const options = { body: event.data ? event.data.text() : 'New notification available', icon: '/assets/icons/icon-192x192.png', badge: '/assets/icons/icon-72x72.png', vibrate: [200, 100, 200], tag: 'pwa-notification', renotify: true, requireInteraction: false, actions: [ { action: 'open', title: 'Open', icon: '/assets/icons/icon-72x72.png' }, { action: 'close', title: 'Close', icon: '/assets/icons/icon-72x72.png' } ] }; event.waitUntil( self.registration.showNotification('${this.config.cacheName}', options) ); }); // Notification click handler self.addEventListener('notificationclick', event => { console.log('Service Worker: Notification clicked'); event.notification.close(); if (event.action === 'open' || !event.action) { event.waitUntil( clients.openWindow('/') ); } });`; } /** * Generate update handling */ private generateUpdateHandling(): string { return `// Update Handling self.addEventListener('message', event => { if (event.data && event.data.type === 'SKIP_WAITING') { self.skipWaiting(); event.ports[0].postMessage({ type: 'UPDATE_APPLIED' }); } if (event.data && event.data.type === 'CHECK_FOR_UPDATE') { checkForUpdate().then(updateAvailable => { event.ports[0].postMessage({ type: 'UPDATE_CHECK_RESULT', updateAvailable: updateAvailable, currentVersion: CACHE_VERSION }); }); } }); // Check for updates async function checkForUpdate() { try { const response = await fetch('/service-worker.js', { cache: 'no-cache' }); const newSWContent = await response.text(); const versionMatch = newSWContent.match(/const CACHE_VERSION = '([^']+)'/); const newVersion = versionMatch ? versionMatch[1] : null; if (newVersion && newVersion !== CACHE_VERSION) { console.log(\`New version detected! Current: \${CACHE_VERSION}, New: \${newVersion}\`); return true; } return false; } catch (error) { console.error('Update check failed:', error); return false; } }`; } /** * Generate utility functions */ private generateUtilityFunctions(): string { return `// Utility Functions async function getOfflineData() { // Implement based on your storage needs (IndexedDB, localStorage, etc.) try { // Example implementation for localStorage const data = localStorage.getItem('offline-data'); return data ? JSON.parse(data) : []; } catch (error) { console.error('Failed to get offline data:', error); return []; } } async function removeOfflineData(id) { // Implement based on your storage needs try { const data = await getOfflineData(); const filtered = data.filter(item => item.id !== id); localStorage.setItem('offline-data', JSON.stringify(filtered)); } catch (error) { console.error('Failed to remove offline data:', error); } } // Network status detection function isOnline() { return navigator.onLine; } // Cache size management async function getCacheSize() { const cacheNames = await caches.keys(); let totalSize = 0; for (const cacheName of cacheNames) { const cache = await caches.open(cacheName); const requests = await cache.keys(); for (const request of requests) { const response = await cache.match(request); if (response) { const blob = await response.blob(); totalSize += blob.size; } } } return totalSize; } // Cleanup old caches if size exceeds limit async function cleanupIfNeeded(maxSizeBytes = 50 * 1024 * 1024) { // 50MB default const currentSize = await getCacheSize(); if (currentSize > maxSizeBytes) { const cacheNames = await caches.keys(); const oldCaches = cacheNames.filter(name => name !== CACHE_NAME); // Delete oldest caches first for (const cacheName of oldCaches) { await caches.delete(cacheName); console.log('Cleaned up old cache:', cacheName); const newSize = await getCacheSize(); if (newSize <= maxSizeBytes) { break; } } } }`; } /** * Validate configuration */ private validateConfig(): void { const required = ['cacheName', 'version', 'urlsToCache']; for (const field of required) { if (!this.config[field as keyof ServiceWorkerConfig]) { throw new ServiceWorkerError(`Missing required field: ${field}`); } } if (!Array.isArray(this.config.urlsToCache) || this.config.urlsToCache.length === 0) { throw new ServiceWorkerError('urlsToCache must be a non-empty array'); } } /** * Update configuration */ updateConfig(newConfig: Partial<ServiceWorkerConfig>): void { this.config = { ...this.config, ...newConfig }; this.validateConfig(); } /** * Get current configuration */ getConfig(): ServiceWorkerConfig { return { ...this.config }; } /** * Create service worker templates for common use cases */ static createTemplates() { return { /** * Basic service worker with essential features */ basic: (config: Partial<ServiceWorkerConfig>): ServiceWorkerGenerator => { const basicConfig: ServiceWorkerConfig = { cacheName: 'basic-pwa', version: '1.0.0', urlsToCache: [ '/', '/assets/css/style.css', '/assets/js/main.js', '/offline.html' ], offlinePageUrl: '/offline.html', skipWaiting: true, clientsClaim: true, ...config }; return new ServiceWorkerGenerator(basicConfig); }, /** * Advanced service worker with all features */ advanced: (config: Partial<ServiceWorkerConfig>): ServiceWorkerGenerator => { const advancedConfig: ServiceWorkerConfig = { cacheName: 'advanced-pwa', version: '1.0.0', urlsToCache: [ '/', '/assets/css/style.css', '/assets/js/main.js', '/offline.html' ], networkFirst: ['/api/'], cacheFirst: ['/assets/', '/images/'], staleWhileRevalidate: ['/data/'], networkOnly: ['/api/real-time'], offlinePageUrl: '/offline.html', enableBackgroundSync: true, enablePushNotifications: true, skipWaiting: true, clientsClaim: true, ...config }; return new ServiceWorkerGenerator(advancedConfig); }, /** * Offline-first service worker */ offlineFirst: (config: Partial<ServiceWorkerConfig>): ServiceWorkerGenerator => { const offlineConfig: ServiceWorkerConfig = { cacheName: 'offline-first-pwa', version: '1.0.0', urlsToCache: [ '/', '/assets/css/style.css', '/assets/js/main.js', '/offline.html' ], cacheFirst: ['/', '/assets/', '/data/'], networkOnly: ['/api/sync'], offlinePageUrl: '/offline.html', enableBackgroundSync: true, skipWaiting: true, clientsClaim: true, ...config }; return new ServiceWorkerGenerator(offlineConfig); } }; } }