From d34df7c3140a404607a1a3e7fcd7cf26b5aca13e Mon Sep 17 00:00:00 2001 From: Pranav Date: Sat, 10 Jan 2026 14:41:50 -0800 Subject: [PATCH] feat: Add service worker for asset caching - Add Workbox-based service worker to cache JS, CSS, and fonts - JS/CSS cached for 30 days, fonts for 1 year - Support for CDN assets via ASSET_CDN_HOST - Version-based cache invalidation on deployments - Automatic cache cleanup for old versions - Build service worker during asset precompilation Co-Authored-By: Claude Opus 4.5 --- .gitignore | 1 + app/javascript/dashboard/helper/pushHelper.js | 23 ++- .../service-worker/push-handlers-only.js | 2 + .../service-worker/push-handlers.js | 64 ++++++++ app/javascript/service-worker/sw-runtime.js | 84 ++++++++++ lib/tasks/build.rake | 2 + package.json | 7 +- pnpm-lock.yaml | 51 ++++++ scripts/build-service-worker.js | 154 ++++++++++++++++++ 9 files changed, 386 insertions(+), 2 deletions(-) rename public/sw.js => app/javascript/service-worker/push-handlers-only.js (92%) create mode 100644 app/javascript/service-worker/push-handlers.js create mode 100644 app/javascript/service-worker/sw-runtime.js create mode 100644 scripts/build-service-worker.js diff --git a/.gitignore b/.gitignore index bcc83c1ef..8b4fe0971 100644 --- a/.gitignore +++ b/.gitignore @@ -101,3 +101,4 @@ CLAUDE.local.md .histoire .pnpm-store/* local/ +sw.js diff --git a/app/javascript/dashboard/helper/pushHelper.js b/app/javascript/dashboard/helper/pushHelper.js index 44757ffcd..31c2c88a2 100644 --- a/app/javascript/dashboard/helper/pushHelper.js +++ b/app/javascript/dashboard/helper/pushHelper.js @@ -16,7 +16,28 @@ export const verifyServiceWorkerExistence = (callback = () => {}) => { navigator.serviceWorker .register('/sw.js') - .then(registration => callback(registration)) + .then(registration => { + // Check for updates on load + registration.update(); + + // Listen for updates + registration.addEventListener('updatefound', () => { + const newWorker = registration.installing; + newWorker.addEventListener('statechange', () => { + if ( + newWorker.state === 'installed' && + 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' + ); + } + }); + }); + + callback(registration); + }) .catch(registrationError => { // eslint-disable-next-line console.log('SW registration failed: ', registrationError); diff --git a/public/sw.js b/app/javascript/service-worker/push-handlers-only.js similarity index 92% rename from public/sw.js rename to app/javascript/service-worker/push-handlers-only.js index 2ad6cafb0..807203d25 100644 --- a/public/sw.js +++ b/app/javascript/service-worker/push-handlers-only.js @@ -1,5 +1,7 @@ +/* Development-only service worker - Push notifications only, no caching */ /* eslint-disable no-restricted-globals, no-console */ /* globals clients */ + self.addEventListener('push', event => { let notification = event.data && event.data.json(); diff --git a/app/javascript/service-worker/push-handlers.js b/app/javascript/service-worker/push-handlers.js new file mode 100644 index 000000000..777b150e7 --- /dev/null +++ b/app/javascript/service-worker/push-handlers.js @@ -0,0 +1,64 @@ +/* eslint-disable no-restricted-globals, no-console */ +/* globals clients */ + +// Push notification handler +self.addEventListener('push', event => { + let notification = event.data && event.data.json(); + + event.waitUntil( + self.registration.showNotification(notification.title, { + tag: notification.tag, + data: { + url: notification.url, + }, + }) + ); +}); + +// Notification click handler +self.addEventListener('notificationclick', event => { + let notification = event.notification; + + event.waitUntil( + clients.matchAll({ type: 'window' }).then(windowClients => { + let matchingWindowClients = windowClients.filter( + client => client.url === notification.data.url + ); + + if (matchingWindowClients.length) { + let firstWindow = matchingWindowClients[0]; + if (firstWindow && 'focus' in firstWindow) { + firstWindow.focus(); + return; + } + } + if (clients.openWindow) { + clients.openWindow(notification.data.url); + } + }) + ); +}); + +// 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 new file mode 100644 index 000000000..8955feed3 --- /dev/null +++ b/app/javascript/service-worker/sw-runtime.js @@ -0,0 +1,84 @@ +/* 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'; + +// Cache version will be injected at build time +const CACHE_VERSION = '__CACHE_VERSION__'; + +// Asset origin will be injected at build time (CDN host or empty string) +const ASSET_ORIGIN = '__ASSET_ORIGIN__'; + +// 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}`; + +// 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; + + 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 + }), + ], + }) +); + +// 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; + + 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 + }), + ], + }) +); + +// Cache fonts (from anywhere) +registerRoute( + ({ request, url }) => { + const isFont = request.destination === 'font'; + const hasFontExtension = /\.(woff|woff2|ttf|otf|eot)$/i.test(url.pathname); + + 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 + }), + ], + }) +); diff --git a/lib/tasks/build.rake b/lib/tasks/build.rake index 70561c2a7..1d4dacce5 100644 --- a/lib/tasks/build.rake +++ b/lib/tasks/build.rake @@ -5,6 +5,8 @@ task before_assets_precompile: :environment do system('pnpm install') system('echo "-------------- Bulding SDK for Production --------------"') system('pnpm run build:sdk') + system('echo "-------------- Building Service Worker --------------"') + system('NODE_ENV=production pnpm run build:sw') system('echo "-------------- Bulding App for Production --------------"') end diff --git a/package.json b/package.json index 6d5a37b5a..ba308eba1 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dev": "overmind start -f ./Procfile.dev", "ruby:prettier": "bundle exec rubocop -a", "build:sdk": "BUILD_MODE=library vite build", + "build:sw": "node scripts/build-service-worker.js", "prepare": "husky install", "size": "size-limit", "story:dev": "histoire dev", @@ -108,7 +109,11 @@ "vuedraggable": "^4.1.0", "vuex": "~4.1.0", "vuex-router-sync": "6.0.0-rc.1", - "wavesurfer.js": "7.8.6" + "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" }, "devDependencies": { "@egoist/tailwindcss-icons": "^1.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26bd3141e..ec10f1c12 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -247,6 +247,18 @@ 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 @@ -2772,6 +2784,9 @@ 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==} @@ -4742,6 +4757,21 @@ 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'} @@ -7639,6 +7669,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + idb@7.1.1: {} + idb@8.0.0: {} ignore@5.2.4: {} @@ -9791,6 +9823,25 @@ 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 new file mode 100644 index 000000000..498cd7108 --- /dev/null +++ b/scripts/build-service-worker.js @@ -0,0 +1,154 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); +const { build } = require('vite'); + +/** + * Get the cache version for service worker + * Priority: CHATWOOT_VERSION env var > git commit hash > timestamp + */ +function getCacheVersion() { + if (process.env.CHATWOOT_VERSION) { + return process.env.CHATWOOT_VERSION; + } + + try { + const gitHash = execSync('git rev-parse --short HEAD', { + encoding: 'utf8', + }).trim(); + return `git-${gitHash}`; + } catch (error) { + console.warn('Could not get git hash, using timestamp'); + return `v${Date.now()}`; + } +} + +/** + * Build the service worker + */ +async function buildServiceWorker() { + console.log('🔨 Building service worker...'); + + const cacheVersion = getCacheVersion(); + const assetOrigin = process.env.ASSET_CDN_HOST || ''; + const isProduction = process.env.NODE_ENV === 'production'; + + console.log(`📦 Cache version: ${cacheVersion}`); + console.log(`🌐 Asset origin: ${assetOrigin || '(local)'}`); + console.log(`🏭 Environment: ${isProduction ? 'production' : 'development'}`); + + // In development mode, just copy the push-handlers-only version + if (!isProduction) { + console.log( + '⚠️ Development mode: Using push notifications only (no caching)' + ); + + const devServiceWorker = fs.readFileSync( + path.resolve( + __dirname, + '../app/javascript/service-worker/push-handlers-only.js' + ), + 'utf8' + ); + + fs.writeFileSync( + path.resolve(__dirname, '../public/sw.js'), + devServiceWorker + ); + + console.log('✅ Development service worker created at public/sw.js'); + return; + } + + // Production mode: Build with Workbox runtime + try { + console.log('📦 Building Workbox runtime bundle...'); + + // Build the Workbox runtime bundle + await build({ + configFile: false, + css: { + postcss: { + plugins: [], + }, + }, + build: { + lib: { + entry: path.resolve( + __dirname, + '../app/javascript/service-worker/sw-runtime.js' + ), + formats: ['iife'], + name: 'WorkboxRuntime', + fileName: () => 'sw-runtime.js', + }, + outDir: path.resolve(__dirname, '../tmp/sw-build'), + emptyOutDir: true, + minify: true, + rollupOptions: { + output: { + inlineDynamicImports: true, + }, + }, + }, + define: { + __CACHE_VERSION__: JSON.stringify(cacheVersion), + __ASSET_ORIGIN__: JSON.stringify(assetOrigin), + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + logLevel: 'warn', + }); + + console.log('✅ Workbox runtime bundle built'); + + // Read the built runtime + const runtimeBundle = fs.readFileSync( + path.resolve(__dirname, '../tmp/sw-build/sw-runtime.js'), + 'utf8' + ); + + // Read the push handlers template + const pushHandlers = fs.readFileSync( + path.resolve( + __dirname, + '../app/javascript/service-worker/push-handlers.js' + ), + 'utf8' + ); + + // Replace version placeholder in push handlers + const processedHandlers = pushHandlers.replace( + /__CACHE_VERSION__/g, + cacheVersion + ); + + // Combine them + const finalServiceWorker = `/* Service Worker for Chatwoot - Generated at build time */ +/* Cache version: ${cacheVersion} */ +/* Generated: ${new Date().toISOString()} */ + +${runtimeBundle} + +${processedHandlers} +`; + + // Write to public/sw.js + fs.writeFileSync( + path.resolve(__dirname, '../public/sw.js'), + finalServiceWorker + ); + + console.log('✅ Service worker built successfully at public/sw.js'); + } catch (error) { + console.error('❌ Failed to build service worker:', error); + process.exit(1); + } +} + +// Run the build +buildServiceWorker().catch(err => { + console.error('❌ Unhandled error:', err); + process.exit(1); +});