Files
chatwoot/app/javascript/service-worker/sw-runtime.js
T
PranavandClaude Opus 4.5 38232a73dc refactor: Use stable cache name with incremental asset updates
Instead of invalidating the entire cache on every deployment (via git hash
in cache name), use a stable cache name and leverage Vite's content hashes
in filenames.

Changes:
- Use fixed cache name 'chatwoot-assets-v1' instead of 'chatwoot-{git-hash}'
- Add cleanupStaleAssets() to remove old assets not in current manifest
- Unchanged files (same content hash) stay cached across deployments
- Only new/changed files are fetched
- Remove unused getCacheVersion() and CACHE_VERSION references

This significantly reduces bandwidth on deployments since most assets
don't change between releases.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 16:28:42 -08:00

202 lines
5.8 KiB
JavaScript

/* eslint-disable no-restricted-globals, no-use-before-define, no-undef */
// Build-time injected values (replaced by Vite define at build time)
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/',
'/rails/',
'/cable',
'/sidekiq',
'/super_admin',
'/swagger',
'/webhooks/',
'/widget',
'/survey/',
'/__vite',
'/sw.js',
];
// =============================================================================
// Fetch Handler
// =============================================================================
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('/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}${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
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([
// Clean up stale assets not in current manifest
cleanupStaleAssets(),
// Take control of all clients
self.clients.claim(),
])
);
});
/**
* 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;
const cache = await caches.open(CACHE_NAME);
const cachedRequests = await cache.keys();
const baseUrl = ASSET_ORIGIN || `${self.location.origin}${ASSET_PATH}`;
// Build set of valid asset URLs from current manifest
const validUrls = new Set(
ASSET_MANIFEST.map(asset => `${baseUrl}${asset.url}`)
);
// 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('/packs/assets/') ||
url.pathname.startsWith('/vite-dev/assets/');
// Only clean up asset files, keep other cached items (like /app shell)
return isAssetPath && !validUrls.has(request.url);
})
.map(request => cache.delete(request));
await Promise.all(deletions);
}
self.addEventListener('message', async event => {
if (event.data?.type === 'PREFETCH_ASSETS') {
await prefetchAssets();
event.ports[0]?.postMessage({ type: 'PREFETCH_COMPLETE' });
}
});