Compare commits

...
Author SHA1 Message Date
PranavandGitHub d18be84b45 Merge branch 'develop' into feature/service-worker-caching 2026-03-11 23:48:51 -07:00
PranavandGitHub b023487c3e Merge branch 'develop' into feature/service-worker-caching 2026-03-09 05:09:16 -07:00
PranavandGitHub 40d944b7d7 Merge branch 'develop' into feature/service-worker-caching 2026-01-29 19:57:12 -08:00
Muhsin KelothandGitHub b7a3bb892d Merge branch 'develop' into feature/service-worker-caching 2026-01-12 14:00:43 +04:00
Vinay KeerthiandGitHub 08c415ea0e Merge branch 'develop' into feature/service-worker-caching 2026-01-12 14:13:27 +05:30
Sivin VargheseandGitHub 6d662cf339 Merge branch 'develop' into feature/service-worker-caching 2026-01-12 09:45:24 +05:30
Pranav 9d32e27c98 Add a check with http 2026-01-11 11:31:32 -08:00
Pranav 52625c8138 remove unnecessary comments 2026-01-11 11:28:28 -08:00
Pranav 1b9f496832 remove unnecessary comment 2026-01-11 11:26:40 -08:00
Pranav 3527c07e94 Cleanup the code to make more readable 2026-01-11 11:18:17 -08:00
PranavandClaude Opus 4.5 b345826566 refactor: Simplify prefetch by moving to activate event
Remove message-based prefetch in favor of prefetching on activate:
- Remove PREFETCH_ASSETS message listener from service worker
- Remove prefetchAssets() call and idle callback from pushHelper.js
- Call prefetchAssets() directly in activate event

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 21:14:35 -08:00
PranavandClaude Opus 4.5 6d3d76982a refactor: Remove HTML shell caching for navigation
Remove networkFirstWithShellCache - offline shell caching has limited
value for a real-time chat app that requires network connectivity.

The service worker now only caches static assets (JS, CSS, fonts, images).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 20:58:13 -08:00
PranavandClaude Opus 4.5 59427c4de3 fix: Include assets (fonts, images) from manifest
Add entry.assets array to precache list for fonts and images.
Increases precached assets from 69 to 86.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 20:54:05 -08:00
PranavandClaude Opus 4.5 7787ec1265 fix: Include CSS files from manifest css arrays
The Vite manifest has CSS files in two places:
- entry.file (main CSS files)
- entry.css (CSS imported by JS modules)

The previous code only read entry.file, missing CSS from entry.css.
This increases precached assets from 57 to 69.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 20:51:27 -08:00
PranavandClaude Opus 4.5 8daac4cdd1 fix: Construct CDN asset URLs correctly with protocol and path
- Add https:// protocol to ASSET_CDN_HOST if missing
- Include asset path (/vite/) when constructing CDN URLs
- Fix URL: https://cdn.example.com/vite/assets/file.css

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 20:13:39 -08:00
PranavandClaude Opus 4.5 adbee23e12 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>
2026-01-10 19:30:15 -08:00
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
PranavandClaude Opus 4.5 0d21a711e7 fix: Build service worker after assets to include manifest
Two issues were causing the service worker to not cache assets in production:

1. The prefetch baseUrl was hardcoded to /vite-dev/ which only exists in
   development. In production, assets are served from /packs/. Added
   ASSET_PATH build-time constant to use the correct path per environment.

2. The service worker was built before assets:precompile, so the Vite
   manifest didn't exist yet, resulting in 0 precached assets. Moved
   the SW build to after_assets_precompile so it can read the manifest.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 16:18:00 -08:00
PranavandClaude Opus 4.5 f5d538ba48 fix: Use bare identifiers for Vite define replacement in service worker
Vite's define option replaces identifiers, not string literals.
Changed from '__CACHE_VERSION__' (string) to __CACHE_VERSION__ (identifier)
so Vite can properly inject the values at build time.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 15:38:12 -08:00
PranavandClaude Opus 4.5 f713678328 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>
2026-01-10 15:02:34 -08:00
PranavandClaude Opus 4.5 d34df7c314 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>
2026-01-10 14:48:23 -08:00
7 changed files with 341 additions and 6 deletions
+1
View File
@@ -103,4 +103,5 @@ CLAUDE.local.md
.histoire
.pnpm-store/*
local/
sw.js
Procfile.worktree
+20 -2
View File
@@ -1,4 +1,3 @@
/* eslint-disable no-console */
import NotificationSubscriptions from '../api/notificationSubscription';
import auth from '../api/auth';
import { useAlert } from 'dashboard/composables';
@@ -16,7 +15,26 @@ 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
) {
// eslint-disable-next-line no-console
console.log('New service worker available');
}
});
});
callback(registration);
})
.catch(registrationError => {
// eslint-disable-next-line
console.log('SW registration failed: ', registrationError);
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env node
/* eslint-disable no-console */
const fs = require('fs');
const path = require('path');
const { build } = require('vite');
const ROOT_DIR = process.cwd();
const PATHS = {
manifest: path.join(ROOT_DIR, 'public/vite/.vite/manifest.json'),
swRuntime: path.join(__dirname, 'sw-runtime.js'),
swRuntimeBuilt: path.join(ROOT_DIR, 'tmp/sw-build/sw-runtime.js'),
pushHandlers: path.join(__dirname, 'push-handlers.js'),
buildDir: path.join(ROOT_DIR, 'tmp/sw-build'),
output: path.join(ROOT_DIR, 'public/sw.js'),
};
const buildSWContent = (runtimeBundle, pushHandlers, assetCount) => `
/* Service Worker for Chatwoot */
/* Generated: ${new Date().toISOString()} */
/* Precached assets: ${assetCount} */
${runtimeBundle}
${pushHandlers}
`;
function generateAssetManifest() {
const manifestPath = fs.existsSync(PATHS.manifest) ? PATHS.manifest : null;
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();
Object.entries(manifest).forEach(([, entry]) => {
const file = entry.file;
if (file && !seen.has(file)) {
seen.add(file);
if (file.endsWith('.js') || file.endsWith('.css')) {
assets.push({ url: file, revision: null });
}
}
if (entry.css) {
entry.css.forEach(cssFile => {
if (!seen.has(cssFile)) {
seen.add(cssFile);
assets.push({ url: cssFile, revision: null });
}
});
}
if (entry.assets) {
entry.assets.forEach(assetFile => {
if (!seen.has(assetFile)) {
seen.add(assetFile);
assets.push({ url: assetFile, revision: null });
}
});
}
});
console.log(`📦 Found ${assets.length} assets to precache`);
return assets;
}
async function buildServiceWorker() {
console.log('🔨 Building service worker...');
let assetOrigin = process.env.ASSET_CDN_HOST || '';
if (assetOrigin && !assetOrigin.startsWith('http')) {
assetOrigin = `https://${assetOrigin}`;
}
const isProduction = process.env.NODE_ENV === 'production';
const assetPath = '/vite/';
console.log(`📁 Root dir: ${ROOT_DIR}`);
console.log(`🌐 Asset origin: ${assetOrigin || '(local)'}`);
console.log(`🏭 Environment: ${isProduction ? 'production' : 'development'}`);
if (!isProduction) {
console.log(
'⚠️ Development mode: Using push notifications only (no caching)'
);
const devServiceWorker = fs.readFileSync(PATHS.pushHandlers, 'utf8');
fs.writeFileSync(PATHS.output, devServiceWorker);
console.log('✅ Development service worker created at public/sw.js');
return;
}
const assetManifest = generateAssetManifest();
try {
console.log('📦 Building service worker runtime...');
await build({
configFile: false,
css: {
postcss: {
plugins: [],
},
},
build: {
lib: {
entry: PATHS.swRuntime,
formats: ['iife'],
name: 'ServiceWorkerRuntime',
fileName: () => 'sw-runtime.js',
},
outDir: PATHS.buildDir,
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');
const runtimeBundle = fs.readFileSync(PATHS.swRuntimeBuilt, 'utf8');
const pushHandlers = fs.readFileSync(PATHS.pushHandlers, 'utf8');
const finalServiceWorker = buildSWContent(
runtimeBundle,
pushHandlers,
assetManifest.length
);
fs.writeFileSync(PATHS.output, 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);
}
}
buildServiceWorker().catch(err => {
console.error('❌ Unhandled error:', err);
process.exit(1);
});
@@ -1,5 +1,6 @@
/* eslint-disable no-restricted-globals, no-console */
/* eslint-disable no-restricted-globals */
/* globals clients */
self.addEventListener('push', event => {
let notification = event.data && event.data.json();
+142
View File
@@ -0,0 +1,142 @@
/* 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__;
const CACHE_NAME = 'chatwoot-assets-v1';
const EXCLUDED_PATHS = [
'/api/',
'/auth/',
'/rails/',
'/cable',
'/sidekiq',
'/super_admin',
'/swagger',
'/webhooks/',
'/widget',
'/survey/',
'/__vite',
'/sw.js',
];
const BATCH_SIZE = 5;
const buildAssetListChunks = (arr, size) => {
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
};
const prefetchBatch = async (cache, baseUrl, assets) => {
const fetchAndCacheAsset = async asset => {
const url = `${baseUrl}${asset.url}`;
if (await cache.match(url)) {
return;
}
try {
const response = await fetch(url);
if (response.ok) {
cache.put(url, response);
}
} catch {
// Ignore prefetch failures
}
};
await Promise.all(assets.map(fetchAndCacheAsset));
};
const cacheFirst = async request => {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
if (response.ok) {
cache.put(request, response.clone());
}
return response;
} catch (err) {
const stale = await cache.match(request);
if (stale) return stale;
throw err;
}
};
const prefetchAssets = async () => {
if (!ASSET_MANIFEST?.length) return;
const cache = await caches.open(CACHE_NAME);
const baseUrl = ASSET_ORIGIN
? `${ASSET_ORIGIN}${ASSET_PATH}`
: `${self.location.origin}${ASSET_PATH}`;
const batches = buildAssetListChunks(ASSET_MANIFEST, BATCH_SIZE);
await batches.reduce(
(promise, batch) =>
promise.then(() => prefetchBatch(cache, baseUrl, batch)),
Promise.resolve()
);
};
const cleanupStaleAssets = async () => {
if (!ASSET_MANIFEST?.length) return;
const cache = await caches.open(CACHE_NAME);
const cachedRequests = await cache.keys();
const baseUrl = ASSET_ORIGIN
? `${ASSET_ORIGIN}${ASSET_PATH}`
: `${self.location.origin}${ASSET_PATH}`;
const validUrls = new Set(
ASSET_MANIFEST.map(asset => `${baseUrl}${asset.url}`)
);
const deletions = cachedRequests
.filter(request => {
const url = new URL(request.url);
const isAssetPath = url.pathname.startsWith('/vite/assets/');
return isAssetPath && !validUrls.has(request.url);
})
.map(request => cache.delete(request));
await Promise.all(deletions);
};
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', event => {
event.waitUntil(
Promise.all([cleanupStaleAssets(), prefetchAssets(), self.clients.claim()])
);
});
self.addEventListener('fetch', event => {
const { request } = event;
const url = new URL(request.url);
if (EXCLUDED_PATHS.some(path => url.pathname.startsWith(path))) return;
if (request.method !== 'GET') return;
const isAChatwootRequest =
url.origin === self.location.origin ||
(ASSET_ORIGIN && url.origin === ASSET_ORIGIN);
if (!isAChatwootRequest) return;
const isAnAsset =
url.pathname.startsWith('/vite/') ||
url.origin === ASSET_ORIGIN ||
/\.(js|css|woff2?|ttf|otf|eot|svg|png)$/i.test(url.pathname);
if (isAnAsset) {
event.respondWith(cacheFirst(request));
}
});
+9 -3
View File
@@ -8,6 +8,12 @@ task before_assets_precompile: :environment do
system('echo "-------------- Bulding App for Production --------------"')
end
# every time you execute 'rake assets:precompile'
# run 'before_assets_precompile' first
Rake::Task['assets:precompile'].enhance %w[before_assets_precompile]
task after_assets_precompile: :environment do
# Build service worker after Vite has generated the asset manifest
system('echo "-------------- Building Service Worker --------------"')
system('NODE_ENV=production pnpm run build:sw')
end
Rake::Task['assets:precompile'].enhance %w[before_assets_precompile] do
Rake::Task['after_assets_precompile'].invoke
end
+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 app/javascript/service-worker/build.js",
"prepare": "husky install",
"size": "size-limit",
"story:dev": "histoire dev",