feat(pwa): implement service worker and PWA enhancements
- Added service worker (sw.js) for offline functionality, caching strategies, and performance improvements. - Registered service worker in App component to manage updates and offline readiness. - Introduced ServiceWorkerStatus component to display connection status and provide cache management controls. - Created manifest.json for PWA configuration, including app name, icons, and display settings. - Updated index.html with PWA meta tags and links to support mobile web app capabilities. - Refactored authentication guards to utilize useAuthStatus hook for improved user state management. - Removed deprecated unregister-sw.js file to streamline service worker management.
This commit is contained in:
345
worklenz-frontend/public/sw.js
Normal file
345
worklenz-frontend/public/sw.js
Normal file
@@ -0,0 +1,345 @@
|
||||
// Worklenz Service Worker
|
||||
// Provides offline functionality, caching, and performance improvements
|
||||
|
||||
const CACHE_VERSION = 'v1.0.0';
|
||||
const CACHE_NAMES = {
|
||||
STATIC: `worklenz-static-${CACHE_VERSION}`,
|
||||
DYNAMIC: `worklenz-dynamic-${CACHE_VERSION}`,
|
||||
API: `worklenz-api-${CACHE_VERSION}`,
|
||||
IMAGES: `worklenz-images-${CACHE_VERSION}`
|
||||
};
|
||||
|
||||
// Resources to cache immediately on install
|
||||
const STATIC_CACHE_URLS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/favicon.ico',
|
||||
'/env-config.js',
|
||||
'/manifest.json',
|
||||
// Ant Design and other critical CSS/JS will be cached as they're requested
|
||||
];
|
||||
|
||||
// API endpoints that can be cached
|
||||
const CACHEABLE_API_PATTERNS = [
|
||||
/\/api\/project-categories/,
|
||||
/\/api\/project-statuses/,
|
||||
/\/api\/task-priorities/,
|
||||
/\/api\/task-statuses/,
|
||||
/\/api\/job-titles/,
|
||||
/\/api\/teams\/\d+\/members/,
|
||||
/\/api\/auth\/user/, // Cache user info for offline access
|
||||
];
|
||||
|
||||
// Resources that should never be cached
|
||||
const NEVER_CACHE_PATTERNS = [
|
||||
/\/api\/auth\/login/,
|
||||
/\/api\/auth\/logout/,
|
||||
/\/api\/notifications/,
|
||||
/\/socket\.io/,
|
||||
/\.hot-update\./,
|
||||
/sw\.js$/,
|
||||
/chrome-extension/,
|
||||
/moz-extension/,
|
||||
];
|
||||
|
||||
// Install event - Cache static resources
|
||||
self.addEventListener('install', event => {
|
||||
console.log('Service Worker: Installing...');
|
||||
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
try {
|
||||
const cache = await caches.open(CACHE_NAMES.STATIC);
|
||||
await cache.addAll(STATIC_CACHE_URLS);
|
||||
console.log('Service Worker: Static resources cached');
|
||||
|
||||
// Skip waiting to activate immediately
|
||||
await self.skipWaiting();
|
||||
} catch (error) {
|
||||
console.error('Service Worker: Installation failed', error);
|
||||
}
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
// Activate event - Clean up old caches
|
||||
self.addEventListener('activate', event => {
|
||||
console.log('Service Worker: Activating...');
|
||||
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
try {
|
||||
// Clean up old caches
|
||||
const cacheNames = await caches.keys();
|
||||
const oldCaches = cacheNames.filter(name =>
|
||||
Object.values(CACHE_NAMES).every(currentCache => currentCache !== name)
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
oldCaches.map(cacheName => caches.delete(cacheName))
|
||||
);
|
||||
|
||||
console.log('Service Worker: Old caches cleaned up');
|
||||
|
||||
// Take control of all pages
|
||||
await self.clients.claim();
|
||||
} catch (error) {
|
||||
console.error('Service Worker: Activation failed', error);
|
||||
}
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch event - Handle all network requests
|
||||
self.addEventListener('fetch', event => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Skip non-GET requests and browser extensions
|
||||
if (request.method !== 'GET' || NEVER_CACHE_PATTERNS.some(pattern => pattern.test(url.href))) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(handleFetchRequest(request));
|
||||
});
|
||||
|
||||
// Main fetch handler with different strategies based on resource type
|
||||
async function handleFetchRequest(request) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
try {
|
||||
// Static assets - Cache First strategy
|
||||
if (isStaticAsset(url)) {
|
||||
return await cacheFirstStrategy(request, CACHE_NAMES.STATIC);
|
||||
}
|
||||
|
||||
// Images - Cache First with long-term storage
|
||||
if (isImageRequest(url)) {
|
||||
return await cacheFirstStrategy(request, CACHE_NAMES.IMAGES);
|
||||
}
|
||||
|
||||
// API requests - Network First with fallback
|
||||
if (isAPIRequest(url)) {
|
||||
return await networkFirstStrategy(request, CACHE_NAMES.API);
|
||||
}
|
||||
|
||||
// HTML pages - Stale While Revalidate
|
||||
if (isHTMLRequest(request)) {
|
||||
return await staleWhileRevalidateStrategy(request, CACHE_NAMES.DYNAMIC);
|
||||
}
|
||||
|
||||
// Everything else - Network First
|
||||
return await networkFirstStrategy(request, CACHE_NAMES.DYNAMIC);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Service Worker: Fetch failed', error);
|
||||
return createOfflineResponse(request);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache First Strategy - Try cache first, fallback to network
|
||||
async function cacheFirstStrategy(request, cacheName) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const cachedResponse = await cache.match(request);
|
||||
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
try {
|
||||
const networkResponse = await fetch(request);
|
||||
if (networkResponse.status === 200) {
|
||||
// Clone before caching as response can only be used once
|
||||
const responseClone = networkResponse.clone();
|
||||
await cache.put(request, responseClone);
|
||||
}
|
||||
return networkResponse;
|
||||
} catch (error) {
|
||||
console.error('Cache First: Network failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Network First Strategy - Try network first, fallback to cache
|
||||
async function networkFirstStrategy(request, cacheName) {
|
||||
const cache = await caches.open(cacheName);
|
||||
|
||||
try {
|
||||
const networkResponse = await fetch(request);
|
||||
|
||||
if (networkResponse.status === 200) {
|
||||
// Cache successful responses
|
||||
const responseClone = networkResponse.clone();
|
||||
await cache.put(request, responseClone);
|
||||
}
|
||||
|
||||
return networkResponse;
|
||||
} catch (error) {
|
||||
console.warn('Network First: Network failed, trying cache', error);
|
||||
const cachedResponse = await cache.match(request);
|
||||
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Stale While Revalidate Strategy - Return cached version while updating in background
|
||||
async function staleWhileRevalidateStrategy(request, cacheName) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const cachedResponse = await cache.match(request);
|
||||
|
||||
// Fetch from network in background
|
||||
const networkResponsePromise = fetch(request).then(async networkResponse => {
|
||||
if (networkResponse.status === 200) {
|
||||
const responseClone = networkResponse.clone();
|
||||
await cache.put(request, responseClone);
|
||||
}
|
||||
return networkResponse;
|
||||
}).catch(error => {
|
||||
console.warn('Stale While Revalidate: Background update failed', error);
|
||||
});
|
||||
|
||||
// Return cached version immediately if available
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
// If no cached version, wait for network
|
||||
return await networkResponsePromise;
|
||||
}
|
||||
|
||||
// Helper functions to identify resource types
|
||||
function isStaticAsset(url) {
|
||||
return /\.(js|css|woff2?|ttf|eot)$/.test(url.pathname) ||
|
||||
url.pathname.includes('/assets/') ||
|
||||
url.pathname === '/' ||
|
||||
url.pathname === '/index.html' ||
|
||||
url.pathname === '/favicon.ico' ||
|
||||
url.pathname === '/env-config.js';
|
||||
}
|
||||
|
||||
function isImageRequest(url) {
|
||||
return /\.(png|jpg|jpeg|gif|svg|webp|ico)$/.test(url.pathname) ||
|
||||
url.pathname.includes('/file-types/');
|
||||
}
|
||||
|
||||
function isAPIRequest(url) {
|
||||
return url.pathname.startsWith('/api/') ||
|
||||
CACHEABLE_API_PATTERNS.some(pattern => pattern.test(url.pathname));
|
||||
}
|
||||
|
||||
function isHTMLRequest(request) {
|
||||
return request.headers.get('accept')?.includes('text/html');
|
||||
}
|
||||
|
||||
// Create offline fallback response
|
||||
function createOfflineResponse(request) {
|
||||
if (isImageRequest(new URL(request.url))) {
|
||||
// Return a simple SVG placeholder for images
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="200" height="150" viewBox="0 0 200 150">
|
||||
<rect width="200" height="150" fill="#f0f0f0"/>
|
||||
<text x="100" y="75" text-anchor="middle" fill="#999" font-family="Arial, sans-serif" font-size="14">
|
||||
Offline
|
||||
</text>
|
||||
</svg>`;
|
||||
|
||||
return new Response(svg, {
|
||||
headers: { 'Content-Type': 'image/svg+xml' }
|
||||
});
|
||||
}
|
||||
|
||||
if (isAPIRequest(new URL(request.url))) {
|
||||
// Return empty array or error for API requests
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Offline',
|
||||
message: 'This feature requires an internet connection'
|
||||
}), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// For HTML requests, try to return cached index.html
|
||||
return caches.match('/') || new Response('Offline', { status: 503 });
|
||||
}
|
||||
|
||||
// Handle background sync events (for future implementation)
|
||||
self.addEventListener('sync', event => {
|
||||
console.log('Service Worker: Background sync', event.tag);
|
||||
|
||||
if (event.tag === 'background-sync') {
|
||||
event.waitUntil(handleBackgroundSync());
|
||||
}
|
||||
});
|
||||
|
||||
async function handleBackgroundSync() {
|
||||
// This is where you would handle queued actions when coming back online
|
||||
console.log('Service Worker: Handling background sync');
|
||||
|
||||
// Example: Send queued task updates, sync offline changes, etc.
|
||||
// Implementation would depend on your app's specific needs
|
||||
}
|
||||
|
||||
// Handle push notification events (for future implementation)
|
||||
self.addEventListener('push', event => {
|
||||
if (!event.data) return;
|
||||
|
||||
const options = {
|
||||
body: event.data.text(),
|
||||
icon: '/favicon.ico',
|
||||
badge: '/favicon.ico',
|
||||
vibrate: [200, 100, 200],
|
||||
data: {
|
||||
dateOfArrival: Date.now(),
|
||||
primaryKey: 1
|
||||
}
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification('Worklenz', options)
|
||||
);
|
||||
});
|
||||
|
||||
// Handle notification click events
|
||||
self.addEventListener('notificationclick', event => {
|
||||
event.notification.close();
|
||||
|
||||
event.waitUntil(
|
||||
self.clients.openWindow('/')
|
||||
);
|
||||
});
|
||||
|
||||
// Message handling for communication with main thread
|
||||
self.addEventListener('message', event => {
|
||||
const { type, payload } = event.data;
|
||||
|
||||
switch (type) {
|
||||
case 'SKIP_WAITING':
|
||||
self.skipWaiting();
|
||||
break;
|
||||
|
||||
case 'GET_VERSION':
|
||||
event.ports[0].postMessage({ version: CACHE_VERSION });
|
||||
break;
|
||||
|
||||
case 'CLEAR_CACHE':
|
||||
clearAllCaches().then(() => {
|
||||
event.ports[0].postMessage({ success: true });
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('Service Worker: Unknown message type', type);
|
||||
}
|
||||
});
|
||||
|
||||
async function clearAllCaches() {
|
||||
const cacheNames = await caches.keys();
|
||||
await Promise.all(cacheNames.map(name => caches.delete(name)));
|
||||
console.log('Service Worker: All caches cleared');
|
||||
}
|
||||
|
||||
console.log('Service Worker: Loaded successfully');
|
||||
Reference in New Issue
Block a user