mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
Repro on every fresh demo subdomain: visitor lands on Printers OK, first
sidebar click sticks on a spinner (Chromium) or trips "Corrupted Content
Error" with sw.js stuck `activating` (Firefox). Manual reload recovers.
Root cause is the `client.navigate(client.url)` call added to the activate
handler in 18d534c9 (intended to force kiosks to pick up new bundles).
Its guard — `client.url && typeof client.navigate === 'function'` — does
not distinguish first install from upgrade. On any fresh origin (every
demo session, every first-time visitor, every cleared profile) it still
fired: Chromium raced it against the in-flight SPA mount; Firefox
deadlocked the activate's waitUntil on `await client.navigate(...)`
because the SW intercepts its own document fetch while still activating.
Split the lifecycle correctly:
- sw.js activate handler: just cache cleanup + clients.claim().
- sw-register.js: capture `hadController = !!serviceWorker.controller`
at load, listen for `controllerchange`, reload only when hadController
was true. Returning kiosk hits a new deploy -> had controller -> reloads
as before; first-install visitor -> no controller -> no forced nav ->
React mount completes.
Bump CACHE_NAME v29->v30 and STATIC_CACHE v28->v29 so existing browsers
fetching the new sw.js drop the old CacheStorage in the same pass.
SpoolBuddy unregister branch and notificationclick's client.navigate are
unrelated and unchanged.
229 lines
6.6 KiB
JavaScript
229 lines
6.6 KiB
JavaScript
// Bambuddy Service Worker
|
|
const CACHE_NAME = 'bambuddy-v30';
|
|
const STATIC_CACHE = 'bambuddy-static-v29';
|
|
|
|
// Static assets to cache on install
|
|
const STATIC_ASSETS = [
|
|
'/',
|
|
'/manifest.json',
|
|
'/img/favicon.png',
|
|
'/img/favicon-16x16.png',
|
|
'/img/favicon-32x32.png',
|
|
'/img/android-chrome-192x192.png',
|
|
'/img/android-chrome-512x512.png',
|
|
'/img/apple-touch-icon.png',
|
|
'/img/bambuddy_logo_dark.png',
|
|
// Self-hosted Inter font (#1460) - cached so the UI renders offline.
|
|
'/fonts/inter-latin.woff2',
|
|
'/fonts/inter-latin-ext.woff2',
|
|
];
|
|
|
|
// Install event - cache static assets
|
|
self.addEventListener('install', (event) => {
|
|
console.log('[SW] Installing service worker...');
|
|
event.waitUntil(
|
|
caches.open(STATIC_CACHE).then((cache) => {
|
|
console.log('[SW] Caching static assets');
|
|
return cache.addAll(STATIC_ASSETS);
|
|
})
|
|
);
|
|
// Activate immediately
|
|
self.skipWaiting();
|
|
});
|
|
|
|
// Activate event - clean up old caches and claim existing clients.
|
|
//
|
|
// The forced reload that picks up a new bundle on already-open clients (the
|
|
// kiosk deploy-pickup scenario) lives in sw-register.js via a
|
|
// `controllerchange` listener, gated on whether the page already had a SW
|
|
// controller at load time. That gate distinguishes first-install (where a
|
|
// reload would race the in-flight React mount — observed on every fresh
|
|
// *.demo.bambuddy.cool subdomain, and in Firefox the activate's waitUntil
|
|
// hung on `client.navigate` until the document load was aborted with a
|
|
// Corrupted-Content error) from upgrade-on-existing-client (where the reload
|
|
// is wanted).
|
|
self.addEventListener('activate', (event) => {
|
|
console.log('[SW] Activating service worker...');
|
|
event.waitUntil(
|
|
(async () => {
|
|
const cacheNames = await caches.keys();
|
|
await Promise.all(
|
|
cacheNames
|
|
.filter((name) => name !== CACHE_NAME && name !== STATIC_CACHE)
|
|
.map((name) => {
|
|
console.log('[SW] Deleting old cache:', name);
|
|
return caches.delete(name);
|
|
}),
|
|
);
|
|
await self.clients.claim();
|
|
})(),
|
|
);
|
|
});
|
|
|
|
// Fetch event - network-first for API, cache-first for static
|
|
self.addEventListener('fetch', (event) => {
|
|
const { request } = event;
|
|
const url = new URL(request.url);
|
|
|
|
// Skip non-GET requests
|
|
if (request.method !== 'GET') {
|
|
return;
|
|
}
|
|
|
|
// Skip cross-origin requests - let the browser handle them directly.
|
|
// Without this the catch-all HTML branch below would answer a failed
|
|
// cross-origin request with our cached index.html, so e.g. a blocked
|
|
// Google Fonts request came back as text/html (#1460).
|
|
if (url.origin !== self.location.origin) {
|
|
return;
|
|
}
|
|
|
|
// Skip WebSocket connections
|
|
if (url.protocol === 'ws:' || url.protocol === 'wss:') {
|
|
return;
|
|
}
|
|
|
|
// Skip camera stream/snapshot requests - Safari has issues with streaming through SW
|
|
if (url.pathname.includes('/camera/stream') || url.pathname.includes('/camera/snapshot')) {
|
|
return;
|
|
}
|
|
|
|
// API requests - network first, no cache (real-time data is critical)
|
|
if (url.pathname.startsWith('/api/')) {
|
|
event.respondWith(
|
|
fetch(request).catch(() => {
|
|
// Return offline response for API failures
|
|
return new Response(
|
|
JSON.stringify({ error: 'offline', message: 'You are currently offline' }),
|
|
{
|
|
status: 503,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}
|
|
);
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Static assets - cache first, then network
|
|
if (
|
|
url.pathname.startsWith('/img/') ||
|
|
url.pathname.startsWith('/icons/') ||
|
|
url.pathname.startsWith('/fonts/') ||
|
|
url.pathname.endsWith('.png') ||
|
|
url.pathname.endsWith('.jpg') ||
|
|
url.pathname.endsWith('.svg') ||
|
|
url.pathname.endsWith('.ico') ||
|
|
url.pathname.endsWith('.woff2')
|
|
) {
|
|
event.respondWith(
|
|
caches.match(request).then((cached) => {
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
return fetch(request).then((response) => {
|
|
// Cache successful responses
|
|
if (response.ok) {
|
|
const clone = response.clone();
|
|
caches.open(STATIC_CACHE).then((cache) => {
|
|
cache.put(request, clone);
|
|
});
|
|
}
|
|
return response;
|
|
});
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
// JS/CSS assets - network first (Vite content-hashes filenames, so
|
|
// cache-busting is built in; network-first ensures new builds load immediately)
|
|
if (
|
|
url.pathname.startsWith('/assets/') ||
|
|
url.pathname.endsWith('.js') ||
|
|
url.pathname.endsWith('.css')
|
|
) {
|
|
event.respondWith(
|
|
fetch(request)
|
|
.then((response) => {
|
|
if (response.ok) {
|
|
const clone = response.clone();
|
|
caches.open(CACHE_NAME).then((cache) => {
|
|
cache.put(request, clone);
|
|
});
|
|
}
|
|
return response;
|
|
})
|
|
.catch(() => {
|
|
return caches.match(request);
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
// HTML pages - network first, fall back to cache
|
|
event.respondWith(
|
|
fetch(request)
|
|
.then((response) => {
|
|
if (response.ok) {
|
|
const clone = response.clone();
|
|
caches.open(CACHE_NAME).then((cache) => {
|
|
cache.put(request, clone);
|
|
});
|
|
}
|
|
return response;
|
|
})
|
|
.catch(() => {
|
|
return caches.match(request).then((cached) => {
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
// Return cached index for SPA navigation
|
|
return caches.match('/');
|
|
});
|
|
})
|
|
);
|
|
});
|
|
|
|
// Handle push notifications (for future use)
|
|
self.addEventListener('push', (event) => {
|
|
if (!event.data) return;
|
|
|
|
const data = event.data.json();
|
|
const options = {
|
|
body: data.body || 'New notification from Bambuddy',
|
|
icon: '/img/android-chrome-192x192.png',
|
|
badge: '/img/favicon-32x32.png',
|
|
vibrate: [100, 50, 100],
|
|
data: {
|
|
url: data.url || '/',
|
|
},
|
|
};
|
|
|
|
event.waitUntil(
|
|
self.registration.showNotification(data.title || 'Bambuddy', options)
|
|
);
|
|
});
|
|
|
|
// Handle notification clicks
|
|
self.addEventListener('notificationclick', (event) => {
|
|
event.notification.close();
|
|
|
|
const url = event.notification.data?.url || '/';
|
|
|
|
event.waitUntil(
|
|
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
|
|
// Check if there's already a window open
|
|
for (const client of windowClients) {
|
|
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
|
client.navigate(url);
|
|
return client.focus();
|
|
}
|
|
}
|
|
// Open a new window if none exists
|
|
if (clients.openWindow) {
|
|
return clients.openWindow(url);
|
|
}
|
|
})
|
|
);
|
|
});
|