|
@@ -0,0 +1,199 @@
|
|
|
|
|
+// Bambuddy Service Worker
|
|
|
|
|
+const CACHE_NAME = 'bambuddy-v1';
|
|
|
|
|
+const STATIC_CACHE = 'bambuddy-static-v1';
|
|
|
|
|
+
|
|
|
|
|
+// Static assets to cache on install
|
|
|
|
|
+const STATIC_ASSETS = [
|
|
|
|
|
+ '/',
|
|
|
|
|
+ '/manifest.json',
|
|
|
|
|
+ '/img/favicon.png',
|
|
|
|
|
+ '/img/favicon-16x16.png',
|
|
|
|
|
+ '/img/favicon-32x32.png',
|
|
|
|
|
+ '/img/android-chrome-192x192.png',
|
|
|
|
|
+ '/img/android-chrome-512x512.png',
|
|
|
|
|
+ '/img/apple-touch-icon.png',
|
|
|
|
|
+ '/img/bambuddy_logo_dark.png',
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
|
|
+// Install event - cache static assets
|
|
|
|
|
+self.addEventListener('install', (event) => {
|
|
|
|
|
+ console.log('[SW] Installing service worker...');
|
|
|
|
|
+ event.waitUntil(
|
|
|
|
|
+ caches.open(STATIC_CACHE).then((cache) => {
|
|
|
|
|
+ console.log('[SW] Caching static assets');
|
|
|
|
|
+ return cache.addAll(STATIC_ASSETS);
|
|
|
|
|
+ })
|
|
|
|
|
+ );
|
|
|
|
|
+ // Activate immediately
|
|
|
|
|
+ self.skipWaiting();
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// Activate event - clean up old caches
|
|
|
|
|
+self.addEventListener('activate', (event) => {
|
|
|
|
|
+ console.log('[SW] Activating service worker...');
|
|
|
|
|
+ event.waitUntil(
|
|
|
|
|
+ caches.keys().then((cacheNames) => {
|
|
|
|
|
+ return Promise.all(
|
|
|
|
|
+ cacheNames
|
|
|
|
|
+ .filter((name) => name !== CACHE_NAME && name !== STATIC_CACHE)
|
|
|
|
|
+ .map((name) => {
|
|
|
|
|
+ console.log('[SW] Deleting old cache:', name);
|
|
|
|
|
+ return caches.delete(name);
|
|
|
|
|
+ })
|
|
|
|
|
+ );
|
|
|
|
|
+ })
|
|
|
|
|
+ );
|
|
|
|
|
+ // Take control immediately
|
|
|
|
|
+ self.clients.claim();
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// Fetch event - network-first for API, cache-first for static
|
|
|
|
|
+self.addEventListener('fetch', (event) => {
|
|
|
|
|
+ const { request } = event;
|
|
|
|
|
+ const url = new URL(request.url);
|
|
|
|
|
+
|
|
|
|
|
+ // Skip non-GET requests
|
|
|
|
|
+ if (request.method !== 'GET') {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Skip WebSocket connections
|
|
|
|
|
+ if (url.protocol === 'ws:' || url.protocol === 'wss:') {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // API requests - network first, no cache (real-time data is critical)
|
|
|
|
|
+ if (url.pathname.startsWith('/api/')) {
|
|
|
|
|
+ event.respondWith(
|
|
|
|
|
+ fetch(request).catch(() => {
|
|
|
|
|
+ // Return offline response for API failures
|
|
|
|
|
+ return new Response(
|
|
|
|
|
+ JSON.stringify({ error: 'offline', message: 'You are currently offline' }),
|
|
|
|
|
+ {
|
|
|
|
|
+ status: 503,
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
+ }
|
|
|
|
|
+ );
|
|
|
|
|
+ })
|
|
|
|
|
+ );
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Static assets - cache first, then network
|
|
|
|
|
+ if (
|
|
|
|
|
+ url.pathname.startsWith('/img/') ||
|
|
|
|
|
+ url.pathname.startsWith('/icons/') ||
|
|
|
|
|
+ url.pathname.endsWith('.png') ||
|
|
|
|
|
+ url.pathname.endsWith('.jpg') ||
|
|
|
|
|
+ url.pathname.endsWith('.svg') ||
|
|
|
|
|
+ url.pathname.endsWith('.ico')
|
|
|
|
|
+ ) {
|
|
|
|
|
+ event.respondWith(
|
|
|
|
|
+ caches.match(request).then((cached) => {
|
|
|
|
|
+ if (cached) {
|
|
|
|
|
+ return cached;
|
|
|
|
|
+ }
|
|
|
|
|
+ return fetch(request).then((response) => {
|
|
|
|
|
+ // Cache successful responses
|
|
|
|
|
+ if (response.ok) {
|
|
|
|
|
+ const clone = response.clone();
|
|
|
|
|
+ caches.open(STATIC_CACHE).then((cache) => {
|
|
|
|
|
+ cache.put(request, clone);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ return response;
|
|
|
|
|
+ });
|
|
|
|
|
+ })
|
|
|
|
|
+ );
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // JS/CSS assets - stale-while-revalidate
|
|
|
|
|
+ if (
|
|
|
|
|
+ url.pathname.startsWith('/assets/') ||
|
|
|
|
|
+ url.pathname.endsWith('.js') ||
|
|
|
|
|
+ url.pathname.endsWith('.css')
|
|
|
|
|
+ ) {
|
|
|
|
|
+ event.respondWith(
|
|
|
|
|
+ caches.match(request).then((cached) => {
|
|
|
|
|
+ const fetchPromise = fetch(request).then((response) => {
|
|
|
|
|
+ if (response.ok) {
|
|
|
|
|
+ const clone = response.clone();
|
|
|
|
|
+ caches.open(CACHE_NAME).then((cache) => {
|
|
|
|
|
+ cache.put(request, clone);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ return response;
|
|
|
|
|
+ });
|
|
|
|
|
+ return cached || fetchPromise;
|
|
|
|
|
+ })
|
|
|
|
|
+ );
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // HTML pages - network first, fall back to cache
|
|
|
|
|
+ event.respondWith(
|
|
|
|
|
+ fetch(request)
|
|
|
|
|
+ .then((response) => {
|
|
|
|
|
+ if (response.ok) {
|
|
|
|
|
+ const clone = response.clone();
|
|
|
|
|
+ caches.open(CACHE_NAME).then((cache) => {
|
|
|
|
|
+ cache.put(request, clone);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ return response;
|
|
|
|
|
+ })
|
|
|
|
|
+ .catch(() => {
|
|
|
|
|
+ return caches.match(request).then((cached) => {
|
|
|
|
|
+ if (cached) {
|
|
|
|
|
+ return cached;
|
|
|
|
|
+ }
|
|
|
|
|
+ // Return cached index for SPA navigation
|
|
|
|
|
+ return caches.match('/');
|
|
|
|
|
+ });
|
|
|
|
|
+ })
|
|
|
|
|
+ );
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// Handle push notifications (for future use)
|
|
|
|
|
+self.addEventListener('push', (event) => {
|
|
|
|
|
+ if (!event.data) return;
|
|
|
|
|
+
|
|
|
|
|
+ const data = event.data.json();
|
|
|
|
|
+ const options = {
|
|
|
|
|
+ body: data.body || 'New notification from Bambuddy',
|
|
|
|
|
+ icon: '/img/android-chrome-192x192.png',
|
|
|
|
|
+ badge: '/img/favicon-32x32.png',
|
|
|
|
|
+ vibrate: [100, 50, 100],
|
|
|
|
|
+ data: {
|
|
|
|
|
+ url: data.url || '/',
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ event.waitUntil(
|
|
|
|
|
+ self.registration.showNotification(data.title || 'Bambuddy', options)
|
|
|
|
|
+ );
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// Handle notification clicks
|
|
|
|
|
+self.addEventListener('notificationclick', (event) => {
|
|
|
|
|
+ event.notification.close();
|
|
|
|
|
+
|
|
|
|
|
+ const url = event.notification.data?.url || '/';
|
|
|
|
|
+
|
|
|
|
|
+ event.waitUntil(
|
|
|
|
|
+ clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
|
|
|
|
|
+ // Check if there's already a window open
|
|
|
|
|
+ for (const client of windowClients) {
|
|
|
|
|
+ if (client.url.includes(self.location.origin) && 'focus' in client) {
|
|
|
|
|
+ client.navigate(url);
|
|
|
|
|
+ return client.focus();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ // Open a new window if none exists
|
|
|
|
|
+ if (clients.openWindow) {
|
|
|
|
|
+ return clients.openWindow(url);
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ );
|
|
|
|
|
+});
|