refactor: Simplify service worker implementation

Remove over-engineering from asset caching service worker:
- Remove retry logic, stats tracking, unused message handlers
- Inline helper functions used only once
- Reduce sw-runtime.js from 314 to 170 lines (46% reduction)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Pranav
2026-01-10 15:02:34 -08:00
co-authored by Claude Opus 4.5
parent d34df7c314
commit f713678328
6 changed files with 250 additions and 169 deletions
+17 -4
View File
@@ -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 => {
+8 -29
View File
@@ -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())
);
});
+168 -74
View File
@@ -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' });
}
});
+1 -5
View File
@@ -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",
-51
View File
@@ -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
+56 -6
View File
@@ -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);