1// public/sw-push.js â Sprint 2026-05-23 (Push Notifications) 2// 3// Browser service worker. Owns three concerns: 4// 1. push â render notification from server-pushed JSON payload 5// 2. notificationclick â open the click_url (focus tab if already open) 6// 3. message â none here yet; analytics post happens on 'click' 7// 8// Registered by components/PushOptInPrompt.tsx via: 9// navigator.serviceWorker.register('/sw-push.js', { scope: '/' }) 10// 11// IMPORTANT: this file is served from /public/ and runs in the SW thread. It 12// cannot import from app/ or lib/. Keep it self-contained. 13 14self.addEventListener('install', (event) => { 15 // Activate immediately on first install â no waiting for old tabs to close. 16 self.skipWaiting(); 17}); 18 19self.addEventListener('activate', (event) => { 20 event.waitUntil(self.clients.claim()); 21}); 22 23self.addEventListener('push', (event) => { 24 // Payload contract (set by lib/push-notify.ts): 25 // { title: string, body: string, url?: string, tag?: string, icon?: string } 26 let payload = {}; 27 try { 28 payload = event.data ? event.data.json() : {}; 29 } catch (_e) { 30 payload = { title: 'RealSmile', body: event.data ? event.data.text() : '' }; 31 } 32 33 const title = payload.title || 'RealSmile'; 34 const options = { 35 body: payload.body || '', 36 icon: payload.icon || '/icon-192.png', 37 badge: '/icon-192.png', 38 // tag collapses repeated notifications of the same kind (e.g. daily-tip) 39 tag: payload.tag || 'realsmile', 40 // Always re-notify so the user sees the latest tip if they ignored the previous. 41 renotify: true, 42 data: { 43 url: payload.url || '/account', 44 tag: payload.tag || 'realsmile', 45 sentAt: payload.sentAt || Date.now(), 46 }, 47 }; 48 49 event.waitUntil(self.registration.showNotification(title, options)); 50}); 51 52self.addEventListener('notificationclick', (event) => { 53 event.notification.close(); 54 const data = event.notification.data || {}; 55 const targetUrl = data.url || '/account'; 56 57 // Fire-and-forget click beacon to /api/push/track. Service worker fetch 58 // ignores the response â analytics is best-effort. 59 event.waitUntil( 60 (async () => { 61 try { 62 await fetch('/api/push/track', { 63 method: 'POST', 64 headers: { 'Content-Type': 'application/json' }, 65 body: JSON.stringify({ 66 event: 'click', 67 tag: data.tag || 'realsmile', 68 sentAt: data.sentAt || null, 69 }), 70 // keepalive lets the beacon survive SW shutdown. 71 keepalive: true, 72 }); 73 } catch (_e) { 74 // ignore 75 } 76 77 // Focus an existing realsmile tab if any, otherwise open a new one. 78 const allClients = await self.clients.matchAll({ 79 type: 'window', 80 includeUncontrolled: true, 81 }); 82 for (const client of allClients) { 83 try { 84 const u = new URL(client.url); 85 if (u.origin === self.location.origin) { 86 await client.focus(); 87 // Navigate the focused tab to the target URL. 88 if ('navigate' in client) { 89 try { 90 await client.navigate(targetUrl); 91 } catch (_e) { 92 // navigate can fail if cross-origin redirected â fall back to openWindow 93 } 94 } 95 return; 96 } 97 } catch (_e) { 98 // ignore 99 } 100 } 101 await self.clients.openWindow(targetUrl); 102 })(), 103 ); 104}); 105 106self.addEventListener('notificationclose', (event) => { 107 // Best-effort dismissal analytics â same endpoint, different event. 108 const data = event.notification.data || {}; 109 event.waitUntil( 110 (async () => { 111 try { 112 await fetch('/api/push/track', { 113 method: 'POST', 114 headers: { 'Content-Type': 'application/json' }, 115 body: JSON.stringify({ 116 event: 'dismiss', 117 tag: data.tag || 'realsmile', 118 sentAt: data.sentAt || null, 119 }), 120 keepalive: true, 121 }); 122 } catch (_e) { 123 // ignore 124 } 125 })(), 126 ); 127});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.