sw.js 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. // Bambuddy Service Worker
  2. const CACHE_NAME = 'bambuddy-v29';
  3. const STATIC_CACHE = 'bambuddy-static-v28';
  4. // Static assets to cache on install
  5. const STATIC_ASSETS = [
  6. '/',
  7. '/manifest.json',
  8. '/img/favicon.png',
  9. '/img/favicon-16x16.png',
  10. '/img/favicon-32x32.png',
  11. '/img/android-chrome-192x192.png',
  12. '/img/android-chrome-512x512.png',
  13. '/img/apple-touch-icon.png',
  14. '/img/bambuddy_logo_dark.png',
  15. // Self-hosted Inter font (#1460) - cached so the UI renders offline.
  16. '/fonts/inter-latin.woff2',
  17. '/fonts/inter-latin-ext.woff2',
  18. ];
  19. // Install event - cache static assets
  20. self.addEventListener('install', (event) => {
  21. console.log('[SW] Installing service worker...');
  22. event.waitUntil(
  23. caches.open(STATIC_CACHE).then((cache) => {
  24. console.log('[SW] Caching static assets');
  25. return cache.addAll(STATIC_ASSETS);
  26. })
  27. );
  28. // Activate immediately
  29. self.skipWaiting();
  30. });
  31. // Activate event - clean up old caches, then force-reload any controlled
  32. // windows so they pick up the new bundle. Important for the SpoolBuddy kiosk
  33. // (Pi + Chromium-in-kiosk-mode, no devtools, no manual reload control):
  34. // without this hop, restarting Chromium installs the new SW but the existing
  35. // document keeps running the previously-cached bundle until a navigation
  36. // happens — which on a locked kiosk never occurs.
  37. self.addEventListener('activate', (event) => {
  38. console.log('[SW] Activating service worker...');
  39. event.waitUntil(
  40. (async () => {
  41. const cacheNames = await caches.keys();
  42. await Promise.all(
  43. cacheNames
  44. .filter((name) => name !== CACHE_NAME && name !== STATIC_CACHE)
  45. .map((name) => {
  46. console.log('[SW] Deleting old cache:', name);
  47. return caches.delete(name);
  48. }),
  49. );
  50. // Take control immediately.
  51. await self.clients.claim();
  52. // Force a fresh navigation in any window that this SW now controls.
  53. // ``client.navigate(client.url)`` re-requests the page through the
  54. // network-first fetch handler, picking up the new index.html + the
  55. // new content-hashed JS bundle. Guarded so the very first install on
  56. // a never-controlled client doesn't trigger an unwanted reload.
  57. const clients = await self.clients.matchAll({ type: 'window' });
  58. for (const client of clients) {
  59. try {
  60. if (client.url && typeof client.navigate === 'function') {
  61. await client.navigate(client.url);
  62. }
  63. } catch (e) {
  64. // Some browsers reject navigate on cross-origin or detached
  65. // clients — swallow so one bad client doesn't break the rest.
  66. console.warn('[SW] Forced reload skipped for client:', client.url, e);
  67. }
  68. }
  69. })(),
  70. );
  71. });
  72. // Fetch event - network-first for API, cache-first for static
  73. self.addEventListener('fetch', (event) => {
  74. const { request } = event;
  75. const url = new URL(request.url);
  76. // Skip non-GET requests
  77. if (request.method !== 'GET') {
  78. return;
  79. }
  80. // Skip cross-origin requests - let the browser handle them directly.
  81. // Without this the catch-all HTML branch below would answer a failed
  82. // cross-origin request with our cached index.html, so e.g. a blocked
  83. // Google Fonts request came back as text/html (#1460).
  84. if (url.origin !== self.location.origin) {
  85. return;
  86. }
  87. // Skip WebSocket connections
  88. if (url.protocol === 'ws:' || url.protocol === 'wss:') {
  89. return;
  90. }
  91. // Skip camera stream/snapshot requests - Safari has issues with streaming through SW
  92. if (url.pathname.includes('/camera/stream') || url.pathname.includes('/camera/snapshot')) {
  93. return;
  94. }
  95. // API requests - network first, no cache (real-time data is critical)
  96. if (url.pathname.startsWith('/api/')) {
  97. event.respondWith(
  98. fetch(request).catch(() => {
  99. // Return offline response for API failures
  100. return new Response(
  101. JSON.stringify({ error: 'offline', message: 'You are currently offline' }),
  102. {
  103. status: 503,
  104. headers: { 'Content-Type': 'application/json' },
  105. }
  106. );
  107. })
  108. );
  109. return;
  110. }
  111. // Static assets - cache first, then network
  112. if (
  113. url.pathname.startsWith('/img/') ||
  114. url.pathname.startsWith('/icons/') ||
  115. url.pathname.startsWith('/fonts/') ||
  116. url.pathname.endsWith('.png') ||
  117. url.pathname.endsWith('.jpg') ||
  118. url.pathname.endsWith('.svg') ||
  119. url.pathname.endsWith('.ico') ||
  120. url.pathname.endsWith('.woff2')
  121. ) {
  122. event.respondWith(
  123. caches.match(request).then((cached) => {
  124. if (cached) {
  125. return cached;
  126. }
  127. return fetch(request).then((response) => {
  128. // Cache successful responses
  129. if (response.ok) {
  130. const clone = response.clone();
  131. caches.open(STATIC_CACHE).then((cache) => {
  132. cache.put(request, clone);
  133. });
  134. }
  135. return response;
  136. });
  137. })
  138. );
  139. return;
  140. }
  141. // JS/CSS assets - network first (Vite content-hashes filenames, so
  142. // cache-busting is built in; network-first ensures new builds load immediately)
  143. if (
  144. url.pathname.startsWith('/assets/') ||
  145. url.pathname.endsWith('.js') ||
  146. url.pathname.endsWith('.css')
  147. ) {
  148. event.respondWith(
  149. fetch(request)
  150. .then((response) => {
  151. if (response.ok) {
  152. const clone = response.clone();
  153. caches.open(CACHE_NAME).then((cache) => {
  154. cache.put(request, clone);
  155. });
  156. }
  157. return response;
  158. })
  159. .catch(() => {
  160. return caches.match(request);
  161. })
  162. );
  163. return;
  164. }
  165. // HTML pages - network first, fall back to cache
  166. event.respondWith(
  167. fetch(request)
  168. .then((response) => {
  169. if (response.ok) {
  170. const clone = response.clone();
  171. caches.open(CACHE_NAME).then((cache) => {
  172. cache.put(request, clone);
  173. });
  174. }
  175. return response;
  176. })
  177. .catch(() => {
  178. return caches.match(request).then((cached) => {
  179. if (cached) {
  180. return cached;
  181. }
  182. // Return cached index for SPA navigation
  183. return caches.match('/');
  184. });
  185. })
  186. );
  187. });
  188. // Handle push notifications (for future use)
  189. self.addEventListener('push', (event) => {
  190. if (!event.data) return;
  191. const data = event.data.json();
  192. const options = {
  193. body: data.body || 'New notification from Bambuddy',
  194. icon: '/img/android-chrome-192x192.png',
  195. badge: '/img/favicon-32x32.png',
  196. vibrate: [100, 50, 100],
  197. data: {
  198. url: data.url || '/',
  199. },
  200. };
  201. event.waitUntil(
  202. self.registration.showNotification(data.title || 'Bambuddy', options)
  203. );
  204. });
  205. // Handle notification clicks
  206. self.addEventListener('notificationclick', (event) => {
  207. event.notification.close();
  208. const url = event.notification.data?.url || '/';
  209. event.waitUntil(
  210. clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
  211. // Check if there's already a window open
  212. for (const client of windowClients) {
  213. if (client.url.includes(self.location.origin) && 'focus' in client) {
  214. client.navigate(url);
  215. return client.focus();
  216. }
  217. }
  218. // Open a new window if none exists
  219. if (clients.openWindow) {
  220. return clients.openWindow(url);
  221. }
  222. })
  223. );
  224. });