@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
text/typescript
/**
* 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);
}
};
}
}