A chat no longer dies when the network moves under it. A NAT rebind, a lift, a Wi-Fi radio parking itself, a phone that dozed: the session repairs its own network path in place, and the messages typed meanwhile go out when it returns. Recovery is an ICE restart, which renegotiates only the transport path — the DTLS handshake, the session keys and the SCTP association carrying the data channel all sit above ICE and survive it. The renegotiation SDP therefore travels over the existing end-to-end encrypted, SAS-verified channel: no signalling service enters the design, and an attacker who cannot already decrypt the session cannot inject a reconnection. A restart is refused outright unless the DTLS fingerprint in the incoming SDP matches the live session's, so recovery can never re-point a conversation at a different peer. When the path is gone for good the session is ended and its data wiped rather than left half-alive: with no server there is nothing to re-signal through, and a conversation whose transport is gone should not leave its plaintext in an open tab. The two cases where that is already certain are recognised in seconds instead of being retried for two minutes — a channel that has delivered nothing at all since the drop cannot carry a renegotiation, and an ICE agent left bound to a network that no longer exists reports zero candidate pairs on every restart. Judging liveness was the hard part. Silence is not evidence of death: browsers freeze backgrounded tabs outright, and a frozen peer answers nothing while being perfectly healthy. What survives that freeze is ICE consent, which the browser runs in its network stack rather than on the page's thread — so a connected ICE state means a silent peer is asleep, and only a degraded one turns an unanswered probe into a teardown. The grace window before a restart is sized to the browser's own timings: 'disconnected' arrives after ~5s of missed consent responses and is held ~25s before 'failed', and that window exists for self-healing, so restarting at the start of it broke connections that were about to recover. Several long-standing bugs surfaced along the way and are fixed here: - handleHeartbeat() was dispatched to but never defined, so every inbound heartbeat threw a TypeError and peer liveness was never observed at all. - Heartbeats were folded into the 5-minute maintenance cycle instead of running on their own timer, far too coarse to notice a dead path. - ondatachannel can hand over a channel that is already open, so the answering side's 'open' event had been dispatched before the handler was assigned and never fired, leaving that side with no heartbeat, no watchdog and no file-transfer init. The peer whose network was fine kept showing "connected" indefinitely because nothing was running to notice. - Answering a heartbeat required the peer to have finished verifying, but the two sides confirm a SAS code at different moments; for that whole window one of them could not reply and was declared dead on a healthy connection. - Sending on a channel that was not ready returned in silence: the text stayed in the box, nothing was transmitted, and nothing said why. - The send path gated on navigator.onLine and the offline/online events, which report whether an interface exists rather than whether anything is reachable. A tab the OS froze misses the 'online' edge, and this side then queued every message forever: one tick on everything it sent, while incoming messages kept arriving. Sending is now decided by the data channel, and queues drain by polling rather than on an edge, so a missed event cannot strand them. - A false offline modal appeared on a working session, because the offline event was taken at face value. tests/session-recovery.test.mjs covers the state machine, the backoff and its serialisation, the offline hold, the sleeping-peer discriminator and the identity check.
545 lines
18 KiB
JavaScript
545 lines
18 KiB
JavaScript
// SecureBit.chat Service Worker
|
|
// Conservative PWA Edition v4.7.56 - Minimal Caching Strategy
|
|
// Enhanced with version-aware cache management
|
|
|
|
// Dynamic version detection from meta.json
|
|
let APP_VERSION = 'v4.7.56';
|
|
let CACHE_NAME = 'securebit-pwa-v4.7.56';
|
|
let STATIC_CACHE = 'securebit-pwa-static-v4.7.56';
|
|
let DYNAMIC_CACHE = 'securebit-pwa-dynamic-v4.7.56';
|
|
|
|
// Build stamp — rewritten by scripts/post-build.js on every release so this file's
|
|
// bytes change each deploy. That is what makes the browser detect a new Service Worker,
|
|
// reinstall it, drop stale caches and (via controllerchange) prompt the page to update.
|
|
const SW_BUILD_VERSION = '1785719685129';
|
|
|
|
// Load version from meta.json on install
|
|
async function getAppVersion() {
|
|
try {
|
|
const response = await fetch('/meta.json?t=' + Date.now(), {
|
|
cache: 'no-store',
|
|
headers: {
|
|
'Cache-Control': 'no-cache, no-store, must-revalidate'
|
|
}
|
|
});
|
|
if (response.ok) {
|
|
const meta = await response.json();
|
|
const version = meta.version || meta.buildVersion || 'v4.7.56';
|
|
APP_VERSION = version;
|
|
CACHE_NAME = `securebit-pwa-${version}`;
|
|
STATIC_CACHE = `securebit-pwa-static-${version}`;
|
|
DYNAMIC_CACHE = `securebit-pwa-dynamic-${version}`;
|
|
return version;
|
|
}
|
|
} catch (error) {
|
|
console.warn('⚠️ Failed to load version from meta.json, using default');
|
|
}
|
|
return APP_VERSION;
|
|
}
|
|
|
|
// Essential files for PWA offline functionality
|
|
// DO NOT include JS files from dist/ - they should load from network for updates
|
|
const STATIC_ASSETS = [
|
|
'/',
|
|
'/index.html',
|
|
'/manifest.json',
|
|
|
|
// DO NOT cache /dist/app.js and /dist/app-boot.js - they should be updated
|
|
// This allows the update system to work correctly
|
|
|
|
// Essential styles for PWA
|
|
'/src/styles/pwa.css',
|
|
|
|
// PWA icons (required for install)
|
|
'/logo/icon-192x192.png',
|
|
'/logo/icon-512x512.png',
|
|
'/logo/favicon.ico',
|
|
|
|
// PWA components only
|
|
'/src/pwa/pwa-manager.js',
|
|
'/src/pwa/install-prompt.js',
|
|
'/src/scripts/pwa-register.js',
|
|
'/src/scripts/pwa-offline-test.js'
|
|
];
|
|
|
|
// Sensitive files that should never be cached
|
|
const SENSITIVE_PATTERNS = [
|
|
/\/api\//,
|
|
/preimage/,
|
|
/payment/,
|
|
/session/,
|
|
/auth/,
|
|
/verification/
|
|
];
|
|
|
|
// Network first patterns (always try network first)
|
|
const NETWORK_FIRST_PATTERNS = [
|
|
/\/api\//,
|
|
/\/session\//,
|
|
/\/payment\//,
|
|
/\/verification\//,
|
|
/preimage/,
|
|
/auth/
|
|
];
|
|
|
|
// Cache first patterns (only essential PWA assets)
|
|
const CACHE_FIRST_PATTERNS = [
|
|
/manifest\.json$/,
|
|
/logo\/icon-.*\.png$/,
|
|
/logo\/favicon\.ico$/,
|
|
/src\/styles\/pwa\.css$/,
|
|
/src\/pwa\/.*\.js$/,
|
|
/src\/scripts\/pwa-.*\.js$/
|
|
];
|
|
|
|
// Explicit allowlist for any response that may be written to Cache Storage.
|
|
// Unknown GET responses are deliberately excluded by default.
|
|
const CACHEABLE_PATHS = new Set([
|
|
'/',
|
|
'/index.html',
|
|
'/manifest.json',
|
|
'/src/styles/pwa.css',
|
|
'/logo/icon-192x192.png',
|
|
'/logo/icon-512x512.png',
|
|
'/logo/favicon.ico',
|
|
'/src/pwa/pwa-manager.js',
|
|
'/src/pwa/install-prompt.js',
|
|
'/src/scripts/pwa-register.js',
|
|
'/src/scripts/pwa-offline-test.js'
|
|
]);
|
|
|
|
function isSensitivePath(pathname) {
|
|
return SENSITIVE_PATTERNS.some(pattern => pattern.test(pathname));
|
|
}
|
|
|
|
function isCacheableStaticPath(pathname) {
|
|
return CACHEABLE_PATHS.has(pathname);
|
|
}
|
|
|
|
self.addEventListener('message', (event) => {
|
|
if (event.data && event.data.type === 'PWA_INSTALLED') {
|
|
self.clients.matchAll().then(clients => {
|
|
clients.forEach(client => {
|
|
client.postMessage({ type: 'PWA_INSTALL_DETECTED' });
|
|
});
|
|
});
|
|
}
|
|
});
|
|
// Install event - cache static assets with better error handling
|
|
self.addEventListener('install', (event) => {
|
|
|
|
event.waitUntil(
|
|
getAppVersion().then(async (version) => {
|
|
console.log('📦 Service Worker installing with version:', version);
|
|
|
|
return caches.open(STATIC_CACHE)
|
|
.then(async (cache) => {
|
|
|
|
// Cache assets one by one to handle failures gracefully
|
|
const cachePromises = STATIC_ASSETS.map(async (url) => {
|
|
try {
|
|
// Skip sensitive patterns
|
|
if (SENSITIVE_PATTERNS.some(pattern => pattern.test(url))) {
|
|
return;
|
|
}
|
|
|
|
// Add cache-busting for meta.json
|
|
if (url.includes('meta.json')) {
|
|
url = url + '?t=' + Date.now();
|
|
}
|
|
|
|
await cache.add(url);
|
|
} catch (error) {
|
|
console.warn(`⚠️ Failed to cache ${url}:`, error.message);
|
|
// Continue with other assets even if one fails
|
|
}
|
|
});
|
|
|
|
await Promise.allSettled(cachePromises);
|
|
|
|
// Force activation of new service worker
|
|
return self.skipWaiting();
|
|
})
|
|
.catch((error) => {
|
|
console.error('❌ Failed to open cache:', error);
|
|
// Still skip waiting to activate the service worker
|
|
return self.skipWaiting();
|
|
});
|
|
})
|
|
);
|
|
});
|
|
|
|
// Activate event - clean up old caches and notify about updates
|
|
self.addEventListener('activate', (event) => {
|
|
|
|
event.waitUntil(
|
|
getAppVersion().then(async (version) => {
|
|
console.log('✅ Service Worker activating with version:', version);
|
|
|
|
const cacheNames = await caches.keys();
|
|
|
|
// Remove all old caches that don't match current version
|
|
const deletePromises = cacheNames.map(cacheName => {
|
|
// Remove caches that don't match current version
|
|
if (cacheName !== STATIC_CACHE &&
|
|
cacheName !== DYNAMIC_CACHE &&
|
|
cacheName !== CACHE_NAME &&
|
|
cacheName.startsWith('securebit-pwa-')) {
|
|
console.log(`🗑️ Removing old cache: ${cacheName}`);
|
|
return caches.delete(cacheName);
|
|
}
|
|
});
|
|
|
|
await Promise.all(deletePromises);
|
|
|
|
// Notify all clients about the update
|
|
return self.clients.claim().then(() => {
|
|
self.clients.matchAll().then(clients => {
|
|
clients.forEach(client => {
|
|
client.postMessage({
|
|
type: 'SW_ACTIVATED',
|
|
version: version,
|
|
timestamp: Date.now()
|
|
});
|
|
});
|
|
});
|
|
});
|
|
})
|
|
);
|
|
});
|
|
|
|
// Removed duplicate activate event code
|
|
|
|
// Fetch event - handle requests with security-aware caching
|
|
self.addEventListener('fetch', (event) => {
|
|
const url = new URL(event.request.url);
|
|
|
|
// Skip non-GET requests
|
|
if (event.request.method !== 'GET') {
|
|
return;
|
|
}
|
|
|
|
// Skip sensitive endpoints
|
|
if (isSensitivePath(url.pathname)) {
|
|
console.log('🔒 Skipping cache for sensitive endpoint:', url.pathname);
|
|
return;
|
|
}
|
|
|
|
// Skip chrome-extension and non-http requests
|
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
return;
|
|
}
|
|
|
|
// Network-first for meta.json (never cache)
|
|
if (url.pathname === '/meta.json' || url.pathname.endsWith('/meta.json')) {
|
|
event.respondWith(
|
|
fetch(event.request, {
|
|
cache: 'no-store',
|
|
headers: {
|
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
'Pragma': 'no-cache'
|
|
}
|
|
}).catch(() => {
|
|
// Fallback if network is unavailable
|
|
return new Response(JSON.stringify({
|
|
version: APP_VERSION,
|
|
error: 'Network unavailable'
|
|
}), {
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Network-first for JS files from dist/ (don't cache for updates)
|
|
if (url.pathname.startsWith('/dist/') && (url.pathname.endsWith('.js') || url.pathname.endsWith('.mjs'))) {
|
|
event.respondWith(
|
|
fetch(event.request, {
|
|
cache: 'no-store',
|
|
headers: {
|
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
'Pragma': 'no-cache'
|
|
}
|
|
}).catch((error) => {
|
|
// Log error for debugging
|
|
console.warn('⚠️ Failed to fetch JS file:', url.pathname, error.message);
|
|
// Try to get from cache as fallback
|
|
return caches.match(event.request).then(cachedResponse => {
|
|
if (cachedResponse) {
|
|
console.log('📦 Using cached version of:', url.pathname);
|
|
return cachedResponse;
|
|
}
|
|
// Only return 503 if no cache available
|
|
return new Response('Network unavailable', {
|
|
status: 503,
|
|
statusText: 'Service Unavailable',
|
|
headers: { 'Content-Type': 'text/plain' }
|
|
});
|
|
});
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
event.respondWith(handleRequest(event.request));
|
|
});
|
|
|
|
// Conservative request handling - only cache PWA essentials
|
|
async function handleRequest(request) {
|
|
const url = new URL(request.url);
|
|
|
|
try {
|
|
// Strategy 1: Cache First (only for essential PWA assets)
|
|
if (
|
|
url.origin === self.location.origin &&
|
|
isCacheableStaticPath(url.pathname) &&
|
|
!isSensitivePath(url.pathname) &&
|
|
CACHE_FIRST_PATTERNS.some(pattern => pattern.test(url.pathname))
|
|
) {
|
|
return await cacheFirst(request);
|
|
}
|
|
|
|
// Strategy 2: Network First (for all other requests)
|
|
if (NETWORK_FIRST_PATTERNS.some(pattern => pattern.test(url.pathname))) {
|
|
return await networkFirst(request);
|
|
}
|
|
|
|
// Strategy 3: Network First for everything else (no aggressive caching)
|
|
return await networkFirst(request);
|
|
|
|
} catch (error) {
|
|
console.error('❌ Request handling failed:', error);
|
|
return await handleOffline(request);
|
|
}
|
|
}
|
|
|
|
// Cache First strategy with Response cloning fix
|
|
async function cacheFirst(request) {
|
|
const url = new URL(request.url);
|
|
const cachedResponse = await caches.match(request);
|
|
if (cachedResponse) {
|
|
return cachedResponse;
|
|
}
|
|
|
|
try {
|
|
const networkResponse = await fetch(request);
|
|
if (
|
|
networkResponse &&
|
|
networkResponse.ok &&
|
|
url.origin === self.location.origin &&
|
|
isCacheableStaticPath(url.pathname) &&
|
|
!isSensitivePath(url.pathname)
|
|
) {
|
|
// Clone the response before using it
|
|
const responseToCache = networkResponse.clone();
|
|
const cache = await caches.open(STATIC_CACHE);
|
|
cache.put(request, responseToCache);
|
|
}
|
|
return networkResponse;
|
|
} catch (error) {
|
|
console.warn('⚠️ Cache-first strategy failed:', error.message);
|
|
return await handleOffline(request);
|
|
}
|
|
}
|
|
|
|
// Network First strategy with Response cloning fix
|
|
async function networkFirst(request) {
|
|
const url = new URL(request.url);
|
|
try {
|
|
const networkResponse = await fetch(request);
|
|
if (networkResponse && networkResponse.ok) {
|
|
// Only cache explicitly known-safe static responses.
|
|
if (
|
|
url.origin === self.location.origin &&
|
|
isCacheableStaticPath(url.pathname) &&
|
|
!isSensitivePath(url.pathname)
|
|
) {
|
|
// Clone the response before caching
|
|
const responseToCache = networkResponse.clone();
|
|
const cache = await caches.open(DYNAMIC_CACHE);
|
|
cache.put(request, responseToCache).catch(err => {
|
|
console.warn('⚠️ Cache put failed (non-critical):', err.message);
|
|
});
|
|
}
|
|
return networkResponse;
|
|
}
|
|
// If response is not ok, try cache
|
|
const cachedResponse = await caches.match(request);
|
|
if (cachedResponse) {
|
|
return cachedResponse;
|
|
}
|
|
return networkResponse; // Return the non-ok response anyway
|
|
} catch (error) {
|
|
console.warn('⚠️ Network-first strategy failed:', error.message);
|
|
const cachedResponse = await caches.match(request);
|
|
if (cachedResponse) {
|
|
return cachedResponse;
|
|
}
|
|
return await handleOffline(request);
|
|
}
|
|
}
|
|
|
|
// Stale While Revalidate strategy with Response cloning fix
|
|
async function staleWhileRevalidate(request) {
|
|
const url = new URL(request.url);
|
|
const cachedResponse = await caches.match(request);
|
|
|
|
const networkResponsePromise = fetch(request)
|
|
.then((networkResponse) => {
|
|
if (
|
|
networkResponse &&
|
|
networkResponse.ok &&
|
|
url.origin === self.location.origin &&
|
|
isCacheableStaticPath(url.pathname) &&
|
|
!isSensitivePath(url.pathname)
|
|
) {
|
|
// Clone the response before caching
|
|
const responseToCache = networkResponse.clone();
|
|
caches.open(DYNAMIC_CACHE)
|
|
.then(cache => cache.put(request, responseToCache))
|
|
.catch(error => console.warn('⚠️ Cache update failed:', error.message));
|
|
}
|
|
return networkResponse;
|
|
})
|
|
.catch(error => {
|
|
console.warn('⚠️ Network request failed:', error.message);
|
|
return null;
|
|
});
|
|
|
|
return cachedResponse || networkResponsePromise || handleOffline(request);
|
|
}
|
|
|
|
// Offline fallback - minimal caching for PWA only
|
|
async function handleOffline(request) {
|
|
const url = new URL(request.url);
|
|
|
|
// For navigation requests, return cached index.html
|
|
if (request.destination === 'document' || request.mode === 'navigate') {
|
|
const cachedIndex = await caches.match('/index.html');
|
|
if (cachedIndex) {
|
|
return cachedIndex;
|
|
}
|
|
|
|
// Fallback to root if index.html not found
|
|
const cachedRoot = await caches.match('/');
|
|
if (cachedRoot) {
|
|
return cachedRoot;
|
|
}
|
|
}
|
|
|
|
// For PWA assets, try to return cached version
|
|
if (CACHE_FIRST_PATTERNS.some(pattern => pattern.test(url.pathname))) {
|
|
const cachedAsset = await caches.match(request);
|
|
if (cachedAsset) {
|
|
return cachedAsset;
|
|
}
|
|
}
|
|
|
|
// Return a generic offline response for everything else
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: 'Offline',
|
|
message: 'Network unavailable - PWA offline mode',
|
|
url: url.pathname
|
|
}),
|
|
{
|
|
status: 503,
|
|
statusText: 'Service Unavailable',
|
|
headers: { 'Content-Type': 'application/json' }
|
|
}
|
|
);
|
|
}
|
|
|
|
// Background sync for failed requests
|
|
self.addEventListener('sync', (event) => {
|
|
|
|
if (event.tag === 'retry-failed-requests') {
|
|
event.waitUntil(retryFailedRequests());
|
|
}
|
|
});
|
|
|
|
async function retryFailedRequests() {
|
|
try {
|
|
// Get all cached requests that failed
|
|
const cache = await caches.open(DYNAMIC_CACHE);
|
|
const requests = await cache.keys();
|
|
|
|
for (const request of requests) {
|
|
try {
|
|
// Try to fetch the request again
|
|
const response = await fetch(request);
|
|
if (response.ok) {
|
|
// Update cache with successful response
|
|
await cache.put(request, response);
|
|
}
|
|
} catch (error) {
|
|
console.warn('⚠️ Retry failed for:', request.url, error.message);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ Failed to retry requests:', error);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// Notification click handler
|
|
self.addEventListener('notificationclick', (event) => {
|
|
event.notification.close();
|
|
|
|
event.waitUntil(
|
|
clients.openWindow('/')
|
|
);
|
|
});
|
|
|
|
// Message handler for communication with main thread
|
|
self.addEventListener('message', (event) => {
|
|
|
|
if (event.data && event.data.type === 'SKIP_WAITING') {
|
|
self.skipWaiting();
|
|
}
|
|
|
|
if (event.data && event.data.type === 'CACHE_CLEAR') {
|
|
event.waitUntil(clearCaches());
|
|
}
|
|
|
|
if (event.data && event.data.type === 'CACHE_STATUS') {
|
|
event.waitUntil(getCacheStatus().then(status => {
|
|
event.ports[0].postMessage(status);
|
|
}));
|
|
}
|
|
});
|
|
|
|
// Clear all caches
|
|
async function clearCaches() {
|
|
const cacheNames = await caches.keys();
|
|
await Promise.all(
|
|
cacheNames.map(cacheName => caches.delete(cacheName))
|
|
);
|
|
}
|
|
|
|
// Get cache status
|
|
async function getCacheStatus() {
|
|
const cacheNames = await caches.keys();
|
|
const status = {};
|
|
|
|
for (const cacheName of cacheNames) {
|
|
const cache = await caches.open(cacheName);
|
|
const keys = await cache.keys();
|
|
status[cacheName] = keys.length;
|
|
}
|
|
|
|
return status;
|
|
}
|
|
|
|
// Error handler
|
|
self.addEventListener('error', (event) => {
|
|
console.error('❌ Service Worker error:', event.error);
|
|
});
|
|
|
|
// Unhandled rejection handler
|
|
self.addEventListener('unhandledrejection', (event) => {
|
|
console.error('❌ Service Worker unhandled rejection:', event.reason);
|
|
});
|