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:
co-authored by
Claude Opus 4.5
parent
8b230c6920
commit
d34df7c314
@@ -101,3 +101,4 @@ CLAUDE.local.md
|
||||
.histoire
|
||||
.pnpm-store/*
|
||||
local/
|
||||
sw.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);
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+51
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user