diff --git a/app/javascript/dashboard/helper/pushHelper.js b/app/javascript/dashboard/helper/pushHelper.js index 31c2c88a2..d614cfb8d 100644 --- a/app/javascript/dashboard/helper/pushHelper.js +++ b/app/javascript/dashboard/helper/pushHelper.js @@ -1,8 +1,14 @@ -/* eslint-disable no-console */ import NotificationSubscriptions from '../api/notificationSubscription'; import auth from '../api/auth'; import { useAlert } from 'dashboard/composables'; +/** + * Request the service worker to prefetch all assets in the manifest. + */ +const prefetchAssets = () => { + navigator.serviceWorker?.controller?.postMessage({ type: 'PREFETCH_ASSETS' }); +}; + export const verifyServiceWorkerExistence = (callback = () => {}) => { if (!('serviceWorker' in navigator)) { // Service Worker isn't supported on this browser, disable or hide UI. @@ -29,13 +35,20 @@ export const verifyServiceWorkerExistence = (callback = () => {}) => { navigator.serviceWorker.controller ) { // New service worker available, will activate on next page load - console.log( - 'New service worker available, will activate on next page load' - ); + // eslint-disable-next-line no-console + console.log('New service worker available'); } }); }); + // Trigger asset prefetch during idle time + if ('requestIdleCallback' in window) { + window.requestIdleCallback(() => prefetchAssets(), { timeout: 5000 }); + } else { + // Fallback: prefetch after 3 seconds + setTimeout(prefetchAssets, 3000); + } + callback(registration); }) .catch(registrationError => { diff --git a/app/javascript/service-worker/push-handlers.js b/app/javascript/service-worker/push-handlers.js index 777b150e7..5aad04536 100644 --- a/app/javascript/service-worker/push-handlers.js +++ b/app/javascript/service-worker/push-handlers.js @@ -1,9 +1,11 @@ -/* eslint-disable no-restricted-globals, no-console */ +/* eslint-disable no-restricted-globals */ /* globals clients */ // Push notification handler self.addEventListener('push', event => { - let notification = event.data && event.data.json(); + const notification = event.data && event.data.json(); + + if (!notification) return; event.waitUntil( self.registration.showNotification(notification.title, { @@ -17,16 +19,17 @@ self.addEventListener('push', event => { // Notification click handler self.addEventListener('notificationclick', event => { - let notification = event.notification; + const notification = event.notification; + notification.close(); event.waitUntil( clients.matchAll({ type: 'window' }).then(windowClients => { - let matchingWindowClients = windowClients.filter( + const matchingWindowClients = windowClients.filter( client => client.url === notification.data.url ); if (matchingWindowClients.length) { - let firstWindow = matchingWindowClients[0]; + const firstWindow = matchingWindowClients[0]; if (firstWindow && 'focus' in firstWindow) { firstWindow.focus(); return; @@ -38,27 +41,3 @@ self.addEventListener('notificationclick', event => { }) ); }); - -// Cache cleanup on activation -self.addEventListener('activate', event => { - const currentCacheVersion = '__CACHE_VERSION__'; - const cacheWhitelist = [ - `js-cache-${currentCacheVersion}`, - `css-cache-${currentCacheVersion}`, - `font-cache-${currentCacheVersion}`, - ]; - - event.waitUntil( - caches - .keys() - .then(cacheNames => { - cacheNames.forEach(cacheName => { - if (!cacheWhitelist.includes(cacheName)) { - console.log('Deleting old cache:', cacheName); - caches.delete(cacheName); - } - }); - }) - .then(() => self.clients.claim()) - ); -}); diff --git a/app/javascript/service-worker/sw-runtime.js b/app/javascript/service-worker/sw-runtime.js index 8955feed3..78f0c10eb 100644 --- a/app/javascript/service-worker/sw-runtime.js +++ b/app/javascript/service-worker/sw-runtime.js @@ -1,84 +1,178 @@ -/* eslint-disable no-restricted-globals */ -import { registerRoute } from 'workbox-routing'; -import { CacheFirst } from 'workbox-strategies'; -import { CacheableResponsePlugin } from 'workbox-cacheable-response'; -import { ExpirationPlugin } from 'workbox-expiration'; +/* eslint-disable no-restricted-globals, no-use-before-define */ -// Cache version will be injected at build time +// Build-time injected values const CACHE_VERSION = '__CACHE_VERSION__'; - -// Asset origin will be injected at build time (CDN host or empty string) const ASSET_ORIGIN = '__ASSET_ORIGIN__'; +const ASSET_MANIFEST = '__ASSET_MANIFEST__'; -// Define cache names with versioning -const JS_CACHE = `js-cache-${CACHE_VERSION}`; -const CSS_CACHE = `css-cache-${CACHE_VERSION}`; -const FONT_CACHE = `font-cache-${CACHE_VERSION}`; +const CACHE_NAME = `chatwoot-${CACHE_VERSION}`; -// Cache JavaScript bundles from /packs/ or CDN -registerRoute( - ({ request, url }) => { - const isScript = request.destination === 'script'; - const isFromPacks = url.pathname.startsWith('/packs/'); - const isFromCDN = ASSET_ORIGIN && url.origin === ASSET_ORIGIN; +// Paths that should never be cached (API, auth, real-time, etc.) +const EXCLUDED_PATHS = [ + '/api/', + '/auth/', + '/rails/', + '/cable', + '/sidekiq', + '/super_admin', + '/swagger', + '/webhooks/', + '/widget', + '/survey/', + '/__vite', + '/sw.js', +]; - return isScript && (isFromPacks || isFromCDN); - }, - new CacheFirst({ - cacheName: JS_CACHE, - plugins: [ - new CacheableResponsePlugin({ - statuses: [0, 200], // 0 for opaque responses from CDN, 200 for same-origin - }), - new ExpirationPlugin({ - maxEntries: 100, - maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days - }), - ], - }) -); +// ============================================================================= +// Fetch Handler +// ============================================================================= -// Cache CSS files from /packs/ or CDN -registerRoute( - ({ request, url }) => { - const isStyle = request.destination === 'style'; - const isFromPacks = url.pathname.startsWith('/packs/'); - const isFromCDN = ASSET_ORIGIN && url.origin === ASSET_ORIGIN; +self.addEventListener('fetch', event => { + const { request } = event; + const url = new URL(request.url); - return isStyle && (isFromPacks || isFromCDN); - }, - new CacheFirst({ - cacheName: CSS_CACHE, - plugins: [ - new CacheableResponsePlugin({ - statuses: [0, 200], - }), - new ExpirationPlugin({ - maxEntries: 30, - maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days - }), - ], - }) -); + // Only handle GET requests from same origin or CDN + if (request.method !== 'GET') return; + const isOurRequest = + url.origin === self.location.origin || + (ASSET_ORIGIN && url.origin === ASSET_ORIGIN); + if (!isOurRequest) return; -// Cache fonts (from anywhere) -registerRoute( - ({ request, url }) => { - const isFont = request.destination === 'font'; - const hasFontExtension = /\.(woff|woff2|ttf|otf|eot)$/i.test(url.pathname); + // Skip excluded paths + if (EXCLUDED_PATHS.some(path => url.pathname.startsWith(path))) return; - return isFont || hasFontExtension; - }, - new CacheFirst({ - cacheName: FONT_CACHE, - plugins: [ - new CacheableResponsePlugin({ - statuses: [0, 200], - }), - new ExpirationPlugin({ - maxEntries: 30, - maxAgeSeconds: 365 * 24 * 60 * 60, // 1 year - }), - ], - }) -); + // Assets (JS/CSS/fonts) → cache-first + const isAsset = + url.pathname.startsWith('/vite-dev/') || + url.pathname.startsWith('/packs/') || + url.origin === ASSET_ORIGIN || + /\.(js|css|woff2?|ttf|otf|eot)$/i.test(url.pathname); + + if (isAsset) { + event.respondWith(cacheFirst(request)); + return; + } + + // Navigation to /app/* → network-first (cache HTML shell as fallback) + if (request.mode === 'navigate' && url.pathname.startsWith('/app')) { + event.respondWith(networkFirstWithShellCache(request)); + } +}); + +// ============================================================================= +// Caching Strategies +// ============================================================================= + +async function cacheFirst(request) { + const cache = await caches.open(CACHE_NAME); + + // Return cached version if available + const cached = await cache.match(request); + if (cached) return cached; + + // Otherwise fetch and cache + try { + const response = await fetch(request); + if (response.ok) { + cache.put(request, response.clone()); + } + return response; + } catch (err) { + // Network failed - return stale cache if available + const stale = await cache.match(request); + if (stale) return stale; + throw err; + } +} + +async function networkFirstWithShellCache(request) { + const cache = await caches.open(CACHE_NAME); + + try { + const response = await fetch(request); + + // Cache the HTML shell for offline fallback + if (response.ok) { + const html = await response.clone().text(); + if (html.includes('data-sw-cache')) { + cache.put( + '/app', + new Response(html, { + headers: response.headers, + }) + ); + } + } + + return response; + } catch (err) { + // Network failed - return cached shell + const cached = await cache.match('/app'); + if (cached) return cached; + throw err; + } +} + +// ============================================================================= +// Background Prefetch +// ============================================================================= + +async function prefetchAssets() { + if (!ASSET_MANIFEST?.length) return; + + const cache = await caches.open(CACHE_NAME); + const baseUrl = ASSET_ORIGIN || `${self.location.origin}/vite-dev/`; + + // Prefetch in batches of 5 (await in loop is intentional for throttling) + for (let i = 0; i < ASSET_MANIFEST.length; i += 5) { + const batch = ASSET_MANIFEST.slice(i, i + 5); + // eslint-disable-next-line no-await-in-loop + await Promise.all( + batch.map(async asset => { + const url = `${baseUrl}${asset.url}`; + if (await cache.match(url)) return; // Already cached + + try { + const response = await fetch(url); + if (response.ok) cache.put(url, response); + } catch { + // Ignore prefetch failures + } + }) + ); + } +} + +// ============================================================================= +// Lifecycle Events +// ============================================================================= + +self.addEventListener('install', () => self.skipWaiting()); + +self.addEventListener('activate', event => { + event.waitUntil( + Promise.all([ + // Delete old caches + caches + .keys() + .then(names => + Promise.all( + names + .filter( + name => name.startsWith('chatwoot-') && name !== CACHE_NAME + ) + .map(name => caches.delete(name)) + ) + ), + // Take control of all clients + self.clients.claim(), + ]) + ); +}); + +self.addEventListener('message', async event => { + if (event.data?.type === 'PREFETCH_ASSETS') { + await prefetchAssets(); + event.ports[0]?.postMessage({ type: 'PREFETCH_COMPLETE' }); + } +}); diff --git a/package.json b/package.json index ba308eba1..c1ca8cce1 100644 --- a/package.json +++ b/package.json @@ -109,11 +109,7 @@ "vuedraggable": "^4.1.0", "vuex": "~4.1.0", "vuex-router-sync": "6.0.0-rc.1", - "wavesurfer.js": "7.8.6", - "workbox-cacheable-response": "^7.0.0", - "workbox-expiration": "^7.0.0", - "workbox-routing": "^7.0.0", - "workbox-strategies": "^7.0.0" + "wavesurfer.js": "7.8.6" }, "devDependencies": { "@egoist/tailwindcss-icons": "^1.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec10f1c12..26bd3141e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -247,18 +247,6 @@ importers: wavesurfer.js: specifier: 7.8.6 version: 7.8.6 - workbox-cacheable-response: - specifier: ^7.0.0 - version: 7.4.0 - workbox-expiration: - specifier: ^7.0.0 - version: 7.4.0 - workbox-routing: - specifier: ^7.0.0 - version: 7.4.0 - workbox-strategies: - specifier: ^7.0.0 - version: 7.4.0 devDependencies: '@egoist/tailwindcss-icons': specifier: ^1.8.1 @@ -2784,9 +2772,6 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} - idb@7.1.1: - resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} - idb@8.0.0: resolution: {integrity: sha512-l//qvlAKGmQO31Qn7xdzagVPPaHTxXx199MhrAFuVBTPqydcPYBWjkrbv4Y0ktB+GmWOiwHl237UUOrLmQxLvw==} @@ -4757,21 +4742,6 @@ packages: resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} engines: {node: '>=18'} - workbox-cacheable-response@7.4.0: - resolution: {integrity: sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==} - - workbox-core@7.4.0: - resolution: {integrity: sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==} - - workbox-expiration@7.4.0: - resolution: {integrity: sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==} - - workbox-routing@7.4.0: - resolution: {integrity: sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==} - - workbox-strategies@7.4.0: - resolution: {integrity: sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==} - wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -7669,8 +7639,6 @@ snapshots: dependencies: safer-buffer: 2.1.2 - idb@7.1.1: {} - idb@8.0.0: {} ignore@5.2.4: {} @@ -9823,25 +9791,6 @@ snapshots: dependencies: string-width: 7.2.0 - workbox-cacheable-response@7.4.0: - dependencies: - workbox-core: 7.4.0 - - workbox-core@7.4.0: {} - - workbox-expiration@7.4.0: - dependencies: - idb: 7.1.1 - workbox-core: 7.4.0 - - workbox-routing@7.4.0: - dependencies: - workbox-core: 7.4.0 - - workbox-strategies@7.4.0: - dependencies: - workbox-core: 7.4.0 - wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 diff --git a/scripts/build-service-worker.js b/scripts/build-service-worker.js index 498cd7108..7140fdafc 100644 --- a/scripts/build-service-worker.js +++ b/scripts/build-service-worker.js @@ -25,6 +25,50 @@ function getCacheVersion() { } } +/** + * Generate asset manifest from Vite build output + * Returns array of { url, revision } for JS/CSS files + */ +function generateAssetManifest() { + const manifestPaths = [ + path.resolve(__dirname, '../public/packs/.vite/manifest.json'), + path.resolve(__dirname, '../public/vite-dev/.vite/manifest.json'), + ]; + + let manifestPath = manifestPaths.find(p => fs.existsSync(p)); + + if (!manifestPath) { + console.warn( + '⚠️ No Vite manifest found, skipping asset manifest generation' + ); + return []; + } + + console.log(`📋 Reading manifest from: ${manifestPath}`); + + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const assets = []; + const seen = new Set(); + + for (const [, entry] of Object.entries(manifest)) { + const file = entry.file; + if (!file || seen.has(file)) continue; + seen.add(file); + + // Only include JS and CSS files (not images, fonts, etc. - those are cached on demand) + if (file.endsWith('.js') || file.endsWith('.css')) { + assets.push({ + url: file, + // revision is null because hash is in filename + revision: null, + }); + } + } + + console.log(`📦 Found ${assets.length} JS/CSS assets to precache`); + return assets; +} + /** * Build the service worker */ @@ -62,11 +106,14 @@ async function buildServiceWorker() { return; } - // Production mode: Build with Workbox runtime - try { - console.log('📦 Building Workbox runtime bundle...'); + // Generate asset manifest for precaching + const assetManifest = generateAssetManifest(); - // Build the Workbox runtime bundle + // Production mode: Build with custom runtime + try { + console.log('📦 Building service worker runtime...'); + + // Build the runtime bundle await build({ configFile: false, css: { @@ -81,7 +128,7 @@ async function buildServiceWorker() { '../app/javascript/service-worker/sw-runtime.js' ), formats: ['iife'], - name: 'WorkboxRuntime', + name: 'ServiceWorkerRuntime', fileName: () => 'sw-runtime.js', }, outDir: path.resolve(__dirname, '../tmp/sw-build'), @@ -96,12 +143,13 @@ async function buildServiceWorker() { define: { __CACHE_VERSION__: JSON.stringify(cacheVersion), __ASSET_ORIGIN__: JSON.stringify(assetOrigin), + __ASSET_MANIFEST__: JSON.stringify(assetManifest), 'process.env.NODE_ENV': JSON.stringify('production'), }, logLevel: 'warn', }); - console.log('✅ Workbox runtime bundle built'); + console.log('✅ Service worker runtime built'); // Read the built runtime const runtimeBundle = fs.readFileSync( @@ -128,6 +176,7 @@ async function buildServiceWorker() { const finalServiceWorker = `/* Service Worker for Chatwoot - Generated at build time */ /* Cache version: ${cacheVersion} */ /* Generated: ${new Date().toISOString()} */ +/* Precached assets: ${assetManifest.length} */ ${runtimeBundle} @@ -141,6 +190,7 @@ ${processedHandlers} ); console.log('✅ Service worker built successfully at public/sw.js'); + console.log(` - ${assetManifest.length} assets in precache manifest`); } catch (error) { console.error('❌ Failed to build service worker:', error); process.exit(1);