refactor: Colocate service worker build script with source files

Move build script from scripts/build-service-worker.js to
app/javascript/service-worker/build.js to keep all service worker
related code in one place.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Pranav
2026-01-10 19:30:15 -08:00
co-authored by Claude Opus 4.5
parent 38232a73dc
commit adbee23e12
5 changed files with 22 additions and 74 deletions
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env node
/* eslint-disable no-console, no-restricted-syntax, no-continue */
const fs = require('fs');
const path = require('path');
const { build } = require('vite');
/**
* Generate asset manifest from Vite build output
* Returns array of { url, revision } for JS/CSS files
*/
function generateAssetManifest() {
// vite-plugin-ruby outputs to 'vite' in production, 'vite-dev' in development
const manifestPaths = [
path.resolve(__dirname, '../../../public/vite/.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
*/
async function buildServiceWorker() {
console.log('🔨 Building service worker...');
const assetOrigin = process.env.ASSET_CDN_HOST || '';
const isProduction = process.env.NODE_ENV === 'production';
// vite-plugin-ruby serves from /vite/ in production, /vite-dev/ in development
const assetPath = isProduction ? '/vite/' : '/vite-dev/';
console.log(`🌐 Asset origin: ${assetOrigin || '(local)'}`);
console.log(`📁 Asset path: ${assetPath}`);
console.log(`🏭 Environment: ${isProduction ? 'production' : 'development'}`);
// In development mode, just use push handlers (no caching)
if (!isProduction) {
console.log(
'⚠️ Development mode: Using push notifications only (no caching)'
);
const devServiceWorker = fs.readFileSync(
path.resolve(__dirname, 'push-handlers.js'),
'utf8'
);
fs.writeFileSync(
path.resolve(__dirname, '../../../public/sw.js'),
devServiceWorker
);
console.log('✅ Development service worker created at public/sw.js');
return;
}
// Generate asset manifest for precaching
const assetManifest = generateAssetManifest();
// Production mode: Build with custom runtime
try {
console.log('📦 Building service worker runtime...');
// Build the runtime bundle
await build({
configFile: false,
css: {
postcss: {
plugins: [],
},
},
build: {
lib: {
entry: path.resolve(__dirname, 'sw-runtime.js'),
formats: ['iife'],
name: 'ServiceWorkerRuntime',
fileName: () => 'sw-runtime.js',
},
outDir: path.resolve(__dirname, '../../../tmp/sw-build'),
emptyOutDir: true,
minify: true,
rollupOptions: {
output: {
inlineDynamicImports: true,
},
},
},
define: {
__ASSET_ORIGIN__: JSON.stringify(assetOrigin),
__ASSET_PATH__: JSON.stringify(assetPath),
__ASSET_MANIFEST__: JSON.stringify(assetManifest),
'process.env.NODE_ENV': JSON.stringify('production'),
},
logLevel: 'warn',
});
console.log('✅ Service worker runtime built');
// Read the built runtime
const runtimeBundle = fs.readFileSync(
path.resolve(__dirname, '../../../tmp/sw-build/sw-runtime.js'),
'utf8'
);
// Read the push handlers
const pushHandlers = fs.readFileSync(
path.resolve(__dirname, 'push-handlers.js'),
'utf8'
);
// Combine them
const finalServiceWorker = `/* Service Worker for Chatwoot - Generated at build time */
/* Generated: ${new Date().toISOString()} */
/* Precached assets: ${assetManifest.length} */
${runtimeBundle}
${pushHandlers}
`;
// Write to public/sw.js
fs.writeFileSync(
path.resolve(__dirname, '../../../public/sw.js'),
finalServiceWorker
);
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);
}
}
// Run the build
buildServiceWorker().catch(err => {
console.error('❌ Unhandled error:', err);
process.exit(1);
});
@@ -1,39 +0,0 @@
/* 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();
event.waitUntil(
self.registration.showNotification(notification.title, {
tag: notification.tag,
data: {
url: notification.url,
},
})
);
});
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);
}
})
);
});
+5 -11
View File
@@ -1,11 +1,7 @@
/* eslint-disable no-restricted-globals */
/* eslint-disable no-restricted-globals, no-console */
/* globals clients */
// Push notification handler
self.addEventListener('push', event => {
const notification = event.data && event.data.json();
if (!notification) return;
let notification = event.data && event.data.json();
event.waitUntil(
self.registration.showNotification(notification.title, {
@@ -17,19 +13,17 @@ self.addEventListener('push', event => {
);
});
// Notification click handler
self.addEventListener('notificationclick', event => {
const notification = event.notification;
notification.close();
let notification = event.notification;
event.waitUntil(
clients.matchAll({ type: 'window' }).then(windowClients => {
const matchingWindowClients = windowClients.filter(
let matchingWindowClients = windowClients.filter(
client => client.url === notification.data.url
);
if (matchingWindowClients.length) {
const firstWindow = matchingWindowClients[0];
let firstWindow = matchingWindowClients[0];
if (firstWindow && 'focus' in firstWindow) {
firstWindow.focus();
return;
+2 -2
View File
@@ -46,7 +46,7 @@ self.addEventListener('fetch', event => {
// Assets (JS/CSS/fonts) → cache-first
const isAsset =
url.pathname.startsWith('/vite-dev/') ||
url.pathname.startsWith('/packs/') ||
url.pathname.startsWith('/vite/') ||
url.origin === ASSET_ORIGIN ||
/\.(js|css|woff2?|ttf|otf|eot)$/i.test(url.pathname);
@@ -183,7 +183,7 @@ async function cleanupStaleAssets() {
.filter(request => {
const url = new URL(request.url);
const isAssetPath =
url.pathname.startsWith('/packs/assets/') ||
url.pathname.startsWith('/vite/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);