diff --git a/app/javascript/service-worker/build.js b/app/javascript/service-worker/build.js index 6c9efb749..cc62a7892 100644 --- a/app/javascript/service-worker/build.js +++ b/app/javascript/service-worker/build.js @@ -1,22 +1,32 @@ #!/usr/bin/env node -/* eslint-disable no-console, no-restricted-syntax, no-continue */ +/* eslint-disable no-console */ const fs = require('fs'); const path = require('path'); const { build } = require('vite'); -/** - * Generate asset manifest from Vite build output - * Returns array of { url, revision } for JS/CSS files - */ -function generateAssetManifest() { - // vite-plugin-ruby outputs to 'vite' in production, 'vite-dev' in development - const manifestPaths = [ - path.resolve(__dirname, '../../../public/vite/.vite/manifest.json'), - path.resolve(__dirname, '../../../public/vite-dev/.vite/manifest.json'), - ]; +const ROOT_DIR = process.cwd(); +const PATHS = { + manifest: path.join(ROOT_DIR, 'public/vite/.vite/manifest.json'), + swRuntime: path.join(__dirname, 'sw-runtime.js'), + swRuntimeBuilt: path.join(ROOT_DIR, 'tmp/sw-build/sw-runtime.js'), + pushHandlers: path.join(__dirname, 'push-handlers.js'), + buildDir: path.join(ROOT_DIR, 'tmp/sw-build'), + output: path.join(ROOT_DIR, 'public/sw.js'), +}; - let manifestPath = manifestPaths.find(p => fs.existsSync(p)); +const buildSWContent = (runtimeBundle, pushHandlers, assetCount) => ` +/* Service Worker for Chatwoot */ +/* Generated: ${new Date().toISOString()} */ +/* Precached assets: ${assetCount} */ + +${runtimeBundle} + +${pushHandlers} +`; + +function generateAssetManifest() { + const manifestPath = fs.existsSync(PATHS.manifest) ? PATHS.manifest : null; if (!manifestPath) { console.warn( @@ -31,8 +41,7 @@ function generateAssetManifest() { const assets = []; const seen = new Set(); - for (const [, entry] of Object.entries(manifest)) { - // Add the main file (JS or CSS) + Object.entries(manifest).forEach(([, entry]) => { const file = entry.file; if (file && !seen.has(file)) { seen.add(file); @@ -41,78 +50,62 @@ function generateAssetManifest() { } } - // Add CSS files from the css array (CSS imported by JS modules) if (entry.css) { - for (const cssFile of entry.css) { + entry.css.forEach(cssFile => { if (!seen.has(cssFile)) { seen.add(cssFile); assets.push({ url: cssFile, revision: null }); } - } + }); } - // Add assets (fonts, images, etc.) if (entry.assets) { - for (const assetFile of entry.assets) { + entry.assets.forEach(assetFile => { if (!seen.has(assetFile)) { seen.add(assetFile); assets.push({ url: assetFile, revision: null }); } - } + }); } - } + }); console.log(`📦 Found ${assets.length} assets to precache`); return assets; } -/** - * Build the service worker - */ async function buildServiceWorker() { console.log('🔨 Building service worker...'); - // Ensure CDN host has protocol prefix for absolute URLs let assetOrigin = process.env.ASSET_CDN_HOST || ''; - if (assetOrigin && !assetOrigin.startsWith('http')) { + if (assetOrigin) { assetOrigin = `https://${assetOrigin}`; } - const isProduction = process.env.NODE_ENV === 'production'; - // vite-plugin-ruby serves from /vite/ in production, /vite-dev/ in development - const assetPath = isProduction ? '/vite/' : '/vite-dev/'; + const isProduction = process.env.NODE_ENV === 'production'; + const assetPath = '/vite/'; + + console.log(`📁 Root dir: ${ROOT_DIR}`); console.log(`🌐 Asset origin: ${assetOrigin || '(local)'}`); - console.log(`📁 Asset path: ${assetPath}`); console.log(`🏭 Environment: ${isProduction ? 'production' : 'development'}`); - // In development mode, just use push handlers (no caching) if (!isProduction) { console.log( '⚠️ Development mode: Using push notifications only (no caching)' ); - const devServiceWorker = fs.readFileSync( - path.resolve(__dirname, 'push-handlers.js'), - 'utf8' - ); + const devServiceWorker = fs.readFileSync(PATHS.pushHandlers, 'utf8'); - fs.writeFileSync( - path.resolve(__dirname, '../../../public/sw.js'), - devServiceWorker - ); + fs.writeFileSync(PATHS.output, devServiceWorker); console.log('✅ Development service worker created at public/sw.js'); return; } - // Generate asset manifest for precaching const assetManifest = generateAssetManifest(); - // Production mode: Build with custom runtime try { console.log('📦 Building service worker runtime...'); - // Build the runtime bundle await build({ configFile: false, css: { @@ -122,12 +115,12 @@ async function buildServiceWorker() { }, build: { lib: { - entry: path.resolve(__dirname, 'sw-runtime.js'), + entry: PATHS.swRuntime, formats: ['iife'], name: 'ServiceWorkerRuntime', fileName: () => 'sw-runtime.js', }, - outDir: path.resolve(__dirname, '../../../tmp/sw-build'), + outDir: PATHS.buildDir, emptyOutDir: true, minify: true, rollupOptions: { @@ -147,33 +140,17 @@ async function buildServiceWorker() { console.log('✅ Service worker runtime built'); - // Read the built runtime - const runtimeBundle = fs.readFileSync( - path.resolve(__dirname, '../../../tmp/sw-build/sw-runtime.js'), - 'utf8' + const runtimeBundle = fs.readFileSync(PATHS.swRuntimeBuilt, 'utf8'); + + const pushHandlers = fs.readFileSync(PATHS.pushHandlers, 'utf8'); + + const finalServiceWorker = buildSWContent( + runtimeBundle, + pushHandlers, + assetManifest.length ); - // Read the push handlers - const pushHandlers = fs.readFileSync( - path.resolve(__dirname, 'push-handlers.js'), - 'utf8' - ); - - // Combine them - const finalServiceWorker = `/* Service Worker for Chatwoot - Generated at build time */ -/* Generated: ${new Date().toISOString()} */ -/* Precached assets: ${assetManifest.length} */ - -${runtimeBundle} - -${pushHandlers} -`; - - // Write to public/sw.js - fs.writeFileSync( - path.resolve(__dirname, '../../../public/sw.js'), - finalServiceWorker - ); + fs.writeFileSync(PATHS.output, finalServiceWorker); console.log('✅ Service worker built successfully at public/sw.js'); console.log(` - ${assetManifest.length} assets in precache manifest`); @@ -183,7 +160,6 @@ ${pushHandlers} } } -// Run the build buildServiceWorker().catch(err => { console.error('❌ Unhandled error:', err); process.exit(1); diff --git a/app/javascript/service-worker/push-handlers.js b/app/javascript/service-worker/push-handlers.js index 2ad6cafb0..7f473e907 100644 --- a/app/javascript/service-worker/push-handlers.js +++ b/app/javascript/service-worker/push-handlers.js @@ -1,5 +1,6 @@ -/* eslint-disable no-restricted-globals, no-console */ +/* eslint-disable no-restricted-globals */ /* globals clients */ + self.addEventListener('push', event => { let notification = event.data && event.data.json(); diff --git a/app/javascript/service-worker/sw-runtime.js b/app/javascript/service-worker/sw-runtime.js index 83e9d1ab3..4ac14f8fb 100644 --- a/app/javascript/service-worker/sw-runtime.js +++ b/app/javascript/service-worker/sw-runtime.js @@ -5,11 +5,8 @@ const ASSET_ORIGIN = __ASSET_ORIGIN__; const ASSET_PATH = __ASSET_PATH__; const ASSET_MANIFEST = __ASSET_MANIFEST__; -// Stable cache name - Vite includes content hashes in filenames, -// so unchanged files keep the same URL and don't need refetching const CACHE_NAME = 'chatwoot-assets-v1'; -// Paths that should never be cached (API, auth, real-time, etc.) const EXCLUDED_PATHS = [ '/api/', '/auth/', @@ -25,48 +22,40 @@ const EXCLUDED_PATHS = [ '/sw.js', ]; -// ============================================================================= -// Fetch Handler -// ============================================================================= +const BATCH_SIZE = 5; -self.addEventListener('fetch', event => { - const { request } = event; - const url = new URL(request.url); - - // 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; - - // Skip excluded paths - if (EXCLUDED_PATHS.some(path => url.pathname.startsWith(path))) return; - - // Assets (JS/CSS/fonts) → cache-first - const isAsset = - url.pathname.startsWith('/vite-dev/') || - url.pathname.startsWith('/vite/') || - url.origin === ASSET_ORIGIN || - /\.(js|css|woff2?|ttf|otf|eot)$/i.test(url.pathname); - - if (isAsset) { - event.respondWith(cacheFirst(request)); +const buildAssetListChunks = (arr, size) => { + const result = []; + for (let i = 0; i < arr.length; i += size) { + result.push(arr.slice(i, i + size)); } -}); + return result; +}; -// ============================================================================= -// Caching Strategies -// ============================================================================= +const prefetchBatch = async (cache, baseUrl, assets) => { + const fetchAndCacheAsset = async asset => { + const url = `${baseUrl}${asset.url}`; + if (await cache.match(url)) { + return; + } + try { + const response = await fetch(url); + if (response.ok) { + cache.put(url, response); + } + } catch { + // Ignore prefetch failures + } + }; -async function cacheFirst(request) { + await Promise.all(assets.map(fetchAndCacheAsset)); +}; + +const cacheFirst = async 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) { @@ -74,49 +63,51 @@ async function cacheFirst(request) { } return response; } catch (err) { - // Network failed - return stale cache if available const stale = await cache.match(request); if (stale) return stale; throw err; } -} +}; -// ============================================================================= -// Background Prefetch -// ============================================================================= - -async function prefetchAssets() { +const prefetchAssets = async () => { if (!ASSET_MANIFEST?.length) return; const cache = await caches.open(CACHE_NAME); - // Build base URL: CDN origin + asset path, or local origin + asset path const baseUrl = ASSET_ORIGIN ? `${ASSET_ORIGIN}${ASSET_PATH}` : `${self.location.origin}${ASSET_PATH}`; - // 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 + const batches = buildAssetListChunks(ASSET_MANIFEST, BATCH_SIZE); + await batches.reduce( + (promise, batch) => + promise.then(() => prefetchBatch(cache, baseUrl, batch)), + Promise.resolve() + ); +}; +const cleanupStaleAssets = async () => { + if (!ASSET_MANIFEST?.length) return; - try { - const response = await fetch(url); - if (response.ok) cache.put(url, response); - } catch { - // Ignore prefetch failures - } - }) - ); - } -} + const cache = await caches.open(CACHE_NAME); + const cachedRequests = await cache.keys(); -// ============================================================================= -// Lifecycle Events -// ============================================================================= + const baseUrl = ASSET_ORIGIN + ? `${ASSET_ORIGIN}${ASSET_PATH}` + : `${self.location.origin}${ASSET_PATH}`; + + const validUrls = new Set( + ASSET_MANIFEST.map(asset => `${baseUrl}${asset.url}`) + ); + + const deletions = cachedRequests + .filter(request => { + const url = new URL(request.url); + const isAssetPath = url.pathname.startsWith('/vite/assets/'); + return isAssetPath && !validUrls.has(request.url); + }) + .map(request => cache.delete(request)); + + await Promise.all(deletions); +}; self.addEventListener('install', () => self.skipWaiting()); @@ -126,36 +117,26 @@ self.addEventListener('activate', event => { ); }); -/** - * Remove cached assets that aren't in the current manifest. - * This keeps unchanged files (same content hash) while removing old versions. - */ -async function cleanupStaleAssets() { - if (!ASSET_MANIFEST?.length) return; +self.addEventListener('fetch', event => { + const { request } = event; + const url = new URL(request.url); - const cache = await caches.open(CACHE_NAME); - const cachedRequests = await cache.keys(); - // Build base URL: CDN origin + asset path, or local origin + asset path - const baseUrl = ASSET_ORIGIN - ? `${ASSET_ORIGIN}${ASSET_PATH}` - : `${self.location.origin}${ASSET_PATH}`; + if (EXCLUDED_PATHS.some(path => url.pathname.startsWith(path))) return; - // Build set of valid asset URLs from current manifest - const validUrls = new Set( - ASSET_MANIFEST.map(asset => `${baseUrl}${asset.url}`) - ); + if (request.method !== 'GET') return; - // Delete cached entries that are assets but not in current manifest - const deletions = cachedRequests - .filter(request => { - const url = new URL(request.url); - const isAssetPath = - url.pathname.startsWith('/vite/assets/') || - url.pathname.startsWith('/vite-dev/assets/'); - // Only clean up asset files - return isAssetPath && !validUrls.has(request.url); - }) - .map(request => cache.delete(request)); + const isAChatwootRequest = + url.origin === self.location.origin || + (ASSET_ORIGIN && url.origin === ASSET_ORIGIN); - await Promise.all(deletions); -} + if (!isAChatwootRequest) return; + + const isAnAsset = + url.pathname.startsWith('/vite/') || + url.origin === ASSET_ORIGIN || + /\.(js|css|woff2?|ttf|otf|eot|svg|png)$/i.test(url.pathname); + + if (isAnAsset) { + event.respondWith(cacheFirst(request)); + } +});