- Add self-hosted push notifications using VAPID (no external services) - Create PushSubscription model in database - Implement server-side push logic with web-push library - Add notification helpers for list events (items, invitations, collaborators) - Create NotificationSettings UI component for profile page - Add usePushNotifications React hook for subscription management - Create API routes for subscription management and VAPID key - Update service worker with push event handlers - Integrate notifications into item actions (add, remove, complete) - Integrate notifications into sharing actions (invite, accept, remove) - Add VAPID key generation script - Add comprehensive documentation and setup guides Notifications are sent when: - Items are added/removed/completed in shared lists - Users are invited to lists - Invitations are accepted - Collaborators are removed from lists Works on iOS 16.4+ (when installed to home screen), Android, and desktop browsers.
333 lines
8.8 KiB
JavaScript
333 lines
8.8 KiB
JavaScript
// Service Worker for Ultimate Lists App
|
|
// Implements caching strategies for offline functionality
|
|
|
|
const CACHE_NAME = 'ultimate-lists-v5';
|
|
const RUNTIME_CACHE = 'ultimate-lists-runtime-v5';
|
|
|
|
// Static assets to cache on install
|
|
const STATIC_ASSETS = [
|
|
'/',
|
|
'/offline',
|
|
'/auth/login',
|
|
'/auth/register',
|
|
'/auth/error',
|
|
'/manifest.json',
|
|
'/icon-192.svg',
|
|
'/icon-512.svg',
|
|
];
|
|
|
|
// Install event - cache static assets
|
|
self.addEventListener('install', (event) => {
|
|
event.waitUntil(
|
|
caches.open(CACHE_NAME).then((cache) => {
|
|
return cache.addAll(STATIC_ASSETS).catch((error) => {
|
|
console.error('Failed to cache static assets:', error);
|
|
// Continue even if some assets fail to cache
|
|
return Promise.resolve();
|
|
});
|
|
})
|
|
);
|
|
// Activate immediately
|
|
self.skipWaiting();
|
|
});
|
|
|
|
// Activate event - clean up old caches
|
|
self.addEventListener('activate', (event) => {
|
|
event.waitUntil(
|
|
caches.keys().then((cacheNames) => {
|
|
return Promise.all(
|
|
cacheNames
|
|
.filter((name) => name !== CACHE_NAME && name !== RUNTIME_CACHE)
|
|
.map((name) => caches.delete(name))
|
|
);
|
|
})
|
|
);
|
|
// Take control of all pages immediately
|
|
return self.clients.claim();
|
|
});
|
|
|
|
// Fetch event - implement caching strategies
|
|
self.addEventListener('fetch', (event) => {
|
|
const { request } = event;
|
|
const url = new URL(request.url);
|
|
|
|
// Skip non-GET requests
|
|
if (request.method !== 'GET') {
|
|
return;
|
|
}
|
|
|
|
// Skip chrome extensions and other protocols
|
|
if (!url.protocol.startsWith('http')) {
|
|
return;
|
|
}
|
|
|
|
// Skip cross-origin requests for performance
|
|
if (url.origin !== self.location.origin) {
|
|
return;
|
|
}
|
|
|
|
// Handle authentication-related requests
|
|
if (isAuthRequest(url)) {
|
|
event.respondWith(handleAuthRequest(request));
|
|
return;
|
|
}
|
|
|
|
// Cache-first strategy for static assets
|
|
if (isStaticAsset(url)) {
|
|
event.respondWith(cacheFirst(request));
|
|
return;
|
|
}
|
|
|
|
// Stale-while-revalidate for API responses and pages
|
|
if (isApiRequest(url) || isPageRequest(url)) {
|
|
event.respondWith(staleWhileRevalidate(request));
|
|
return;
|
|
}
|
|
|
|
// Network-first for everything else
|
|
event.respondWith(networkFirst(request));
|
|
});
|
|
|
|
// Helper: Check if request is for a static asset
|
|
function isStaticAsset(url) {
|
|
const staticExtensions = ['.js', '.css', '.png', '.jpg', '.jpeg', '.svg', '.ico', '.woff', '.woff2'];
|
|
return staticExtensions.some((ext) => url.pathname.endsWith(ext));
|
|
}
|
|
|
|
// Helper: Check if request is an API request
|
|
function isApiRequest(url) {
|
|
return url.pathname.startsWith('/api/');
|
|
}
|
|
|
|
// Helper: Check if request is a page request
|
|
function isPageRequest(url) {
|
|
return url.origin === self.location.origin && !url.pathname.includes('.');
|
|
}
|
|
|
|
// Helper: Check if request is authentication-related
|
|
function isAuthRequest(url) {
|
|
return url.pathname.startsWith('/auth/') ||
|
|
url.pathname.startsWith('/api/auth/');
|
|
}
|
|
|
|
// Strategy: Handle authentication requests
|
|
async function handleAuthRequest(request) {
|
|
const url = new URL(request.url);
|
|
|
|
// For authentication API calls, always try network first
|
|
if (url.pathname.startsWith('/api/auth/')) {
|
|
try {
|
|
const response = await fetch(request);
|
|
return response;
|
|
} catch (error) {
|
|
console.error('Authentication API request failed (offline):', error);
|
|
// Return a JSON error response for API calls
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: 'Authentication requires an internet connection. Please check your connection and try again.'
|
|
}),
|
|
{
|
|
status: 503,
|
|
statusText: 'Service Unavailable',
|
|
headers: { 'Content-Type': 'application/json' }
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
// For authentication pages, use cache-first with network fallback
|
|
const cache = await caches.open(CACHE_NAME);
|
|
const cached = await cache.match(request);
|
|
|
|
try {
|
|
const response = await fetch(request);
|
|
if (response.ok) {
|
|
cache.put(request, response.clone());
|
|
}
|
|
return response;
|
|
} catch (error) {
|
|
console.error('Authentication page request failed:', error);
|
|
// Return cached version if available
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
// Otherwise redirect to offline page
|
|
return caches.match('/offline');
|
|
}
|
|
}
|
|
|
|
// Strategy: Cache-first (for static assets)
|
|
async function cacheFirst(request) {
|
|
const cache = await caches.open(CACHE_NAME);
|
|
const cached = await cache.match(request);
|
|
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(request);
|
|
if (response.ok) {
|
|
cache.put(request, response.clone());
|
|
}
|
|
return response;
|
|
} catch (error) {
|
|
console.error('Cache-first fetch failed:', error);
|
|
return new Response('Network error', { status: 408 });
|
|
}
|
|
}
|
|
|
|
// Strategy: Stale-while-revalidate (for API and pages)
|
|
async function staleWhileRevalidate(request) {
|
|
const cache = await caches.open(RUNTIME_CACHE);
|
|
const cached = await cache.match(request);
|
|
|
|
const fetchPromise = fetch(request).then((response) => {
|
|
// Handle authentication errors (401, 403)
|
|
if (response.status === 401 || response.status === 403) {
|
|
// Don't cache authentication errors
|
|
return response;
|
|
}
|
|
|
|
if (response.ok) {
|
|
cache.put(request, response.clone());
|
|
}
|
|
return response;
|
|
}).catch((error) => {
|
|
console.error('Stale-while-revalidate fetch failed:', error);
|
|
// If we have a cached version, return it on network error
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
// Otherwise, return offline page for navigation requests
|
|
if (request.mode === 'navigate') {
|
|
return caches.match('/offline');
|
|
}
|
|
throw error;
|
|
});
|
|
|
|
return cached || fetchPromise;
|
|
}
|
|
|
|
// Strategy: Network-first (fallback)
|
|
async function networkFirst(request) {
|
|
try {
|
|
const response = await fetch(request);
|
|
|
|
// Handle authentication errors (401, 403)
|
|
if (response.status === 401 || response.status === 403) {
|
|
// Don't cache authentication errors
|
|
return response;
|
|
}
|
|
|
|
if (response.ok) {
|
|
const cache = await caches.open(RUNTIME_CACHE);
|
|
cache.put(request, response.clone());
|
|
}
|
|
return response;
|
|
} catch (error) {
|
|
const cache = await caches.open(RUNTIME_CACHE);
|
|
const cached = await cache.match(request);
|
|
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
// Return offline page for navigation requests
|
|
if (request.mode === 'navigate') {
|
|
return caches.match('/offline');
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Background Sync - sync pending mutations when connection is restored
|
|
self.addEventListener('sync', (event) => {
|
|
if (event.tag === 'sync-mutations') {
|
|
event.waitUntil(syncMutations());
|
|
}
|
|
});
|
|
|
|
// Sync mutations from the queue
|
|
async function syncMutations() {
|
|
try {
|
|
// Notify all clients to trigger sync
|
|
const clients = await self.clients.matchAll();
|
|
clients.forEach((client) => {
|
|
client.postMessage({
|
|
type: 'SYNC_MUTATIONS',
|
|
});
|
|
});
|
|
} catch (error) {
|
|
console.error('Background sync failed:', error);
|
|
throw error; // Re-throw to retry later
|
|
}
|
|
}
|
|
|
|
// Listen for messages from clients
|
|
self.addEventListener('message', (event) => {
|
|
if (event.data && event.data.type === 'SKIP_WAITING') {
|
|
self.skipWaiting();
|
|
}
|
|
|
|
if (event.data && event.data.type === 'REGISTER_SYNC') {
|
|
// Register background sync
|
|
if (self.registration.sync) {
|
|
self.registration.sync.register('sync-mutations').catch((error) => {
|
|
console.error('Failed to register background sync:', error);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
// Push notification event - receive and display notifications
|
|
self.addEventListener('push', (event) => {
|
|
if (!event.data) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const data = event.data.json();
|
|
const { title, body, icon, badge, tag, data: notificationData } = data;
|
|
|
|
const options = {
|
|
body,
|
|
icon: icon || '/icon-192.svg',
|
|
badge: badge || '/icon-192.svg',
|
|
tag: tag || 'default',
|
|
data: notificationData,
|
|
vibrate: [200, 100, 200],
|
|
requireInteraction: false,
|
|
};
|
|
|
|
event.waitUntil(
|
|
self.registration.showNotification(title, options)
|
|
);
|
|
} catch (error) {
|
|
console.error('Failed to show notification:', error);
|
|
}
|
|
});
|
|
|
|
// Notification click event - handle user interaction
|
|
self.addEventListener('notificationclick', (event) => {
|
|
event.notification.close();
|
|
|
|
const urlToOpen = event.notification.data?.url || '/';
|
|
|
|
event.waitUntil(
|
|
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
|
// Check if there's already a window open
|
|
for (const client of clientList) {
|
|
if (client.url === urlToOpen && 'focus' in client) {
|
|
return client.focus();
|
|
}
|
|
}
|
|
// Open a new window if none exists
|
|
if (clients.openWindow) {
|
|
return clients.openWindow(urlToOpen);
|
|
}
|
|
})
|
|
);
|
|
});
|