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' });
}
});