sw.js 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. // Bambuddy Service Worker
  2. const CACHE_NAME = 'bambuddy-v30';
  3. const STATIC_CACHE = 'bambuddy-static-v29';
  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 and claim existing clients.
  32. //
  33. // The forced reload that picks up a new bundle on already-open clients (the
  34. // kiosk deploy-pickup scenario) lives in sw-register.js via a
  35. // `controllerchange` listener, gated on whether the page already had a SW
  36. // controller at load time. That gate distinguishes first-install (where a
  37. // reload would race the in-flight React mount — observed on every fresh
  38. // *.demo.bambuddy.cool subdomain, and in Firefox the activate's waitUntil
  39. // hung on `client.navigate` until the document load was aborted with a
  40. // Corrupted-Content error) from upgrade-on-existing-client (where the reload
  41. // is wanted).
  42. self.addEventListener('activate', (event) => {
  43. console.log('[SW] Activating service worker...');
  44. event.waitUntil(
  45. (async () => {
  46. const cacheNames = await caches.keys();
  47. await Promise.all(
  48. cacheNames
  49. .filter((name) => name !== CACHE_NAME && name !== STATIC_CACHE)
  50. .map((name) => {
  51. console.log('[SW] Deleting old cache:', name);
  52. return caches.delete(name);
  53. }),
  54. );
  55. await self.clients.claim();
  56. })(),
  57. );
  58. });
  59. // Fetch event - network-first for API, cache-first for static
  60. self.addEventListener('fetch', (event) => {
  61. const { request } = event;
  62. const url = new URL(request.url);
  63. // Skip non-GET requests
  64. if (request.method !== 'GET') {
  65. return;
  66. }
  67. // Skip cross-origin requests - let the browser handle them directly.
  68. // Without this the catch-all HTML branch below would answer a failed
  69. // cross-origin request with our cached index.html, so e.g. a blocked
  70. // Google Fonts request came back as text/html (#1460).
  71. if (url.origin !== self.location.origin) {
  72. return;
  73. }
  74. // Skip WebSocket connections
  75. if (url.protocol === 'ws:' || url.protocol === 'wss:') {
  76. return;
  77. }
  78. // Skip camera stream/snapshot requests - Safari has issues with streaming through SW
  79. if (url.pathname.includes('/camera/stream') || url.pathname.includes('/camera/snapshot')) {
  80. return;
  81. }
  82. // API requests - network first, no cache (real-time data is critical)
  83. if (url.pathname.startsWith('/api/')) {
  84. event.respondWith(
  85. fetch(request).catch(() => {
  86. // Return offline response for API failures
  87. return new Response(
  88. JSON.stringify({ error: 'offline', message: 'You are currently offline' }),
  89. {
  90. status: 503,
  91. headers: { 'Content-Type': 'application/json' },
  92. }
  93. );
  94. })
  95. );
  96. return;
  97. }
  98. // Static assets - cache first, then network
  99. if (
  100. url.pathname.startsWith('/img/') ||
  101. url.pathname.startsWith('/icons/') ||
  102. url.pathname.startsWith('/fonts/') ||
  103. url.pathname.endsWith('.png') ||
  104. url.pathname.endsWith('.jpg') ||
  105. url.pathname.endsWith('.svg') ||
  106. url.pathname.endsWith('.ico') ||
  107. url.pathname.endsWith('.woff2')
  108. ) {
  109. event.respondWith(
  110. caches.match(request).then((cached) => {
  111. if (cached) {
  112. return cached;
  113. }
  114. return fetch(request).then((response) => {
  115. // Cache successful responses
  116. if (response.ok) {
  117. const clone = response.clone();
  118. caches.open(STATIC_CACHE).then((cache) => {
  119. cache.put(request, clone);
  120. });
  121. }
  122. return response;
  123. });
  124. })
  125. );
  126. return;
  127. }
  128. // JS/CSS assets - network first (Vite content-hashes filenames, so
  129. // cache-busting is built in; network-first ensures new builds load immediately)
  130. if (
  131. url.pathname.startsWith('/assets/') ||
  132. url.pathname.endsWith('.js') ||
  133. url.pathname.endsWith('.css')
  134. ) {
  135. event.respondWith(
  136. fetch(request)
  137. .then((response) => {
  138. if (response.ok) {
  139. const clone = response.clone();
  140. caches.open(CACHE_NAME).then((cache) => {
  141. cache.put(request, clone);
  142. });
  143. }
  144. return response;
  145. })
  146. .catch(() => {
  147. return caches.match(request);
  148. })
  149. );
  150. return;
  151. }
  152. // HTML pages - network first, fall back to cache
  153. event.respondWith(
  154. fetch(request)
  155. .then((response) => {
  156. if (response.ok) {
  157. const clone = response.clone();
  158. caches.open(CACHE_NAME).then((cache) => {
  159. cache.put(request, clone);
  160. });
  161. }
  162. return response;
  163. })
  164. .catch(() => {
  165. return caches.match(request).then((cached) => {
  166. if (cached) {
  167. return cached;
  168. }
  169. // Return cached index for SPA navigation
  170. return caches.match('/');
  171. });
  172. })
  173. );
  174. });
  175. // Handle push notifications (for future use)
  176. self.addEventListener('push', (event) => {
  177. if (!event.data) return;
  178. const data = event.data.json();
  179. const options = {
  180. body: data.body || 'New notification from Bambuddy',
  181. icon: '/img/android-chrome-192x192.png',
  182. badge: '/img/favicon-32x32.png',
  183. vibrate: [100, 50, 100],
  184. data: {
  185. url: data.url || '/',
  186. },
  187. };
  188. event.waitUntil(
  189. self.registration.showNotification(data.title || 'Bambuddy', options)
  190. );
  191. });
  192. // Handle notification clicks
  193. self.addEventListener('notificationclick', (event) => {
  194. event.notification.close();
  195. const url = event.notification.data?.url || '/';
  196. event.waitUntil(
  197. clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
  198. // Check if there's already a window open
  199. for (const client of windowClients) {
  200. if (client.url.includes(self.location.origin) && 'focus' in client) {
  201. client.navigate(url);
  202. return client.focus();
  203. }
  204. }
  205. // Open a new window if none exists
  206. if (clients.openWindow) {
  207. return clients.openWindow(url);
  208. }
  209. })
  210. );
  211. });