const CACHE_VERSION = 'v4';
const STATIC_ASSETS = [
'./',
'./index.html',
'./styles.css',
'./auth.js',
'./password-strength.js',
'./session.js',
'./storage.js',
'./coi-detector.js',
'./crypto_worker.js',
'./viewer.js',
'./router.js',
'./share.js',
'./stats.js',
'./search.js',
'./database.js',
'./conversation.js',
'./virtual-list.js',
'./attachments.js',
'./settings.js',
'./sw-register.js',
'./vendor/sqlite3.js',
'./vendor/sqlite3.wasm',
'./vendor/argon2-wasm.js',
'./vendor/fflate.min.js',
];
const LOG = {
ERROR: 0,
WARN: 1,
INFO: 2,
DEBUG: 3,
};
let logLevel = LOG.INFO;
function hashScopeId(input) {
let hash = 0x811c9dc5;
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash.toString(16).padStart(8, '0');
}
function getCacheScopeUrl() {
try {
return self.registration?.scope || self.location.href;
} catch (error) {
return self.location.href;
}
}
function getCacheName() {
return `cass-archive-${hashScopeId(getCacheScopeUrl())}-${CACHE_VERSION}`;
}
function getCachePrefix() {
return `cass-archive-${hashScopeId(getCacheScopeUrl())}-`;
}
function log(level, ...args) {
if (level <= logLevel) {
const prefix = ['[SW]', new Date().toISOString()];
const levelName = Object.keys(LOG).find(k => LOG[k] === level);
console.log(...prefix, `[${levelName}]`, ...args);
}
}
self.addEventListener('install', (event) => {
log(LOG.INFO, 'Installing service worker...');
const cacheName = getCacheName();
event.waitUntil(
caches.open(cacheName)
.then((cache) => {
log(LOG.INFO, 'Caching static assets');
return Promise.allSettled(
STATIC_ASSETS.map(asset =>
cache.add(asset).catch(e => {
log(LOG.WARN, `Failed to cache ${asset}:`, e.message);
})
)
);
})
.then(() => {
log(LOG.INFO, 'Service worker installed');
return self.skipWaiting();
})
.catch((error) => {
log(LOG.ERROR, 'Installation failed:', error);
})
);
});
self.addEventListener('activate', (event) => {
log(LOG.INFO, 'Activating service worker...');
const cacheName = getCacheName();
const cachePrefix = getCachePrefix();
event.waitUntil(
caches.keys()
.then((keys) => {
return Promise.all(
keys
.filter((key) => key.startsWith(cachePrefix) && key !== cacheName)
.map(key => {
log(LOG.INFO, 'Deleting old cache:', key);
return caches.delete(key);
})
);
})
.then((results) => {
if (!results.every(Boolean)) {
log(LOG.WARN, 'Some old caches could not be deleted during activation');
}
log(LOG.INFO, 'Service worker activated');
return self.clients.claim();
})
.catch((error) => {
log(LOG.ERROR, 'Activation failed:', error);
})
);
});
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (url.origin !== self.location.origin) {
return;
}
if (event.request.method !== 'GET') {
return;
}
event.respondWith(handleFetch(event.request));
});
async function handleFetch(request) {
const url = new URL(request.url);
const cacheName = getCacheName();
try {
const response = await fetch(request);
if (response.ok) {
try {
const cache = await caches.open(cacheName);
cache.put(request, response.clone()).catch(e => {
log(LOG.WARN, 'Cache put error:', e);
});
} catch (cacheError) {
log(LOG.WARN, 'Cache open error:', cacheError);
}
}
return addSecurityHeaders(response);
} catch (error) {
log(LOG.ERROR, 'Fetch failed:', url.pathname, error.message);
try {
const cached = await caches.match(request);
if (cached) {
log(LOG.INFO, 'Serving cached response after network failure:', url.pathname);
return addSecurityHeaders(cached.clone());
}
} catch (cacheError) {
log(LOG.WARN, 'Cache fallback error:', cacheError);
}
if (request.mode === 'navigate') {
try {
const cachedIndex = await caches.match('./index.html');
if (cachedIndex) {
log(LOG.INFO, 'Serving cached index.html for offline navigation');
return addSecurityHeaders(cachedIndex.clone());
}
} catch (cacheError) {
log(LOG.WARN, 'Navigation cache fallback error:', cacheError);
}
}
return new Response('Offline - Resource not cached', {
status: 503,
statusText: 'Service Unavailable',
headers: {
'Content-Type': 'text/plain',
},
});
}
}
function addSecurityHeaders(response) {
const headers = new Headers(response.headers);
headers.set('Cross-Origin-Opener-Policy', 'same-origin');
headers.set('Cross-Origin-Embedder-Policy', 'require-corp');
headers.set('Content-Security-Policy', [
"default-src 'self'",
"script-src 'self' 'wasm-unsafe-eval'",
"style-src 'self'",
"img-src 'self' data: blob:",
"connect-src 'self'",
"worker-src 'self' blob:",
"object-src 'none'",
"frame-ancestors 'none'",
"form-action 'none'",
"base-uri 'none'",
].join('; '));
headers.set('X-Content-Type-Options', 'nosniff');
headers.set('X-Frame-Options', 'DENY');
headers.set('Referrer-Policy', 'no-referrer');
headers.set('X-Robots-Tag', 'noindex, nofollow');
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
self.addEventListener('message', (event) => {
const respond = (message) => {
if (event.ports && event.ports[0]) {
event.ports[0].postMessage(message);
} else if (event.source) {
event.source.postMessage(message);
}
};
const rejectRequest = (error) => {
respond({
type: 'REQUEST_INVALID',
error,
});
};
const payload = event.data && typeof event.data === 'object' ? event.data : null;
if (!payload) {
log(LOG.WARN, 'Ignoring malformed message payload');
rejectRequest('Malformed message payload');
return;
}
const { type, ...data } = payload;
if (typeof type !== 'string' || type.length === 0) {
log(LOG.WARN, 'Ignoring message without a valid type');
rejectRequest('Message type must be a non-empty string');
return;
}
switch (type) {
case 'SKIP_WAITING':
self.skipWaiting();
break;
case 'GET_VERSION':
respond({
type: 'VERSION',
version: getCacheName(),
});
break;
case 'CLEAR_CACHE':
caches.keys()
.then((keys) => {
const cachePrefix = getCachePrefix();
const targets = keys.filter((key) => key.startsWith(cachePrefix));
return Promise.all(targets.map((key) => caches.delete(key))).then((results) => ({
targets,
cleared: results.every(Boolean),
}));
})
.then(({ targets, cleared }) => {
if (!cleared) {
throw new Error('Some cache entries could not be deleted');
}
respond({
type: 'CACHE_CLEARED',
cleared: targets,
});
})
.catch((error) => {
log(LOG.WARN, 'Failed to clear cache:', error);
respond({
type: 'CACHE_CLEAR_FAILED',
error: error?.message || String(error),
});
});
break;
case 'SET_LOG_LEVEL':
logLevel = data.level;
log(LOG.INFO, 'Log level set to:', Object.keys(LOG).find(k => LOG[k] === logLevel));
break;
default:
log(LOG.WARN, 'Unknown message type:', type);
rejectRequest(`Unknown message type: ${type}`);
}
});
log(LOG.INFO, 'Service worker script loaded');