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 <noreply@anthropic.com>
This commit is contained in:
Pranav
2026-01-10 14:48:23 -08:00
co-authored by Claude Opus 4.5
parent 8b230c6920
commit d34df7c314
9 changed files with 386 additions and 2 deletions
+1
View File
@@ -101,3 +101,4 @@ CLAUDE.local.md
.histoire
.pnpm-store/*
local/
sw.js
+22 -1
View File
@@ -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);
@@ -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();
@@ -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())
);
});
@@ -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
}),
],
})
);
+2
View File
@@ -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
+6 -1
View File
@@ -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",
+51
View File
@@ -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
+154
View File
@@ -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);
});