Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d18be84b45 | ||
|
|
b023487c3e | ||
|
|
40d944b7d7 | ||
|
|
b7a3bb892d | ||
|
|
08c415ea0e | ||
|
|
6d662cf339 | ||
|
|
9d32e27c98 | ||
|
|
52625c8138 | ||
|
|
1b9f496832 | ||
|
|
3527c07e94 | ||
|
|
b345826566 | ||
|
|
6d3d76982a | ||
|
|
59427c4de3 | ||
|
|
7787ec1265 | ||
|
|
8daac4cdd1 | ||
|
|
adbee23e12 | ||
|
|
38232a73dc | ||
|
|
0d21a711e7 | ||
|
|
f5d538ba48 | ||
|
|
f713678328 | ||
|
|
d34df7c314 |
@@ -103,4 +103,5 @@ CLAUDE.local.md
|
||||
.histoire
|
||||
.pnpm-store/*
|
||||
local/
|
||||
sw.js
|
||||
Procfile.worktree
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user